mirror of
				https://github.com/nsnail/NetAdmin.git
				synced 2025-10-31 11:25:27 +08:00 
			
		
		
		
	feat: ✨ 移除RedLocker,更改为自实现 (#169)
用户表增加最后登录时间字段 列表查询多字段模糊查询改为单字段精确查询 WebSocket版本更新检查 前端自定义字段筛选 暗黑模式样式调整 [skip ci] Co-authored-by: tk <fiyne1a@dingtalk.com>
This commit is contained in:
		| @@ -78,6 +78,7 @@ | ||||
| 日期范围 | ||||
| 是否启用 | ||||
| 显示仪表板 | ||||
| 最后登录时间 | ||||
| 未处理异常 | ||||
| 未婚 | ||||
| 未读 | ||||
|   | ||||
| @@ -23,7 +23,7 @@ | ||||
|             <PrivateAssets>all</PrivateAssets> | ||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|         </PackageReference> | ||||
|         <PackageReference Include="SonarAnalyzer.CSharp" Version="9.30.0.95878"> | ||||
|         <PackageReference Include="SonarAnalyzer.CSharp" Version="9.31.0.96804"> | ||||
|             <PrivateAssets>all</PrivateAssets> | ||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|         </PackageReference> | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|   "version": "1.5.0", | ||||
|   "devDependencies": { | ||||
|     "cz-git": "^1.9.3", | ||||
|     "cz-git": "^1.9.4", | ||||
|     "commitizen": "^4.3.0", | ||||
|     "prettier": "^3.3.3", | ||||
|     "standard-version": "^9.5.0" | ||||
| @@ -11,4 +11,4 @@ | ||||
|       "path": "node_modules/cz-git" | ||||
|     } | ||||
|   } | ||||
| } | ||||
| } | ||||
| @@ -9,7 +9,7 @@ | ||||
|       "packages": [ | ||||
|         { | ||||
|           "packageName": "Furion.Pure.NS", | ||||
|           "version": "4.9.4.6-ns4" | ||||
|           "version": "4.9.5.2-ns1" | ||||
|         } | ||||
|       ] | ||||
|     } | ||||
|   | ||||
| @@ -3,11 +3,9 @@ using NetAdmin.AdmServer.Host.Extensions; | ||||
| using NetAdmin.Host.Extensions; | ||||
| using NetAdmin.Host.Middlewares; | ||||
| using NetAdmin.SysComponent.Host.Extensions; | ||||
| using NetAdmin.SysComponent.Host.Middlewares; | ||||
| using Spectre.Console.Cli; | ||||
| using ValidationResult = Spectre.Console.ValidationResult; | ||||
| #if !DEBUG | ||||
| using Prometheus; | ||||
| #endif | ||||
|  | ||||
| NetAdmin.Host.Startup.Entry<Startup>(args); | ||||
|  | ||||
| @@ -36,7 +34,7 @@ namespace NetAdmin.AdmServer.Host | ||||
|                 .UseOpenApiSkin() // 使用OpenApiSkin中间件(仅在调试模式下),提供Swagger UI皮肤 | ||||
|                 #else | ||||
|                 .UseVueAdmin()    // 托管管理后台,仅在非调试模式下 | ||||
|                 .UseHttpMetrics() // 使用HttpMetrics中间件,启用HTTP性能监控 | ||||
|                 .UsePrometheus()  // 使用Prometheus中间件,启用HTTP性能监控 | ||||
|                 #endif | ||||
|                 .UseInject(string.Empty)                   // 使用Inject中间件,Furion脚手架的依赖注入支持 | ||||
|                 .UseUnifyResultStatusCodes()               // 使用UnifyResultStatusCodes中间件,用于统一处理结果状态码 | ||||
| @@ -45,6 +43,8 @@ namespace NetAdmin.AdmServer.Host | ||||
|                 .UseAuthentication()                       // 使用Authentication中间件,启用身份验证 | ||||
|                 .UseAuthorization()                        // 使用Authorization中间件,启用授权 | ||||
|                 .UseMiddleware<RemoveNullNodeMiddleware>() // 使用RemoveNullNodeMiddleware中间件,删除JSON中的空节点 | ||||
|                 .UseWebSockets()                           // 使用WebSockets中间件,启用WebSocket支持 | ||||
|                 .UseMiddleware<VersionCheckerMiddleware>() // 使用VersionUpdaterMiddleware中间件,用于检查版本 | ||||
|                 .UseEndpoints();                           // 配置端点以处理请求 | ||||
|             _ = lifeTime.ApplicationStopping.Register(SafetyShopHostMiddleware.OnStopping); | ||||
|         } | ||||
|   | ||||
| @@ -4,6 +4,6 @@ | ||||
|         <ProjectReference Include="../NetAdmin.AdmServer.Host/NetAdmin.AdmServer.Host.csproj"/> | ||||
|     </ItemGroup> | ||||
|     <ItemGroup> | ||||
|         <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0-release-24352-06"/> | ||||
|         <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0-release-24373-02"/> | ||||
|     </ItemGroup> | ||||
| </Project> | ||||
| @@ -1,50 +0,0 @@ | ||||
| using NetAdmin.Application.Repositories; | ||||
| using RedLockNet; | ||||
|  | ||||
| namespace NetAdmin.Application.Services; | ||||
|  | ||||
| /// <summary> | ||||
| ///     RedLocker Service Base | ||||
| /// </summary> | ||||
| public abstract class RedLockerService<TEntity, TPrimary, TLogger>( | ||||
|     BasicRepository<TEntity, TPrimary> rpo | ||||
|   , RedLocker                          redLocker) : RepositoryService<TEntity, TPrimary, TLogger>(rpo) | ||||
|     where TEntity : EntityBase<TPrimary> // | ||||
|     where TPrimary : IEquatable<TPrimary> | ||||
| { | ||||
|     /// <summary> | ||||
|     ///     获取锁 | ||||
|     /// </summary> | ||||
|     protected Task<IRedLock> GetLockerAsync(string lockName) | ||||
|     { | ||||
|         return GetLockerAsync(lockName, TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_WAIT) | ||||
|                   ,                     TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_RETRY_INTERVAL)); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     获取锁 | ||||
|     /// </summary> | ||||
|     /// <exception cref="NetAdminGetLockerException">NetAdminGetLockerException</exception> | ||||
|     protected async Task<IRedLock> GetLockerAsync(string lockName, TimeSpan waitTime, TimeSpan retryInterval) | ||||
|     { | ||||
|         // 加锁 | ||||
|         var lockTask = waitTime == default || retryInterval == default | ||||
|             ? redLocker.RedLockFactory.CreateLockAsync(lockName, TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_EXPIRY)) | ||||
|             : redLocker.RedLockFactory.CreateLockAsync( // | ||||
|                 lockName, TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_EXPIRY), waitTime, retryInterval); | ||||
|         var redLock = await lockTask.ConfigureAwait(false); | ||||
|  | ||||
|         return redLock.IsAcquired ? redLock : throw new NetAdminGetLockerException(); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     获取锁 | ||||
|     /// </summary> | ||||
|     /// <remarks> | ||||
|     ///     不重试,失败直接抛出异常 | ||||
|     /// </remarks> | ||||
|     protected Task<IRedLock> GetLockerOnceAsync(string lockName) | ||||
|     { | ||||
|         return GetLockerAsync(lockName, default, default); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										47
									
								
								src/backend/NetAdmin.Application/Services/RedisService.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/backend/NetAdmin.Application/Services/RedisService.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| using NetAdmin.Application.Repositories; | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| namespace NetAdmin.Application.Services; | ||||
|  | ||||
| /// <summary> | ||||
| ///     Redis Service Base | ||||
| /// </summary> | ||||
| /// <remarks> | ||||
| ///     Initializes a new instance of the <see cref="RedisService{TEntity, TPrimary, TLogger}" /> class. | ||||
| ///     Redis Service Base | ||||
| /// </remarks> | ||||
| public abstract class RedisService<TEntity, TPrimary, TLogger>(BasicRepository<TEntity, TPrimary> rpo) | ||||
|     : RepositoryService<TEntity, TPrimary, TLogger>(rpo) | ||||
|     where TEntity : EntityBase<TPrimary> // | ||||
|     where TPrimary : IEquatable<TPrimary> | ||||
| { | ||||
|     /// <summary> | ||||
|     ///     Redis Database | ||||
|     /// </summary> | ||||
|     protected IDatabase RedisDatabase { get; } // | ||||
|         = App.GetService<IConnectionMultiplexer>() | ||||
|              .GetDatabase(App.GetOptions<RedisOptions>() | ||||
|                              .Instances.First(x => x.Name == Chars.FLG_REDIS_INSTANCE_DATA_CACHE) | ||||
|                              .Database); | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     获取锁 | ||||
|     /// </summary> | ||||
|     protected Task<RedisLocker> GetLockerAsync(string lockerName) | ||||
|     { | ||||
|         return RedisLocker.GetLockerAsync(RedisDatabase, lockerName | ||||
|                                         , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_EXPIRY) | ||||
|                                         , Numbers.MAX_LIMIT_RETRY_CNT_REDIS_LOCK | ||||
|                                         , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_RETRY_DELAY)); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     获取锁(仅获取一次) | ||||
|     /// </summary> | ||||
|     protected Task<RedisLocker> GetLockerOnceAsync(string lockerName) | ||||
|     { | ||||
|         return RedisLocker.GetLockerAsync(RedisDatabase, lockerName | ||||
|                                         , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_EXPIRY), 1 | ||||
|                                         , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_RETRY_DELAY)); | ||||
|     } | ||||
| } | ||||
| @@ -35,7 +35,7 @@ public abstract class RepositoryService<TEntity, TPrimary, TLogger>(BasicReposit | ||||
|     /// </summary> | ||||
|     protected async Task<IActionResult> ExportAsync<TQuery, TExport>( // | ||||
|         Func<QueryReq<TQuery>, ISelectGrouping<TEntity, TEntity>> selector, QueryReq<TQuery> query, string fileName | ||||
|       , Expression<Func<ISelectGroupingAggregate<TEntity, TEntity>, object>> listExp) | ||||
|       , Expression<Func<ISelectGroupingAggregate<TEntity, TEntity>, object>> listExp = null) | ||||
|         where TQuery : DataAbstraction, new() | ||||
|     { | ||||
|         var list = await selector(query).Take(Numbers.MAX_LIMIT_EXPORT).ToListAsync(listExp).ConfigureAwait(false); | ||||
| @@ -95,6 +95,7 @@ public abstract class RepositoryService<TEntity, TPrimary, TLogger>(BasicReposit | ||||
|     /// <param name="includeFields">包含的属性</param> | ||||
|     /// <param name="excludeFields">排除的属性</param> | ||||
|     /// <param name="whereExp">查询表达式</param> | ||||
|     /// <param name="whereSql">查询sql</param> | ||||
|     /// <param name="ignoreVersion">是否忽略版本锁</param> | ||||
|     /// <returns>更新后的实体列表</returns> | ||||
|     protected Task<List<TEntity>> UpdateReturnListAsync(     // | ||||
| @@ -102,11 +103,15 @@ public abstract class RepositoryService<TEntity, TPrimary, TLogger>(BasicReposit | ||||
|       , IEnumerable<string>             includeFields        // | ||||
|       , string[]                        excludeFields = null // | ||||
|       , Expression<Func<TEntity, bool>> whereExp = null // | ||||
|       , string                          whereSql = null // | ||||
|       , bool                            ignoreVersion = false) | ||||
|     { | ||||
|         // 默认匹配主键 | ||||
|         whereExp ??= a => a.Id.Equals(newValue.Id); | ||||
|         return BuildUpdate(newValue, includeFields, excludeFields, ignoreVersion).Where(whereExp).ExecuteUpdatedAsync(); | ||||
|         return BuildUpdate(newValue, includeFields, excludeFields, ignoreVersion) | ||||
|                .Where(whereExp) | ||||
|                .Where(whereSql) | ||||
|                .ExecuteUpdatedAsync(); | ||||
|     } | ||||
|     #endif | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,7 @@ | ||||
| namespace NetAdmin.Domain.Attributes; | ||||
|  | ||||
| /// <summary> | ||||
| ///     危险字段标记 | ||||
| /// </summary> | ||||
| [AttributeUsage(AttributeTargets.Property)] | ||||
| public sealed class DangerFieldAttribute : Attribute; | ||||
| @@ -51,6 +51,14 @@ public record Sys_User : VersionEntity, IFieldSummary, IFieldEnabled, IRegister | ||||
|     [JsonIgnore] | ||||
|     public virtual bool Enabled { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     最后登录时间 | ||||
|     /// </summary> | ||||
|     [Column] | ||||
|     [CsvIgnore] | ||||
|     [JsonIgnore] | ||||
|     public virtual DateTime? LastLoginTime { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     手机号码 | ||||
|     /// </summary> | ||||
| @@ -64,6 +72,7 @@ public record Sys_User : VersionEntity, IFieldSummary, IFieldEnabled, IRegister | ||||
|     /// </summary> | ||||
|     [Column] | ||||
|     [CsvIgnore] | ||||
|     [DangerField] | ||||
|     [JsonIgnore] | ||||
|     public Guid Password { get; init; } | ||||
|  | ||||
|   | ||||
| @@ -36,4 +36,37 @@ public record QueryReq<T> : DataAbstraction | ||||
|     ///     排序字段 | ||||
|     /// </summary> | ||||
|     public string Prop { get; init; } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     所需字段 | ||||
|     /// </summary> | ||||
|     public string[] RequiredFields { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     列表表达式 | ||||
|     /// </summary> | ||||
|     public Expression<Func<TEntity, TEntity>> GetToListExp<TEntity>() | ||||
|     { | ||||
|         if (RequiredFields.NullOrEmpty()) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         var expParameter = Expression.Parameter(typeof(TEntity), "a"); | ||||
|         var bindings     = new List<MemberBinding>(); | ||||
|  | ||||
|         // ReSharper disable once LoopCanBeConvertedToQuery | ||||
|         foreach (var field in RequiredFields) { | ||||
|             var prop = typeof(TEntity).GetProperty(field); | ||||
|             if (prop == null || prop.GetCustomAttribute<DangerFieldAttribute>() != null) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var propExp = Expression.Property(expParameter, prop); | ||||
|             var binding = Expression.Bind(prop, propExp); | ||||
|             bindings.Add(binding); | ||||
|         } | ||||
|  | ||||
|         var expBody = Expression.MemberInit(Expression.New(typeof(TEntity)), bindings); | ||||
|         return Expression.Lambda<Func<TEntity, TEntity>>(expBody, expParameter); | ||||
|     } | ||||
| } | ||||
| @@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.Api; | ||||
| /// <summary> | ||||
| ///     响应:导出接口 | ||||
| /// </summary> | ||||
| public record ExportApiRsp : QueryApiRsp | ||||
| public sealed record ExportApiRsp : QueryApiRsp | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIgnore] | ||||
|   | ||||
| @@ -6,7 +6,7 @@ namespace NetAdmin.Domain.Dto.Sys.Config; | ||||
| /// <summary> | ||||
| ///     响应:导出配置 | ||||
| /// </summary> | ||||
| public record ExportConfigRsp : QueryConfigRsp, IRegister | ||||
| public sealed record ExportConfigRsp : QueryConfigRsp, IRegister | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIndex(6)] | ||||
|   | ||||
| @@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.Dept; | ||||
| /// <summary> | ||||
| ///     响应:导出部门 | ||||
| /// </summary> | ||||
| public record ExportDeptRsp : QueryDeptRsp | ||||
| public sealed record ExportDeptRsp : QueryDeptRsp | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIgnore] | ||||
|   | ||||
| @@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.Dic.Content; | ||||
| /// <summary> | ||||
| ///     响应:导出字典内容 | ||||
| /// </summary> | ||||
| public record ExportDicContentRsp : QueryDicContentRsp | ||||
| public sealed record ExportDicContentRsp : QueryDicContentRsp | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIndex(2)] | ||||
|   | ||||
| @@ -7,7 +7,7 @@ namespace NetAdmin.Domain.Dto.Sys.Job; | ||||
| /// <summary> | ||||
| ///     响应:导出计划作业 | ||||
| /// </summary> | ||||
| public record ExportJobRsp : QueryJobRsp | ||||
| public sealed record ExportJobRsp : QueryJobRsp | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIndex(5)] | ||||
|   | ||||
| @@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.JobRecord; | ||||
| /// <summary> | ||||
| ///     请求:创建计划作业执行记录 | ||||
| /// </summary> | ||||
| public record CreateJobRecordReq : Sys_JobRecord; | ||||
| public sealed record CreateJobRecordReq : Sys_JobRecord; | ||||
| @@ -5,7 +5,7 @@ namespace NetAdmin.Domain.Dto.Sys.JobRecord; | ||||
| /// <summary> | ||||
| ///     响应:导出计划作业执行记录 | ||||
| /// </summary> | ||||
| public record ExportJobRecordRsp : QueryJobRecordRsp, IRegister | ||||
| public sealed record ExportJobRecordRsp : QueryJobRecordRsp, IRegister | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIndex(1)] | ||||
|   | ||||
| @@ -7,7 +7,7 @@ namespace NetAdmin.Domain.Dto.Sys.LoginLog; | ||||
| /// <summary> | ||||
| ///     请求:创建登录日志 | ||||
| /// </summary> | ||||
| public record CreateLoginLogReq : Sys_LoginLog, IRegister | ||||
| public sealed record CreateLoginLogReq : Sys_LoginLog, IRegister | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public void Register(TypeAdapterConfig config) | ||||
|   | ||||
| @@ -8,7 +8,7 @@ namespace NetAdmin.Domain.Dto.Sys.RequestLog; | ||||
| /// <summary> | ||||
| ///     响应:导出请求日志 | ||||
| /// </summary> | ||||
| public record ExportRequestLogRsp : QueryRequestLogRsp | ||||
| public sealed record ExportRequestLogRsp : QueryRequestLogRsp | ||||
| { | ||||
|     /// <summary> | ||||
|     ///     接口路径 | ||||
|   | ||||
| @@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.RequestLogDetail; | ||||
| /// <summary> | ||||
| ///     请求:创建请求日志明细 | ||||
| /// </summary> | ||||
| public record CreateRequestLogDetailReq : Sys_RequestLogDetail; | ||||
| public sealed record CreateRequestLogDetailReq : Sys_RequestLogDetail; | ||||
| @@ -8,7 +8,7 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsg; | ||||
| /// <summary> | ||||
| ///     响应:导出站内信 | ||||
| /// </summary> | ||||
| public record ExportSiteMsgRsp : QuerySiteMsgRsp | ||||
| public sealed record ExportSiteMsgRsp : QuerySiteMsgRsp | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIndex(5)] | ||||
|   | ||||
| @@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsgDept; | ||||
| /// <summary> | ||||
| ///     请求:创建站内信-部门映射 | ||||
| /// </summary> | ||||
| public record CreateSiteMsgDeptReq : Sys_SiteMsgDept; | ||||
| public sealed record CreateSiteMsgDeptReq : Sys_SiteMsgDept; | ||||
| @@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsgRole; | ||||
| /// <summary> | ||||
| ///     请求:创建站内信-角色映射 | ||||
| /// </summary> | ||||
| public record CreateSiteMsgRoleReq : Sys_SiteMsgRole; | ||||
| public sealed record CreateSiteMsgRoleReq : Sys_SiteMsgRole; | ||||
| @@ -3,4 +3,4 @@ namespace NetAdmin.Domain.Dto.Sys.SiteMsgUser; | ||||
| /// <summary> | ||||
| ///     请求:创建站内信-用户映射 | ||||
| /// </summary> | ||||
| public record CreateSiteMsgUserReq : Sys_SiteMsgUser; | ||||
| public sealed record CreateSiteMsgUserReq : Sys_SiteMsgUser; | ||||
| @@ -6,7 +6,7 @@ namespace NetAdmin.Domain.Dto.Sys.User; | ||||
| /// <summary> | ||||
| ///     响应:导出用户 | ||||
| /// </summary> | ||||
| public record ExportUserRsp : QueryUserRsp | ||||
| public sealed record ExportUserRsp : QueryUserRsp | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIndex(7)] | ||||
| @@ -43,6 +43,12 @@ public record ExportUserRsp : QueryUserRsp | ||||
|     [CsvName(nameof(Ln.唯一编码))] | ||||
|     public override long Id { get; init; } | ||||
|  | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIndex(8)] | ||||
|     [CsvIgnore(false)] | ||||
|     [CsvName(nameof(Ln.最后登录时间))] | ||||
|     public override DateTime? LastLoginTime { get; init; } | ||||
|  | ||||
|     /// <inheritdoc /> | ||||
|     [CsvIndex(2)] | ||||
|     [CsvIgnore(false)] | ||||
|   | ||||
| @@ -31,6 +31,10 @@ public record QueryUserRsp : Sys_User | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.Never)] | ||||
|     public override long Id { get; init; } | ||||
|  | ||||
|     /// <inheritdoc cref="Sys_User.LastLoginTime" /> | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public override DateTime? LastLoginTime { get; init; } | ||||
|  | ||||
|     /// <inheritdoc cref="Sys_User.Mobile" /> | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|     public override string Mobile { get; init; } | ||||
|   | ||||
| @@ -3,7 +3,7 @@ namespace NetAdmin.Domain.Dto.Sys.UserProfile; | ||||
| /// <summary> | ||||
| ///     请求:设置当前用户应用配置 | ||||
| /// </summary> | ||||
| public record SetSessionUserAppConfigReq : Sys_UserProfile | ||||
| public sealed record SetSessionUserAppConfigReq : Sys_UserProfile | ||||
| { | ||||
|     /// <inheritdoc cref="Sys_UserProfile.AppConfig" /> | ||||
|     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||||
|   | ||||
| @@ -13,7 +13,6 @@ | ||||
|         <PackageReference Include="CronExpressionDescriptor" Version="2.36.0"/> | ||||
|         <PackageReference Include="Cronos" Version="0.8.4"/> | ||||
|         <PackageReference Include="CsvHelper.NS" Version="33.0.2-ns2"/> | ||||
|         <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.0-preview.6.24328.4"/> | ||||
|         <PackageReference Include="Yitter.IdGenerator" Version="1.0.14"/> | ||||
|     </ItemGroup> | ||||
| </Project> | ||||
| @@ -1,4 +1,4 @@ | ||||
| using RedLockNet; | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| namespace NetAdmin.Host.BackgroundRunning; | ||||
|  | ||||
| @@ -7,8 +7,6 @@ namespace NetAdmin.Host.BackgroundRunning; | ||||
| /// </summary> | ||||
| public abstract class WorkBase<TLogger> | ||||
| { | ||||
|     private readonly RedLocker _redLocker; | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     Initializes a new instance of the <see cref="WorkBase{TLogger}" /> class. | ||||
|     /// </summary> | ||||
| @@ -17,7 +15,6 @@ public abstract class WorkBase<TLogger> | ||||
|         ServiceProvider = App.GetService<IServiceScopeFactory>().CreateScope().ServiceProvider; | ||||
|         UowManager      = ServiceProvider.GetService<UnitOfWorkManager>(); | ||||
|         Logger          = ServiceProvider.GetService<ILogger<TLogger>>(); | ||||
|         _redLocker      = ServiceProvider.GetService<RedLocker>(); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
| @@ -53,10 +50,8 @@ public abstract class WorkBase<TLogger> | ||||
|     { | ||||
|         if (singleInstance) { | ||||
|             // 加锁 | ||||
|             await using var redLock = await GetLockerAsync(GetType().FullName).ConfigureAwait(false); | ||||
|             if (!redLock.IsAcquired) { | ||||
|                 throw new NetAdminGetLockerException(); | ||||
|             } | ||||
|             var             lockName    = GetType().FullName; | ||||
|             await using var redisLocker = await GetLockerAsync(lockName).ConfigureAwait(false); | ||||
|  | ||||
|             await WorkflowAsync(cancelToken).ConfigureAwait(false); | ||||
|             return; | ||||
| @@ -68,10 +63,15 @@ public abstract class WorkBase<TLogger> | ||||
|     /// <summary> | ||||
|     ///     获取锁 | ||||
|     /// </summary> | ||||
|     private Task<IRedLock> GetLockerAsync(string lockId) | ||||
|     private Task<RedisLocker> GetLockerAsync(string lockId) | ||||
|     { | ||||
|         return _redLocker.RedLockFactory.CreateLockAsync(lockId, TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_EXPIRY) | ||||
|                                                        , TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_WAIT) | ||||
|                                                        , TimeSpan.FromSeconds(Numbers.SECS_RED_LOCK_RETRY_INTERVAL)); | ||||
|         var db = ServiceProvider.GetService<IConnectionMultiplexer>() | ||||
|                                 .GetDatabase(ServiceProvider.GetService<IOptions<RedisOptions>>() | ||||
|                                                             .Value.Instances | ||||
|                                                             .First(x => x.Name == Chars.FLG_REDIS_INSTANCE_DATA_CACHE) | ||||
|                                                             .Database); | ||||
|         return RedisLocker.GetLockerAsync(db, lockId, TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_EXPIRY) | ||||
|                                         , Numbers.MAX_LIMIT_RETRY_CNT_REDIS_LOCK | ||||
|                                         , TimeSpan.FromSeconds(Numbers.SECS_REDIS_LOCK_RETRY_DELAY)); | ||||
|     } | ||||
| } | ||||
| @@ -3,6 +3,7 @@ using IGeekFan.AspNetCore.Knife4jUI; | ||||
|  | ||||
| #else | ||||
| using Prometheus; | ||||
| using Prometheus.HttpMetrics; | ||||
| #endif | ||||
|  | ||||
| namespace NetAdmin.Host.Extensions; | ||||
| @@ -40,5 +41,22 @@ public static class IApplicationBuilderExtensions | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|     #else | ||||
|     /// <summary> | ||||
|     ///     使用 Prometheus | ||||
|     /// </summary> | ||||
|     public static IApplicationBuilder UsePrometheus(this IApplicationBuilder me) | ||||
|     { | ||||
|         return me.UseHttpMetrics(opt => { | ||||
|             opt.RequestDuration.Histogram = Metrics.CreateHistogram( // | ||||
|                 "http_request_duration_seconds" | ||||
|               , "The duration of HTTP requests processed by an ASP.NET Core application." | ||||
|               , HttpRequestLabelNames.All | ||||
|               , new HistogramConfiguration { | ||||
|                                                Buckets = Histogram.PowersOfTenDividedBuckets( | ||||
|                                                    -2, 2, 4) | ||||
|                                            }); | ||||
|         }); | ||||
|     } | ||||
|     #endif | ||||
| } | ||||
| @@ -28,6 +28,7 @@ public sealed class RequestLogger(ILogger<RequestLogger> logger, IEventPublisher | ||||
|           , x => context.Request.ContentType?.Contains(x, StringComparison.OrdinalIgnoreCase) ?? false) | ||||
|             ? await context.ReadBodyContentAsync().ConfigureAwait(false) | ||||
|             : string.Empty; | ||||
|         var apiId = context.Request.Path.Value!.TrimStart('/'); | ||||
|         var auditData = new CreateRequestLogReq // | ||||
|                         { | ||||
|                             Detail = new CreateRequestLogDetailReq // | ||||
| @@ -47,7 +48,7 @@ public sealed class RequestLogger(ILogger<RequestLogger> logger, IEventPublisher | ||||
|                                      } | ||||
|                           , Duration        = (int)duration | ||||
|                           , HttpMethod      = Enum.Parse<HttpMethods>(context.Request.Method, true) | ||||
|                           , ApiPathCrc32    = context.Request.Path.Value!.TrimStart('/').Crc32() | ||||
|                           , ApiPathCrc32    = apiId.Crc32() | ||||
|                           , HttpStatusCode  = context.Response.StatusCode | ||||
|                           , CreatedClientIp = context.GetRealIpAddress()?.MapToIPv4().ToString().IpV4ToInt32() | ||||
|                           , OwnerId         = associatedUser?.UserId | ||||
| @@ -56,11 +57,14 @@ public sealed class RequestLogger(ILogger<RequestLogger> logger, IEventPublisher | ||||
|                           , TraceId         = context.GetTraceId() | ||||
|                         }; | ||||
|  | ||||
|         // 打印日志 | ||||
|         logger.Info(auditData); | ||||
|         // ReSharper disable once InvertIf | ||||
|         if (!GlobalStatic.LoggerIgnoreApiIds.Contains(apiId)) { | ||||
|             // 打印日志 | ||||
|             logger.Info(auditData); | ||||
|  | ||||
|         // 发布请求日志事件 | ||||
|         await eventPublisher.PublishAsync(new RequestLogEvent(auditData)).ConfigureAwait(false); | ||||
|             // 发布请求日志事件 | ||||
|             await eventPublisher.PublishAsync(new RequestLogEvent(auditData)).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         return auditData; | ||||
|     } | ||||
|   | ||||
| @@ -16,20 +16,20 @@ public static class Numbers | ||||
|     public const int  HTTP_STATUS_BIZ_FAIL    = 900;             // Http状态码-业务异常 | ||||
|     public const long ID_DIC_CATALOG_GEO_AREA = 379794295185413; // 唯一编号:字典目录-行政区划字典 | ||||
|  | ||||
|     public const int MAX_LIMIT_BULK_REQ          = 100;   // 最大限制:批量请求数 | ||||
|     public const int MAX_LIMIT_EXPORT            = 10000; // 最大限制:导出为CSV文件的条数 | ||||
|     public const int MAX_LIMIT_PRINT_LEN_CONTENT = 4096;  // 最大限制:打印长度(HTTP 内容) | ||||
|     public const int MAX_LIMIT_PRINT_LEN_SQL     = 4096;  // 最大限制:打印长度(SQL 语句) | ||||
|     public const int MAX_LIMIT_QUERY             = 1000;  // 最大限制:非分页查询条数 | ||||
|     public const int MAX_LIMIT_QUERY_PAGE_NO     = 10000; // 最大限制:分页查询页码 | ||||
|     public const int MAX_LIMIT_QUERY_PAGE_SIZE   = 100;   // 最大限制:分页查询页容量 | ||||
|     public const int MAX_LIMIT_BULK_REQ             = 100;   // 最大限制:批量请求数 | ||||
|     public const int MAX_LIMIT_EXPORT               = 10000; // 最大限制:导出为CSV文件的条数 | ||||
|     public const int MAX_LIMIT_PRINT_LEN_CONTENT    = 4096;  // 最大限制:打印长度(HTTP 内容) | ||||
|     public const int MAX_LIMIT_PRINT_LEN_SQL        = 4096;  // 最大限制:打印长度(SQL 语句) | ||||
|     public const int MAX_LIMIT_QUERY                = 1000;  // 最大限制:非分页查询条数 | ||||
|     public const int MAX_LIMIT_QUERY_PAGE_NO        = 10000; // 最大限制:分页查询页码 | ||||
|     public const int MAX_LIMIT_QUERY_PAGE_SIZE      = 100;   // 最大限制:分页查询页容量 | ||||
|     public const int MAX_LIMIT_RETRY_CNT_REDIS_LOCK = 10;    // 最大限制:Redis锁重试次数 | ||||
|  | ||||
|     public const int SECS_CACHE_CHART             = 300; // 秒:缓存时间-仪表 | ||||
|     public const int SECS_CACHE_DEFAULT           = 60;  // 秒:缓存时间-默认 | ||||
|     public const int SECS_CACHE_DIC_CATALOG_CODE  = 300; // 秒:缓存时间-字典配置-目录代码 | ||||
|     public const int SECS_RED_LOCK_EXPIRY         = 30;  // 秒:RedLock-锁过期时间,假如持有锁的进程挂掉,最多在此时间内锁将被释放(如持有锁的进程正常,此值不会生效) | ||||
|     public const int SECS_RED_LOCK_RETRY_INTERVAL = 1;   // 秒:RedLock-锁等待时间内,多久尝试获取一次 | ||||
|     public const int SECS_RED_LOCK_WAIT           = 10;  // 秒:RedLock-锁等待时间,相同的 resource 如果当前的锁被其他线程占用,最多等待时间 | ||||
|     public const int SECS_TIMEOUT_HTTP_CLIENT     = 15;  // 秒:超时时间-默认HTTP客户端 | ||||
|     public const int SECS_TIMEOUT_JOB             = 600; // 秒:超时时间-作业 | ||||
|     public const int SECS_CACHE_CHART            = 300; // 秒:缓存时间-仪表 | ||||
|     public const int SECS_CACHE_DEFAULT          = 60;  // 秒:缓存时间-默认 | ||||
|     public const int SECS_CACHE_DIC_CATALOG_CODE = 300; // 秒:缓存时间-字典配置-目录代码 | ||||
|     public const int SECS_REDIS_LOCK_EXPIRY      = 60;  // 秒:Redis锁过期时间 | ||||
|     public const int SECS_REDIS_LOCK_RETRY_DELAY = 1;   // 秒:Redis锁重试间隔 | ||||
|     public const int SECS_TIMEOUT_HTTP_CLIENT    = 15;  // 秒:超时时间-默认HTTP客户端 | ||||
|     public const int SECS_TIMEOUT_JOB            = 600; // 秒:超时时间-作业 | ||||
| } | ||||
| @@ -7,5 +7,5 @@ namespace NetAdmin.Infrastructure.Exceptions; | ||||
| ///     并发执行时锁竞争失败 | ||||
| /// </remarks> | ||||
| #pragma warning disable RCS1194 | ||||
| public sealed class NetAdminGetLockerException() : NetAdminInvalidOperationException(null) { } | ||||
| public sealed class NetAdminGetLockerException(string message = null) : NetAdminInvalidOperationException(message) { } | ||||
| #pragma warning restore RCS1194 | ||||
| @@ -24,6 +24,11 @@ public static class GlobalStatic | ||||
|     #endif | ||||
|     ; | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     日志记录器忽略的API编号 | ||||
|     /// </summary> | ||||
|     public static string[] LoggerIgnoreApiIds => []; | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     系统内部密钥 | ||||
|     /// </summary> | ||||
|   | ||||
| @@ -8,13 +8,13 @@ | ||||
|     <ItemGroup> | ||||
|         <PackageReference Include="FreeSql.DbContext.NS" Version="3.2.833-preview20260627-ns1"/> | ||||
|         <PackageReference Include="FreeSql.Provider.Sqlite.NS" Version="3.2.833-preview20260627-ns1"/> | ||||
|         <PackageReference Include="Furion.Extras.Authentication.JwtBearer" Version="4.9.4.6"/> | ||||
|         <PackageReference Include="Furion.Extras.ObjectMapper.Mapster.NS" Version="4.9.4.6-ns4"/> | ||||
|         <PackageReference Include="Furion.Pure.NS" Version="4.9.4.6-ns4"/> | ||||
|         <PackageReference Include="Furion.Extras.Authentication.JwtBearer" Version="4.9.5.2"/> | ||||
|         <PackageReference Include="Furion.Extras.ObjectMapper.Mapster.NS" Version="4.9.5.2-ns1"/> | ||||
|         <PackageReference Include="Furion.Pure.NS" Version="4.9.5.2-ns1"/> | ||||
|         <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.10.0"/> | ||||
|         <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="9.0.0-preview.6.24328.4"/> | ||||
|         <PackageReference Include="Minio" Version="6.0.3"/> | ||||
|         <PackageReference Include="NSExt" Version="2.2.0"/> | ||||
|         <PackageReference Include="RedLock.net" Version="2.3.2"/> | ||||
|         <PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.4"/> | ||||
|     </ItemGroup> | ||||
|     <ItemGroup> | ||||
|   | ||||
| @@ -1,98 +0,0 @@ | ||||
| using RedLockNet.SERedis; | ||||
| using RedLockNet.SERedis.Configuration; | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| namespace NetAdmin.Infrastructure.Utils; | ||||
|  | ||||
| /// <summary> | ||||
| ///     Redis 分布锁 | ||||
| /// </summary> | ||||
| #pragma warning disable DesignedForInheritance | ||||
| public class RedLocker : IDisposable, ISingleton | ||||
| #pragma warning restore DesignedForInheritance | ||||
| { | ||||
|     // Track whether Dispose has been called. | ||||
|     private bool _disposed; | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     Initializes a new instance of the <see cref="RedLocker" /> class. | ||||
|     /// </summary> | ||||
|     public RedLocker(IOptions<RedisOptions> redisOptions) | ||||
|     { | ||||
|         RedLockFactory = RedLockFactory.Create( // | ||||
|             new List<RedLockMultiplexer>        // | ||||
|             { | ||||
|                 ConnectionMultiplexer.Connect( // | ||||
|                     redisOptions.Value.Instances.First(x => x.Name == Chars.FLG_REDIS_INSTANCE_DATA_CACHE).ConnStr) | ||||
|             }); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     Finalizes an instance of the <see cref="RedLocker" /> class. | ||||
|     ///     Use C# finalizer syntax for finalization code. | ||||
|     ///     This finalizer will run only if the Dispose method | ||||
|     ///     does not get called. | ||||
|     ///     It gives your base class the opportunity to finalize. | ||||
|     ///     Do not provide finalizer in types derived from this class. | ||||
|     /// </summary> | ||||
|     ~RedLocker() | ||||
|     { | ||||
|         // Do not re-create Dispose clean-up code here. | ||||
|         // Calling Dispose(disposing: false) is optimal in terms of | ||||
|         // readability and maintainability. | ||||
|         Dispose(false); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     RedLockFactory | ||||
|     /// </summary> | ||||
|     public RedLockFactory RedLockFactory { get; } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     Implement IDisposable. | ||||
|     ///     Do not make this method virtual. | ||||
|     ///     A derived class should not be able to override this method. | ||||
|     /// </summary> | ||||
|     public void Dispose() | ||||
|     { | ||||
|         Dispose(true); | ||||
|  | ||||
|         // This object will be cleaned up by the Dispose method. | ||||
|         // Therefore, you should call GC.SuppressFinalize to | ||||
|         // take this object off the finalization queue | ||||
|         // and prevent finalization code for this object | ||||
|         // from executing a second time. | ||||
|         GC.SuppressFinalize(this); | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     Dispose(bool disposing) executes in two distinct scenarios. | ||||
|     ///     If disposing equals true, the method has been called directly | ||||
|     ///     or indirectly by a user's code. Managed and unmanaged resources | ||||
|     ///     can be disposed. | ||||
|     ///     If disposing equals false, the method has been called by the | ||||
|     ///     runtime from inside the finalizer and you should not reference | ||||
|     ///     other objects. Only unmanaged resources can be disposed. | ||||
|     /// </summary> | ||||
|     protected virtual void Dispose(bool disposing) | ||||
|     { | ||||
|         // Check to see if Dispose has already been called. | ||||
|         if (_disposed) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // If disposing equals true, dispose all managed | ||||
|         // and unmanaged resources. | ||||
|         if (disposing) { | ||||
|             RedLockFactory.Dispose(); | ||||
|         } | ||||
|  | ||||
|         // Call the appropriate methods to clean up | ||||
|         // unmanaged resources here. | ||||
|         // If disposing is false, | ||||
|         // only the following code is executed. | ||||
|  | ||||
|         // Note disposing has been done. | ||||
|         _disposed = true; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										70
									
								
								src/backend/NetAdmin.Infrastructure/Utils/RedisLocker.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								src/backend/NetAdmin.Infrastructure/Utils/RedisLocker.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,70 @@ | ||||
| using StackExchange.Redis; | ||||
|  | ||||
| namespace NetAdmin.Infrastructure.Utils; | ||||
|  | ||||
| /// <summary> | ||||
| ///     Redis 分布锁 | ||||
| /// </summary> | ||||
| /// <remarks> | ||||
| ///     Initializes a new instance of the <see cref="RedisLocker" /> class. | ||||
| /// </remarks> | ||||
| #pragma warning disable DesignedForInheritance | ||||
| public sealed class RedisLocker : IAsyncDisposable | ||||
| #pragma warning restore DesignedForInheritance | ||||
| { | ||||
|     private readonly IDatabase _redisDatabase; | ||||
|  | ||||
|     private readonly string _redisKey; | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     Initializes a new instance of the <see cref="RedisLocker" /> class. | ||||
|     ///     Redis 分布锁 | ||||
|     /// </summary> | ||||
|     private RedisLocker(IDatabase redisDatabase, string redisKey) | ||||
|     { | ||||
|         _redisDatabase = redisDatabase; | ||||
|         _redisKey      = redisKey; | ||||
|     } | ||||
|  | ||||
|     /// <summary> | ||||
|     ///     获取锁 | ||||
|     /// </summary> | ||||
|     /// <exception cref="NetAdminGetLockerException">NetAdminGetLockerException</exception> | ||||
|     public static async Task<RedisLocker> GetLockerAsync(IDatabase redisDatabase, string lockerName | ||||
|                                                        , TimeSpan  lockerExpire,  int retryCount, TimeSpan retryDelay) | ||||
|     { | ||||
|         lockerName = $"{nameof(RedisLocker)}.{lockerName}"; | ||||
|         var setOk = false; | ||||
|         for (var i = 0; i != retryCount; ++i) { | ||||
|             try { | ||||
|                 setOk = await redisDatabase | ||||
|                               .StringSetAsync(lockerName, RedisValue.EmptyString, lockerExpire, When.NotExists | ||||
|                                 ,                         CommandFlags.DemandMaster) | ||||
|                               .ConfigureAwait(false); | ||||
|             } | ||||
|             catch (Exception ex) { | ||||
|                 LogHelper.Get<RedisLocker>().Error(ex.Message); | ||||
|             } | ||||
|  | ||||
|             if (setOk) { | ||||
|                 return new RedisLocker(redisDatabase, lockerName); | ||||
|             } | ||||
|  | ||||
|             await Task.Delay(retryDelay).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         throw new NetAdminGetLockerException(lockerName); | ||||
|     } | ||||
|  | ||||
|     /// <inheritdoc /> | ||||
|     public async ValueTask DisposeAsync() | ||||
|     { | ||||
|         try { | ||||
|             _ = await _redisDatabase.KeyDeleteAsync(_redisKey, CommandFlags.DemandMaster | CommandFlags.FireAndForget) | ||||
|                                     .ConfigureAwait(false); | ||||
|         } | ||||
|         catch (Exception ex) { | ||||
|             LogHelper.Get<RedisLocker>().Error(ex.Message); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -8,11 +8,10 @@ namespace NetAdmin.SysComponent.Application.Services.Sys; | ||||
|  | ||||
| /// <inheritdoc cref="IApiService" /> | ||||
| public sealed class ApiService( | ||||
|     BasicRepository<Sys_Api, string>    rpo              // | ||||
|   , XmlCommentReader                    xmlCommentReader // | ||||
|   , IActionDescriptorCollectionProvider actionDescriptorCollectionProvider | ||||
|   , RedLocker                           redLocker) // | ||||
|     : RedLockerService<Sys_Api, string, IApiService>(rpo, redLocker), IApiService | ||||
|     BasicRepository<Sys_Api, string>    rpo                                 // | ||||
|   , XmlCommentReader                    xmlCommentReader                    // | ||||
|   , IActionDescriptorCollectionProvider actionDescriptorCollectionProvider) // | ||||
|     : RedisService<Sys_Api, string, IApiService>(rpo), IApiService | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public Task<int> BulkDeleteAsync(BulkReq<DelReq> req) | ||||
|   | ||||
| @@ -18,19 +18,23 @@ public sealed class UserService( | ||||
|   , IEventPublisher                 eventPublisher)    // | ||||
|     : RepositoryService<Sys_User, long, IUserService>(rpo), IUserService | ||||
| { | ||||
|     private readonly Expression<Func<Sys_User, Sys_User>> _selectUserFields = a => new Sys_User { | ||||
|         Id          = a.Id | ||||
|       , Avatar      = a.Avatar | ||||
|       , Email       = a.Email | ||||
|       , Mobile      = a.Mobile | ||||
|       , Enabled     = a.Enabled | ||||
|       , UserName    = a.UserName | ||||
|       , Summary     = a.Summary | ||||
|       , Version     = a.Version | ||||
|       , CreatedTime = a.CreatedTime | ||||
|       , Dept        = new Sys_Dept { Id = a.Dept.Id, Name = a.Dept.Name } | ||||
|       , Roles       = a.Roles | ||||
|     }; | ||||
|     private readonly Expression<Func<Sys_User, Sys_User>> _listUserExp = a => new Sys_User { | ||||
|                                                                                   Id            = a.Id | ||||
|                                                                                 , Avatar        = a.Avatar | ||||
|                                                                                 , Email         = a.Email | ||||
|                                                                                 , Mobile        = a.Mobile | ||||
|                                                                                 , Enabled       = a.Enabled | ||||
|                                                                                 , UserName      = a.UserName | ||||
|                                                                                 , Summary       = a.Summary | ||||
|                                                                                 , Version       = a.Version | ||||
|                                                                                 , CreatedTime   = a.CreatedTime | ||||
|                                                                                 , LastLoginTime = a.LastLoginTime | ||||
|                                                                                 , Dept = new Sys_Dept { | ||||
|                                                                                       Id   = a.Dept.Id | ||||
|                                                                                     , Name = a.Dept.Name | ||||
|                                                                                   } | ||||
|                                                                                 , Roles = a.Roles | ||||
|                                                                               }; | ||||
|  | ||||
|     /// <inheritdoc /> | ||||
|     public async Task<int> BulkDeleteAsync(BulkReq<DelReq> req) | ||||
| @@ -232,14 +236,15 @@ public sealed class UserService( | ||||
|     public async Task<PagedQueryRsp<QueryUserRsp>> PagedQueryAsync(PagedQueryReq<QueryUserReq> req) | ||||
|     { | ||||
|         req.ThrowIfInvalid(); | ||||
|         var list = await (await QueryInternalAsync(req).ConfigureAwait(false)).Page(req.Page, req.PageSize) | ||||
|                                                                               #if DBTYPE_SQLSERVER | ||||
|                                                                               .WithLock(SqlServerLock.NoLock | | ||||
|                                                                                   SqlServerLock.NoWait) | ||||
|                                                                               #endif | ||||
|                                                                               .Count(out var total) | ||||
|                                                                               .ToListAsync(_selectUserFields) | ||||
|                                                                               .ConfigureAwait(false); | ||||
|         var listUserExp = req.GetToListExp<Sys_User>() ?? _listUserExp; | ||||
|         var select      = await QueryInternalAsync(req, listUserExp == _listUserExp).ConfigureAwait(false); | ||||
|         var list = await select.Page(req.Page, req.PageSize) | ||||
|                                #if DBTYPE_SQLSERVER | ||||
|                                .WithLock(SqlServerLock.NoLock | SqlServerLock.NoWait) | ||||
|                                #endif | ||||
|                                .Count(out var total) | ||||
|                                .ToListAsync(listUserExp) | ||||
|                                .ConfigureAwait(false); | ||||
|         return new PagedQueryRsp<QueryUserRsp>(req.Page, req.PageSize, total, list.Adapt<IEnumerable<QueryUserRsp>>()); | ||||
|     } | ||||
|  | ||||
| @@ -248,7 +253,7 @@ public sealed class UserService( | ||||
|     { | ||||
|         req.ThrowIfInvalid(); | ||||
|         var list = await (await QueryInternalAsync(req).ConfigureAwait(false)).Take(req.Count) | ||||
|                                                                               .ToListAsync(_selectUserFields) | ||||
|                                                                               .ToListAsync(_listUserExp) | ||||
|                                                                               .ConfigureAwait(false); | ||||
|         return list.Adapt<IEnumerable<QueryUserRsp>>(); | ||||
|     } | ||||
| @@ -437,22 +442,6 @@ public sealed class UserService( | ||||
|         return dbUser.Adapt<UserInfoRsp>(); | ||||
|     } | ||||
|  | ||||
|     private static LoginRsp LoginInternal(Sys_User dbUser) | ||||
|     { | ||||
|         if (!dbUser.Enabled) { | ||||
|             throw new NetAdminInvalidOperationException(Ln.请联系管理员激活账号); | ||||
|         } | ||||
|  | ||||
|         var tokenPayload | ||||
|             = new Dictionary<string, object> { { nameof(ContextUserToken), dbUser.Adapt<ContextUserToken>() } }; | ||||
|  | ||||
|         var accessToken = JWTEncryption.Encrypt(tokenPayload); | ||||
|         return new LoginRsp { | ||||
|                                 AccessToken  = accessToken | ||||
|                               , RefreshToken = JWTEncryption.GenerateRefreshToken(accessToken) | ||||
|                             }; | ||||
|     } | ||||
|  | ||||
|     private async Task<Dictionary<long, string>> CreateEditCheckAsync(CreateEditUserReq req) | ||||
|     { | ||||
|         // 检查角色是否存在 | ||||
| @@ -475,21 +464,44 @@ public sealed class UserService( | ||||
|         return dept.Count != 1 ? throw new NetAdminInvalidOperationException(Ln.部门不存在) : roles; | ||||
|     } | ||||
|  | ||||
|     private ISelect<Sys_User> QueryInternal(QueryReq<QueryUserReq> req, IEnumerable<long> deptIds) | ||||
|     private LoginRsp LoginInternal(Sys_User dbUser) | ||||
|     { | ||||
|         var ret = Rpo.Select.Include(a => a.Dept) | ||||
|                      .IncludeMany(a => a.Roles.Select(b => new Sys_Role { Id = b.Id, Name = b.Name })) | ||||
|                      .WhereDynamicFilter(req.DynamicFilter) | ||||
|                      .WhereIf(deptIds != null, a => deptIds.Contains(a.DeptId)) | ||||
|                      .WhereIf( // | ||||
|                          req.Filter?.Id > 0, a => a.Id == req.Filter.Id) | ||||
|                      .WhereIf( // | ||||
|                          req.Filter?.RoleId > 0, a => a.Roles.Any(b => b.Id == req.Filter.RoleId)) | ||||
|                      .WhereIf( // | ||||
|                          req.Keywords?.Length > 0 | ||||
|                        , a => a.Id     == req.Keywords.Int64Try(0) || a.UserName == req.Keywords || | ||||
|                               a.Mobile == req.Keywords             || | ||||
|                               a.Email  == req.Keywords             || a.Summary.Contains(req.Keywords)); | ||||
|         if (!dbUser.Enabled) { | ||||
|             throw new NetAdminInvalidOperationException(Ln.请联系管理员激活账号); | ||||
|         } | ||||
|  | ||||
|         _ = UpdateAsync(dbUser with { LastLoginTime = DateTime.Now }, [nameof(Sys_User.LastLoginTime)] | ||||
| ,                                                                     ignoreVersion: true); | ||||
|  | ||||
|         var tokenPayload | ||||
|             = new Dictionary<string, object> { { nameof(ContextUserToken), dbUser.Adapt<ContextUserToken>() } }; | ||||
|  | ||||
|         var accessToken = JWTEncryption.Encrypt(tokenPayload); | ||||
|         return new LoginRsp { | ||||
|                                 AccessToken  = accessToken | ||||
|                               , RefreshToken = JWTEncryption.GenerateRefreshToken(accessToken) | ||||
|                             }; | ||||
|     } | ||||
|  | ||||
|     private ISelect<Sys_User> QueryInternal(QueryReq<QueryUserReq> req, IEnumerable<long> deptIds | ||||
|                                           , bool                   includeRoles = true) | ||||
|     { | ||||
|         var ret = Rpo.Select.Include(a => a.Dept); | ||||
|         if (includeRoles) { | ||||
|             ret = ret.IncludeMany(a => a.Roles.Select(b => new Sys_Role { Id = b.Id, Name = b.Name })); | ||||
|         } | ||||
|  | ||||
|         ret = ret.WhereDynamicFilter(req.DynamicFilter) | ||||
|                  .WhereIf(deptIds != null, a => deptIds.Contains(a.DeptId)) | ||||
|                  .WhereIf( // | ||||
|                      req.Filter?.Id > 0, a => a.Id == req.Filter.Id) | ||||
|                  .WhereIf( // | ||||
|                      req.Filter?.RoleId > 0, a => a.Roles.Any(b => b.Id == req.Filter.RoleId)) | ||||
|                  .WhereIf( // | ||||
|                      req.Keywords?.Length > 0 | ||||
|                    , a => a.Id     == req.Keywords.Int64Try(0) || a.UserName == req.Keywords || | ||||
|                           a.Mobile == req.Keywords             || | ||||
|                           a.Email  == req.Keywords             || a.Summary.Contains(req.Keywords)); | ||||
|  | ||||
|         // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault | ||||
|         switch (req.Order) { | ||||
| @@ -518,7 +530,7 @@ public sealed class UserService( | ||||
|         return QueryInternal(req, deptIds); | ||||
|     } | ||||
|  | ||||
|     private async Task<ISelect<Sys_User>> QueryInternalAsync(QueryReq<QueryUserReq> req) | ||||
|     private async Task<ISelect<Sys_User>> QueryInternalAsync(QueryReq<QueryUserReq> req, bool includeRoles = true) | ||||
|     { | ||||
|         IEnumerable<long> deptIds = null; | ||||
|         if (req.Filter?.DeptId > 0) { | ||||
| @@ -529,6 +541,6 @@ public sealed class UserService( | ||||
|                                .ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         return QueryInternal(req, deptIds); | ||||
|         return QueryInternal(req, deptIds, includeRoles); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,49 @@ | ||||
| using System.Net.WebSockets; | ||||
| using NetAdmin.SysComponent.Cache.Sys.Dependency; | ||||
|  | ||||
| namespace NetAdmin.SysComponent.Host.Middlewares; | ||||
|  | ||||
| /// <summary> | ||||
| ///     版本更新检查中间件 | ||||
| /// </summary> | ||||
| public sealed class VersionCheckerMiddleware(RequestDelegate next) | ||||
| { | ||||
|     /// <summary> | ||||
|     ///     主函数 | ||||
|     /// </summary> | ||||
|     public async Task InvokeAsync(HttpContext context) | ||||
|     { | ||||
|         if (context.Request.Path == "/ws/version") { | ||||
|             if (context.WebSockets.IsWebSocketRequest) { | ||||
|                 var webSocket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false); | ||||
|                 await ConnectionAsync(webSocket).ConfigureAwait(false); | ||||
|             } | ||||
|             else { | ||||
|                 context.Response.StatusCode = 400; | ||||
|             } | ||||
|         } | ||||
|         else { | ||||
|             await next(context).ConfigureAwait(false); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static async Task ConnectionAsync(WebSocket webSocket) | ||||
|     { | ||||
|         var buffer = new byte[1024]; | ||||
|         while (webSocket.State == WebSocketState.Open) { | ||||
|             var receiveResult = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None) | ||||
|                                                .ConfigureAwait(false); | ||||
|             if (receiveResult.MessageType != WebSocketMessageType.Text) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var ver = await App.GetService<IToolsCache>().GetVersionAsync().ConfigureAwait(false); | ||||
|             await webSocket.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(ver)), WebSocketMessageType.Text | ||||
|                                     , true, CancellationToken.None) | ||||
|                            .ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None) | ||||
|                        .ConfigureAwait(false); | ||||
|     } | ||||
| } | ||||
| @@ -5,7 +5,7 @@ | ||||
|     <ItemGroup> | ||||
|         <PackageReference Include="xunit" Version="2.9.0"/> | ||||
|         <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0-preview.6.24328.4"/> | ||||
|         <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> | ||||
|         <PackageReference Include="xunit.runner.visualstudio" Version="3.0.0-pre.24"> | ||||
|             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|             <PrivateAssets>all</PrivateAssets> | ||||
|         </PackageReference> | ||||
|   | ||||
| @@ -83,7 +83,7 @@ | ||||
|             } | ||||
|  | ||||
|             .dark .app-loading__title { | ||||
|                 color: #d0d0d0; | ||||
|                 color: #c0c0c0; | ||||
|             } | ||||
|  | ||||
|             @keyframes loader { | ||||
|   | ||||
| @@ -12,13 +12,13 @@ | ||||
|         "@element-plus/icons-vue": "^2.3.1", | ||||
|         "ace-builds": "^1.35.4", | ||||
|         "aieditor": "^1.0.13", | ||||
|         "axios": "^1.7.2", | ||||
|         "axios": "^1.7.3", | ||||
|         "clipboard": "^2.0.11", | ||||
|         "core-js": "^3.37.1", | ||||
|         "core-js": "^3.38.0", | ||||
|         "cropperjs": "^1.6.2", | ||||
|         "crypto-js": "^4.2.0", | ||||
|         "echarts": "^5.5.1", | ||||
|         "element-plus": "^2.7.8", | ||||
|         "element-plus": "^2.8.0", | ||||
|         "json-bigint": "^1.0.0", | ||||
|         "json5-to-table": "^0.1.8", | ||||
|         "markdown-it": "^14.1.0", | ||||
| @@ -28,21 +28,21 @@ | ||||
|         "qrcodejs2": "^0.0.2", | ||||
|         "sortablejs": "^1.15.2", | ||||
|         "vkbeautify": "^0.99.3", | ||||
|         "vue": "^3.4.34", | ||||
|         "vue": "^3.4.37", | ||||
|         "vue-i18n": "^9.13.1", | ||||
|         "vue-router": "^4.4.0", | ||||
|         "vue-router": "^4.4.3", | ||||
|         "vue3-ace-editor": "^2.2.4", | ||||
|         "vue3-json-viewer": "^2.2.2", | ||||
|         "vuedraggable": "^4.0.3", | ||||
|         "vuex": "^4.1.0" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "@vitejs/plugin-vue": "^5.1.1", | ||||
|         "@vitejs/plugin-vue": "^5.1.2", | ||||
|         "prettier": "^3.3.3", | ||||
|         "prettier-plugin-organize-attributes": "^1.0.0", | ||||
|         "sass": "^1.77.8", | ||||
|         "terser": "^5.31.3", | ||||
|         "vite": "^5.3.5" | ||||
|         "terser": "^5.31.5", | ||||
|         "vite": "^5.4.0" | ||||
|     }, | ||||
|     "browserslist": [ | ||||
|         "> 1%", | ||||
|   | ||||
| @@ -21,6 +21,21 @@ | ||||
|                 :placeholder="item.placeholder" | ||||
|                 :style="item.style" | ||||
|                 clearable /> | ||||
|             <el-input | ||||
|                 v-if="item.type === 'select-input' && (!item.condition || item.condition())" | ||||
|                 v-model="form[item.field[0]][selectInputKey]" | ||||
|                 v-role="item.role || '*/*/*'" | ||||
|                 :class="item.class" | ||||
|                 :placeholder="item.placeholder" | ||||
|                 :style="item.style" | ||||
|                 @change="trimSpaces(item.field[0])" | ||||
|                 clearable> | ||||
|                 <template #prepend> | ||||
|                     <el-select v-model="selectInputKey" :placeholder="$t('查询字段')" :style="item.selectStyle" @change="selectInputChange(item)"> | ||||
|                         <el-option v-for="(field, j) in item.field[1]" :label="field.label" :value="field.key" /> | ||||
|                     </el-select> | ||||
|                 </template> | ||||
|             </el-input> | ||||
|             <sc-select | ||||
|                 v-else-if="item.type === 'remote-select' && (!item.condition || item.condition())" | ||||
|                 v-model="form[item.field[0]][item.field[1]]" | ||||
| @@ -93,6 +108,7 @@ export default { | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
|             selectInputKey: null, | ||||
|             dateShortCuts: [ | ||||
|                 { | ||||
|                     text: this.$t('今日'), | ||||
| @@ -342,6 +358,7 @@ export default { | ||||
|     }, | ||||
|     mounted() {}, | ||||
|     async created() { | ||||
|         this.selectInputKey = this.controls.find((x) => x.type === 'select-input')?.field[1][0].key | ||||
|         if (this.dateType === 'datetimerange') { | ||||
|             this.dateShortCuts.unshift( | ||||
|                 { | ||||
| @@ -390,6 +407,14 @@ export default { | ||||
|         }, | ||||
|     }, | ||||
|     methods: { | ||||
|         trimSpaces(key) { | ||||
|             this.form[key][this.selectInputKey] = this.form[key][this.selectInputKey].replace(/^\s*(.*?)\s*$/g, '$1') | ||||
|         }, | ||||
|         selectInputChange(item) { | ||||
|             for (const field of item.field[1]) { | ||||
|                 delete this.form[item.field[0]][field.key] | ||||
|             } | ||||
|         }, | ||||
|         vkbeautify() { | ||||
|             return vkbeautify | ||||
|         }, | ||||
|   | ||||
| @@ -32,7 +32,9 @@ export default { | ||||
|     data() { | ||||
|         return { | ||||
|             user: {}, | ||||
|             form: {}, | ||||
|             form: { | ||||
|                 requiredFields: ['Id', 'UserName', 'Mobile'], | ||||
|             }, | ||||
|         } | ||||
|     }, | ||||
|     watch: { | ||||
|   | ||||
| @@ -2,20 +2,26 @@ | ||||
|  | ||||
| <script> | ||||
| import { h } from 'vue' | ||||
| import config from '@/config' | ||||
|  | ||||
| export default { | ||||
|     data() { | ||||
|         return {} | ||||
|     }, | ||||
|     async created() { | ||||
|         setInterval(async () => { | ||||
|             // 检查版本 | ||||
|             const res = await this.$API.sys_tools.getVersion.post({}) | ||||
|         const ws = new WebSocket(`ws://${config.API_URL.replace('http://', '')}/ws/version`) | ||||
|         ws.onopen = () => { | ||||
|             ws.send('1') | ||||
|         } | ||||
|         ws.onmessage = async (res) => { | ||||
|             if (res.data !== this.$TOOL.data.get('APP_VERSION')) { | ||||
|                 this.$TOOL.data.set('APP_VERSION', res.data) | ||||
|                 await this.$TOOL.data.set('APP_VERSION', res.data) | ||||
|                 this.showTip(res.data.slice(0, res.data.indexOf('+'))) | ||||
|             } else { | ||||
|                 await new Promise((x) => setTimeout(x, 10000)) | ||||
|                 ws.send('1') | ||||
|             } | ||||
|         }, 10000) | ||||
|         } | ||||
|     }, | ||||
|     methods: { | ||||
|         /** | ||||
|   | ||||
| @@ -12,7 +12,7 @@ | ||||
|             <el-card v-for="job in jobs" :class="`user-bar-jobs-item ${job.lastStatusCode === 'oK' ? '' : 'alert'}`" :key="job.id" shadow="hover"> | ||||
|                 <div class="user-bar-jobs-item-body"> | ||||
|                     <div class="jobIcon"> | ||||
|                         {{ job.lastStatusCode }} | ||||
|                         {{ job.lastStatusCode.toUpperCase() }} | ||||
|                     </div> | ||||
|                     <div class="jobMain"> | ||||
|                         <div class="title"> | ||||
|   | ||||
| @@ -495,4 +495,6 @@ export default { | ||||
|     一行一个: 'One line per item', | ||||
|     请输入字段名: 'Please enter field name', | ||||
|     请输入操作符: 'Please enter operator', | ||||
|     查询字段: 'Query field', | ||||
|     最后登录: 'Last login', | ||||
| } | ||||
| @@ -492,4 +492,6 @@ export default { | ||||
|     一行一个: '一行一个', | ||||
|     请输入字段名: '请输入字段名', | ||||
|     请输入操作符: '请输入操作符', | ||||
|     查询字段: '查询字段', | ||||
|     最后登录: '最后登录', | ||||
| } | ||||
| @@ -2,7 +2,10 @@ | ||||
|  | ||||
| html.dark { | ||||
|     //变量 | ||||
|     --el-text-color-primary: #d0d0d0; | ||||
|     --el-text-color-primary: #c0c0c0; | ||||
|     --el-text-color-regular: #c0c0c0; | ||||
|     --el-text-color-secondary: #666666; | ||||
|     --el-mask-color: rgba(29, 30, 31, 0.9); | ||||
|     --el-color-primary-dark-2: var(--el-color-primary-light-2) !important; | ||||
|     --el-color-primary-light-9: var(--el-color-primary-dark-8) !important; | ||||
|     --el-color-primary-light-8: var(--el-color-primary-dark-7) !important; | ||||
| @@ -73,7 +76,7 @@ html.dark { | ||||
|     .el-header, | ||||
|     .el-main.nopadding, | ||||
|     .el-footer { | ||||
|         background: var(--el-bg-color-overlay); | ||||
|         background: var(--el-bg-color); | ||||
|         border-color: var(--el-border-color-light); | ||||
|     } | ||||
|  | ||||
| @@ -93,4 +96,16 @@ html.dark { | ||||
|     .el-table th.is-sortable:hover { | ||||
|         background: #111; | ||||
|     } | ||||
|  | ||||
|     .jv-container .jv-code.open { | ||||
|         background: var(--el-bg-color) !important; | ||||
|     } | ||||
|  | ||||
|     .ace_editor { | ||||
|         background: var(--el-bg-color-overlay); | ||||
|     } | ||||
|  | ||||
|     .ace_gutter { | ||||
|         background: var(--el-bg-color-overlay); | ||||
|     } | ||||
| } | ||||
| @@ -257,7 +257,9 @@ export default { | ||||
|         async copyJob(row) { | ||||
|             let loading = this.$loading() | ||||
|             try { | ||||
|                 const res = await this.$API.sys_job.create.post(Object.assign({}, row, { id: 0, jobName: row.jobName + '-copy' })) | ||||
|                 const res = await this.$API.sys_job.create.post( | ||||
|                     Object.assign({}, row, { id: 0, jobName: row.jobName + '-copy', enabled: false, nextTimeId: null, status: 'idle' }), | ||||
|                 ) | ||||
|                 if (res.data) { | ||||
|                     this.$message.success(this.$t('操作成功')) | ||||
|                 } else { | ||||
|   | ||||
| @@ -44,7 +44,7 @@ | ||||
|                             v-model:value="form.requestHeader" | ||||
|                             :theme="this.$TOOL.data.get('APP_SET_DARK') || this.$CONFIG.APP_SET_DARK ? 'github_dark' : 'github'" | ||||
|                             lang="json" | ||||
|                             style="height: 5rem; width: 100%" /> | ||||
|                             style="height: 10rem; width: 100%" /> | ||||
|                         <el-button @click="form.requestHeader = jsonFormat(form.requestHeader)" type="text">{{ $t('JSON格式化') }}</el-button> | ||||
|                     </el-form-item> | ||||
|                     <el-form-item :label="$t('请求体')" prop="requestBody"> | ||||
| @@ -52,7 +52,7 @@ | ||||
|                             v-model:value="form.requestBody" | ||||
|                             :theme="this.$TOOL.data.get('APP_SET_DARK') || this.$CONFIG.APP_SET_DARK ? 'github_dark' : 'github'" | ||||
|                             lang="json" | ||||
|                             style="height: 10rem; width: 100%" /> | ||||
|                             style="height: 15rem; width: 100%" /> | ||||
|                         <el-button @click="form.requestBody = jsonFormat(form.requestBody)" type="text">{{ $t('JSON格式化') }}</el-button> | ||||
|                     </el-form-item> | ||||
|                     <el-form-item :label="$t('请求的网络地址')" prop="requestUrl"> | ||||
| @@ -107,7 +107,7 @@ | ||||
|                 </el-form> | ||||
|             </el-tab-pane> | ||||
|             <el-tab-pane v-if="mode === 'view'" :label="$t('执行记录')" name="record"> | ||||
|                 <record v-if="tabId === 'record'" :keywords="form.id" /> | ||||
|                 <record v-if="tabId === 'record'" :job-id="form.id" /> | ||||
|             </el-tab-pane> | ||||
|             <el-tab-pane v-if="mode === 'view'" :label="$t('原始数据')"> | ||||
|                 <json-viewer | ||||
|   | ||||
| @@ -28,13 +28,21 @@ | ||||
|                             style: 'width:20rem', | ||||
|                         }, | ||||
|                         { | ||||
|                             type: 'input', | ||||
|                             field: ['root', 'keywords'], | ||||
|                             placeholder: $t('作业编号 / 作业名称 / 执行编号'), | ||||
|                             style: 'width:20rem', | ||||
|                             type: 'select-input', | ||||
|                             field: [ | ||||
|                                 'dy', | ||||
|                                 [ | ||||
|                                     { label: '唯一编码', key: 'id' }, | ||||
|                                     { label: '作业编号', key: 'jobId' }, | ||||
|                                 ], | ||||
|                             ], | ||||
|                             placeholder: '匹配内容', | ||||
|                             style: 'width:25rem', | ||||
|                             selectStyle: 'width:8rem', | ||||
|                         }, | ||||
|                     ]" | ||||
|                     :vue="this" | ||||
|                     @reset="onReset" | ||||
|                     @search="onSearch" | ||||
|                     dateFormat="YYYY-MM-DD HH:mm:ss" | ||||
|                     dateType="datetimerange" | ||||
| @@ -73,7 +81,7 @@ | ||||
|                                 :data="row" | ||||
|                                 :options=" | ||||
|                                     Object.entries(this.$GLOBAL.enums.httpMethods).map((x) => { | ||||
|                                         return { value: x[0], text: `${x[1][1]}`, type: x[1][2] } | ||||
|                                         return { value: x[0], text: `${x[1][1].toString().toUpperCase()}`, type: x[1][2] } | ||||
|                                     }) | ||||
|                                 " | ||||
|                                 prop="httpMethod" /> | ||||
| @@ -96,8 +104,8 @@ | ||||
|                     align="right" | ||||
|                     prop="duration" | ||||
|                     sortable="custom" | ||||
|                     width="150" /> | ||||
|                 <el-table-column :label="$t('作业信息')" prop="jobId" show-overflow-tooltip sortable="custom" width="500"> | ||||
|                     width="100" /> | ||||
|                 <el-table-column :label="$t('作业信息')" min-width="150" prop="jobId" show-overflow-tooltip sortable="custom"> | ||||
|                     <template #default="{ row }"> | ||||
|                         <p> | ||||
|                             <el-link @click="jobClick(row.job)"> | ||||
| @@ -109,7 +117,7 @@ | ||||
|                         </p> | ||||
|                     </template> | ||||
|                 </el-table-column> | ||||
|                 <el-table-column :label="$t('响应体')" prop="responseBody" show-overflow-tooltip sortable="custom" /> | ||||
|                 <el-table-column :label="$t('响应体')" min-width="300" prop="responseBody" show-overflow-tooltip sortable="custom" /> | ||||
|                 <na-col-operation :buttons="[naColOperation.buttons[0]]" :vue="this" width="100" /> | ||||
|             </sc-table> | ||||
|         </el-main> | ||||
| @@ -165,6 +173,13 @@ export default { | ||||
|                 }), | ||||
|             }) | ||||
|         } | ||||
|         if (this.jobId) { | ||||
|             this.query.dynamicFilter.filters.push({ | ||||
|                 field: 'jobId', | ||||
|                 operator: 'eq', | ||||
|                 value: this.jobId, | ||||
|             }) | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
| @@ -181,7 +196,6 @@ export default { | ||||
|                     ], | ||||
|                 }, | ||||
|                 filter: {}, | ||||
|                 keywords: this.keywords, | ||||
|             }, | ||||
|         } | ||||
|     }, | ||||
| @@ -190,6 +204,11 @@ export default { | ||||
|         jobClick(job) { | ||||
|             this.dialog.job = { mode: 'view', row: { id: job.id } } | ||||
|         }, | ||||
|         onReset() { | ||||
|             if (this.jobId) { | ||||
|                 this.$refs.search.selectInputKey = 'jobId' | ||||
|             } | ||||
|         }, | ||||
|         //搜索 | ||||
|         onSearch(form) { | ||||
|             if (Array.isArray(form.dy.createdTime)) { | ||||
| @@ -223,6 +242,22 @@ export default { | ||||
|                 }) | ||||
|             } | ||||
|  | ||||
|             if (typeof form.dy.jobId === 'string' && form.dy.jobId.trim() !== '') { | ||||
|                 this.query.dynamicFilter.filters.push({ | ||||
|                     field: 'jobId', | ||||
|                     operator: 'eq', | ||||
|                     value: form.dy.jobId, | ||||
|                 }) | ||||
|             } | ||||
|  | ||||
|             if (typeof form.dy.jobId === 'number' && form.dy.jobId !== 0) { | ||||
|                 this.query.dynamicFilter.filters.push({ | ||||
|                     field: 'jobId', | ||||
|                     operator: 'eq', | ||||
|                     value: form.dy.jobId, | ||||
|                 }) | ||||
|             } | ||||
|  | ||||
|             if (typeof form.dy.httpMethod === 'string' && form.dy.httpMethod.trim() !== '') { | ||||
|                 this.query.dynamicFilter.filters.push({ | ||||
|                     field: 'httpMethod', | ||||
| @@ -234,12 +269,13 @@ export default { | ||||
|         }, | ||||
|     }, | ||||
|     mounted() { | ||||
|         if (this.keywords) { | ||||
|             this.$refs.search.form.root.keywords = this.keywords | ||||
|         if (this.jobId) { | ||||
|             this.$refs.search.selectInputKey = 'jobId' | ||||
|             this.$refs.search.form.dy.jobId = this.jobId | ||||
|             this.$refs.search.keeps.push({ | ||||
|                 field: 'keywords', | ||||
|                 value: this.keywords, | ||||
|                 type: 'root', | ||||
|                 field: 'jobId', | ||||
|                 value: this.jobId, | ||||
|                 type: 'dy', | ||||
|             }) | ||||
|         } | ||||
|         if (this.statusCodes) { | ||||
| @@ -260,7 +296,7 @@ export default { | ||||
|             type: 'dy', | ||||
|         }) | ||||
|     }, | ||||
|     props: ['keywords', 'statusCodes'], | ||||
|     props: ['statusCodes', 'jobId'], | ||||
|     watch: {}, | ||||
| } | ||||
| </script> | ||||
|   | ||||
| @@ -44,10 +44,18 @@ | ||||
|                             style: 'width:20rem', | ||||
|                         }, | ||||
|                         { | ||||
|                             type: 'input', | ||||
|                             field: ['root', 'keywords'], | ||||
|                             placeholder: $t('日志编号 / 用户编号 / 客户端IP'), | ||||
|                             type: 'select-input', | ||||
|                             field: [ | ||||
|                                 'dy', | ||||
|                                 [ | ||||
|                                     { label: '日志编号', key: 'id' }, | ||||
|                                     { label: '用户编号', key: 'ownerId' }, | ||||
|                                     { label: '客户端IP', key: 'createdClientIp' }, | ||||
|                                 ], | ||||
|                             ], | ||||
|                             placeholder: '匹配内容', | ||||
|                             style: 'width:25rem', | ||||
|                             selectStyle: 'width:8rem', | ||||
|                         }, | ||||
|                     ]" | ||||
|                     :vue="this" | ||||
| @@ -178,6 +186,9 @@ export default { | ||||
|         if (this.ownerId) { | ||||
|             this.query.dynamicFilter.filters.push({ field: 'ownerId', operator: 'eq', value: this.ownerId }) | ||||
|         } | ||||
|         if (this.excludeApiPathCrc32) { | ||||
|             this.query.dynamicFilter.filters.push({ field: 'apiPathCrc32', operator: 'notEqual', value: this.excludeApiPathCrc32 }) | ||||
|         } | ||||
|     }, | ||||
|     data() { | ||||
|         return { | ||||
| @@ -194,10 +205,7 @@ export default { | ||||
|                         { | ||||
|                             field: 'createdTime', | ||||
|                             operator: 'dateRange', | ||||
|                             value: [ | ||||
|                                 this.$TOOL.dateFormat(new Date(new Date() - 3600 * 1000), 'yyyy-MM-dd hh:mm:ss'), | ||||
|                                 this.$TOOL.dateFormat(new Date(), 'yyyy-MM-dd hh:mm:ss'), | ||||
|                             ], | ||||
|                             value: [this.$TOOL.dateFormat(new Date(), 'yyyy-MM-dd'), this.$TOOL.dateFormat(new Date(), 'yyyy-MM-dd')], | ||||
|                         }, | ||||
|                     ], | ||||
|                 }, | ||||
| @@ -268,6 +276,13 @@ export default { | ||||
|                     }), | ||||
|                 }) | ||||
|             } | ||||
|             if (typeof form.dy['excludeApiPathCrc32'] === 'number' && form.dy['excludeApiPathCrc32'] !== 0) { | ||||
|                 this.query.dynamicFilter.filters.push({ | ||||
|                     field: 'apiPathCrc32', | ||||
|                     operator: 'notEqual', | ||||
|                     value: form.dy['excludeApiPathCrc32'], | ||||
|                 }) | ||||
|             } | ||||
|             if (typeof form.dy['apiPathCrc32'] === 'number' && form.dy['apiPathCrc32'] !== 0) { | ||||
|                 this.query.dynamicFilter.filters.push({ | ||||
|                     field: 'apiPathCrc32', | ||||
| @@ -284,6 +299,29 @@ export default { | ||||
|                 }) | ||||
|             } | ||||
|  | ||||
|             if (typeof form.dy.ownerId === 'string' && form.dy.ownerId.trim() !== '') { | ||||
|                 this.query.dynamicFilter.filters.push({ | ||||
|                     field: 'ownerId', | ||||
|                     operator: 'eq', | ||||
|                     value: form.dy.ownerId, | ||||
|                 }) | ||||
|             } | ||||
|             if (typeof form.dy.id === 'string' && form.dy.id.trim() !== '') { | ||||
|                 this.query.dynamicFilter.filters.push({ | ||||
|                     field: 'id', | ||||
|                     operator: 'eq', | ||||
|                     value: form.dy.id, | ||||
|                 }) | ||||
|             } | ||||
|  | ||||
|             if (typeof form.dy.createdClientIp === 'string' && form.dy.createdClientIp.trim() !== '') { | ||||
|                 this.query.dynamicFilter.filters.push({ | ||||
|                     field: 'createdClientIp', | ||||
|                     operator: 'eq', | ||||
|                     value: form.dy.createdClientIp, | ||||
|                 }) | ||||
|             } | ||||
|  | ||||
|             if (typeof form.dy.operationResult === 'boolean') { | ||||
|                 this.query.dynamicFilter.filters.push( | ||||
|                     form.dy.operationResult | ||||
| @@ -334,9 +372,18 @@ export default { | ||||
|             this.$refs.search.form.dy.ownerId = this.ownerId | ||||
|         } | ||||
|  | ||||
|         if (this.excludeApiPathCrc32) { | ||||
|             this.$refs.search.keeps.push({ | ||||
|                 field: 'excludeApiPathCrc32', | ||||
|                 value: this.excludeApiPathCrc32, | ||||
|                 type: 'dy', | ||||
|             }) | ||||
|             this.$refs.search.form.dy.excludeApiPathCrc32 = this.excludeApiPathCrc32 | ||||
|         } | ||||
|  | ||||
|         this.$refs.search.form.dy.createdTime = [ | ||||
|             this.$TOOL.dateFormat(new Date(new Date() - 3600 * 1000), 'yyyy-MM-dd hh:mm:ss'), | ||||
|             this.$TOOL.dateFormat(new Date(), 'yyyy-MM-dd hh:mm:ss'), | ||||
|             this.$TOOL.dateFormat(new Date(), 'yyyy-MM-dd 00:00:00'), | ||||
|             this.$TOOL.dateFormat(new Date(), 'yyyy-MM-dd 00:00:00'), | ||||
|         ] | ||||
|         this.$refs.search.keeps.push({ | ||||
|             field: 'createdTime', | ||||
| @@ -344,7 +391,7 @@ export default { | ||||
|             type: 'dy', | ||||
|         }) | ||||
|     }, | ||||
|     props: ['keywords', 'ownerId'], | ||||
|     props: ['keywords', 'ownerId', 'excludeApiPathCrc32'], | ||||
|     watch: {}, | ||||
| } | ||||
| </script> | ||||
|   | ||||
| @@ -102,6 +102,11 @@ | ||||
|                     field="name" | ||||
|                     prop="dept" | ||||
|                     width="200" /> | ||||
|                 <el-table-column :label="$t('最后登录')" align="right" prop="lastLoginTime" sortable="custom" width="120"> | ||||
|                     <template #default="{ row }"> | ||||
|                         <span v-time.tip="row.lastLoginTime" :title="row.lastLoginTime"></span> | ||||
|                     </template> | ||||
|                 </el-table-column> | ||||
|                 <el-table-column :label="$t('启用')" align="center" prop="enabled" sortable="custom" width="100"> | ||||
|                     <template #default="{ row }"> | ||||
|                         <el-switch v-model="row.enabled" @change="changeSwitch($event, row)"></el-switch> | ||||
|   | ||||
| @@ -70,6 +70,9 @@ | ||||
|                             <el-switch v-model="form.enabled"></el-switch> | ||||
|                         </el-form-item> | ||||
|                     </template> | ||||
|                     <el-form-item v-if="mode === 'view'" :label="$t('最后登录')" prop="summary"> | ||||
|                         <el-input v-model="form.lastLoginTime" clearable></el-input> | ||||
|                     </el-form-item> | ||||
|                     <el-form-item :label="$t('备注')" prop="summary"> | ||||
|                         <el-input v-model="form.summary" clearable type="textarea"></el-input> | ||||
|                     </el-form-item> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 GitHub
						GitHub