diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..1ff0c4230 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore index 9491a2fda..9d098c4ab 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,9 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +/src/*Pro*/ +/src/*Pro* +/src/*pro* +/src/*pro*/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 1cb149fd6..000000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "Admin"] - url = https://gitee.com/ThingsGateway/BlazorAdmin - path = Admin - diff --git a/Admin b/Admin deleted file mode 160000 index 3b73b7283..000000000 --- a/Admin +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3b73b7283ac180a3a3bb2a0a1ecdd87a516b570a diff --git a/README.md b/README.md index 9728781da..a37b71a2b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## Introduction  -A cross-platform, high-performance edge data collection gateway based on net8, capable of handling millions of data points per. +A cross-platform, high-performance edge data collection gateway based on net9.  ## Documentation diff --git a/README.zh-CN.md b/README.zh-CN.md index da7bbf8a6..a1a412b67 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,7 +2,11 @@ ## 介绍 -基于net8的跨平台高性能边缘采集网关,单机采集数据点位可达百万 +基于net9的跨平台高性能边缘采集网关 + +最新版本分支:10.0 + +稳定版分支:Release ## 文档 @@ -15,7 +19,7 @@ ### 源码克隆 -注意因仓库包含子模块,直接下载zip包会导致子模块丢失,建议使用git clone命令 +建议使用git clone命令 ``` shell @@ -23,29 +27,6 @@ https://gitee.com/ThingsGateway/ThingsGateway.git ``` -### 源码更新 - -在vs中打开powerShell窗口,执行以下命令,或根目录下的`git_pull.bat`脚本 - - - - -``` shell - -chcp 65001 - -rem 更新主仓库 -git pull - -rem 初始化并更新所有子模块 -git submodule update --init - -pause - -``` - - - ### 插件列表 #### 采集插件 diff --git a/git_pull.bat b/git_pull.bat deleted file mode 100644 index 439fe704a..000000000 --- a/git_pull.bat +++ /dev/null @@ -1,9 +0,0 @@ -chcp 65001 - -rem 更新主仓库 -git pull - -rem 初始化并更新所有子模块 -git submodule update --init - -pause \ No newline at end of file diff --git a/src/Admin/README.md b/src/Admin/README.md new file mode 100644 index 000000000..dd1f01fdc --- /dev/null +++ b/src/Admin/README.md @@ -0,0 +1,21 @@ + +

ThingsBlazor

+

权限管理框架

+ + +### 🎁 框架介绍 + 一个通用的AspNetCore小型权限框架,BlazorServer实现RBAC权限管理,在线会话管理。 + + + ### 📙 衍生作品 + - 👉[ThingsGateway](https://gitee.com/diego2098/ThingsGateway) + 跨平台边缘采集网关 + +### 💐 特别鸣谢/源码参考(引用) + - 👉[Simple.Admin](https://gitee.com/zxzyjs/SimpleAdmin) + - 👉[Furion](https://gitee.com/dotnetchina/Furion) + - 👉[Bootstrap Blazor](https://www.blazor.zone/) + +### 🍎 联系作者 + * QQ群:605534569 + * 邮箱:2248356998@qq.com \ No newline at end of file diff --git a/src/Admin/README.zh-CN.md b/src/Admin/README.zh-CN.md new file mode 100644 index 000000000..dd1f01fdc --- /dev/null +++ b/src/Admin/README.zh-CN.md @@ -0,0 +1,21 @@ + +

ThingsBlazor

+

权限管理框架

+ + +### 🎁 框架介绍 + 一个通用的AspNetCore小型权限框架,BlazorServer实现RBAC权限管理,在线会话管理。 + + + ### 📙 衍生作品 + - 👉[ThingsGateway](https://gitee.com/diego2098/ThingsGateway) + 跨平台边缘采集网关 + +### 💐 特别鸣谢/源码参考(引用) + - 👉[Simple.Admin](https://gitee.com/zxzyjs/SimpleAdmin) + - 👉[Furion](https://gitee.com/dotnetchina/Furion) + - 👉[Bootstrap Blazor](https://www.blazor.zone/) + +### 🍎 联系作者 + * QQ群:605534569 + * 邮箱:2248356998@qq.com \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Application/Aop/OperDescAttribute.cs b/src/Admin/ThingsGateway.Admin.Application/Aop/OperDescAttribute.cs new file mode 100644 index 000000000..e638c3e51 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Aop/OperDescAttribute.cs @@ -0,0 +1,164 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using Rougamo; +using Rougamo.Context; +using Rougamo.Metadatas; + +using System.Collections.Concurrent; + +using ThingsGateway.Extension; +using ThingsGateway.FriendlyException; +using ThingsGateway.NewLife.Json.Extension; + +namespace ThingsGateway.Admin.Application; + +/// +/// Aop拦截器 +/// +[Pointcut(AccessFlags.Public | AccessFlags.Method)] +[Advice(Feature.OnException | Feature.OnSuccess)] +public sealed class OperDescAttribute : MoAttribute +{ + /// + /// 日志消息队列(线程安全) + /// + private static readonly ConcurrentQueue _logMessageQueue = new(); + private static readonly IAppService AppService; + + static OperDescAttribute() + { + // 创建长时间运行的后台任务,并将日志消息队列中数据写入存储中 + Task.Factory.StartNew(ProcessQueue, TaskCreationOptions.LongRunning); + AppService = App.RootServices.GetService(); + } + + public OperDescAttribute(string description, bool isRecordPar = true, object localizerType = null) + { + Description = description; + IsRecordPar = isRecordPar; + LocalizerType = (Type)localizerType; + } + + /// + /// 说明,需配置本地化json文件 + /// + public string Description { get; } + + /// + /// 是否记录进出参数 + /// + public bool IsRecordPar { get; } + + public Type? LocalizerType { get; } + + public override void OnException(MethodContext context) + { + //插入异常日志 + SysOperateLog log = GetOperLog(LocalizerType, context); + + log.Category = LogCateGoryEnum.Exception;//操作类型为异常 + log.ExeStatus = false;//操作状态为失败 + if (context.Exception is AppFriendlyException exception) + log.ExeMessage = exception?.Message; + else + log.ExeMessage = context.Exception?.ToString(); + + OperDescAttribute.WriteToQueue(log); + } + + public override void OnSuccess(MethodContext context) + { + //插入操作日志 + SysOperateLog log = GetOperLog(LocalizerType, context); + OperDescAttribute.WriteToQueue(log); + } + + /// + /// 将日志消息写入数据库中 + /// + private static async Task ProcessQueue() + { + var db = DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); + var appLifetime = App.RootServices!.GetService()!; + while (!appLifetime.ApplicationStopping.IsCancellationRequested) + { + try + { + var data = _logMessageQueue.ToListWithDequeue(); // 从日志队列中获取数据 + if (data.Count > 0) + { + await db.InsertableWithAttr(data).ExecuteCommandAsync(appLifetime.ApplicationStopping).ConfigureAwait(false);//入库 + } + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + finally + { + await Task.Delay(3000, appLifetime.ApplicationStopping).ConfigureAwait(false); + } + } + } + + private SysOperateLog GetOperLog(Type? localizerType, MethodContext context) + { + var methodBase = context.Method; + var clientInfo = AppService.ClientInfo; + string? paramJson = null; + if (IsRecordPar) + { + var args = context.Arguments; + var parametersInfo = methodBase.GetParameters(); + var parametersDict = new Dictionary(); + + for (int i = 0; i < parametersInfo.Length; i++) + { + parametersDict[parametersInfo[i].Name!] = args[i]; + } + paramJson = parametersDict.ToJsonNetString(); + } + var result = context.ReturnValue; + var resultJson = IsRecordPar ? result?.ToJsonNetString() : null; + //操作日志表实体 + var log = new SysOperateLog + { + Name = (localizerType == null ? App.CreateLocalizerByType(typeof(OperDescAttribute)) : App.CreateLocalizerByType(localizerType))![Description], + Category = LogCateGoryEnum.Operate, + ExeStatus = true, + OpIp = AppService?.RemoteIpAddress?.MapToIPv4()?.ToString() ?? string.Empty, + OpBrowser = clientInfo?.UA?.Family + clientInfo?.UA?.Major, + OpOs = clientInfo?.OS?.Family + clientInfo?.OS?.Major, + OpTime = DateTime.Now, + OpAccount = UserManager.UserAccount, + ReqUrl = null, + ReqMethod = "browser", + ResultJson = resultJson, + ClassName = methodBase.ReflectedType!.Name, + MethodName = methodBase.Name, + ParamJson = paramJson, + VerificatId = UserManager.VerificatId, + }; + return log; + } + + /// + /// 将日志消息写入队列中等待后台任务出队写入数据库 + /// + /// 结构化日志消息 + private static void WriteToQueue(SysOperateLog logMsg) + { + _logMessageQueue.Enqueue(logMsg); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Attributes/IgnoreExcelAttribute.cs b/src/Admin/ThingsGateway.Admin.Application/Attributes/IgnoreExcelAttribute.cs new file mode 100644 index 000000000..48c9d491c --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Attributes/IgnoreExcelAttribute.cs @@ -0,0 +1,19 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 忽略Excel导入导出 +/// +[AttributeUsage(AttributeTargets.Property)] +public sealed class IgnoreExcelAttribute : Attribute +{ +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Attributes/IgnoreRolePermissionAttribute.cs b/src/Admin/ThingsGateway.Admin.Application/Attributes/IgnoreRolePermissionAttribute.cs new file mode 100644 index 000000000..19045fbf1 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Attributes/IgnoreRolePermissionAttribute.cs @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 需要角色授权权限 +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class RolePermissionAttribute : Attribute +{ +} + +/// +/// 忽略角色授权权限 +/// +public sealed class IgnoreRolePermissionAttribute : Attribute +{ +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Attributes/IgnoreSeedDataAttribute.cs b/src/Admin/ThingsGateway.Admin.Application/Attributes/IgnoreSeedDataAttribute.cs new file mode 100644 index 000000000..5f6751daf --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Attributes/IgnoreSeedDataAttribute.cs @@ -0,0 +1,35 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 种子数据忽略新增 +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class IgnoreSeedDataAddAttribute : Attribute +{ +} + +/// +/// 种子数据忽略修改 +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class IgnoreSeedDataUpdateAttribute : Attribute +{ +} + +/// +/// 忽略初始化表 +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class IgnoreInitTableAttribute : Attribute +{ +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Attributes/SuperAdminAttribute.cs b/src/Admin/ThingsGateway.Admin.Application/Attributes/SuperAdminAttribute.cs new file mode 100644 index 000000000..7d90ba723 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Attributes/SuperAdminAttribute.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 管理员才能访问 +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class SuperAdminAttribute : Attribute +{ +} + +/// +/// 忽略超级管理员才能访问特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class IgnoreSuperAdminAttribute : Attribute +{ +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Const/CacheConst.cs b/src/Admin/ThingsGateway.Admin.Application/Const/CacheConst.cs new file mode 100644 index 000000000..16f3da5a6 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Const/CacheConst.cs @@ -0,0 +1,85 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class CacheConst +{ + /// + /// Token表缓存Key + /// + public const string Cache_HardwareInfo = $"{CacheConst.Cache_Prefix_Admin}Cache_HardwareInfo:"; + + public const string Cache_Prefix_Admin = "ThingsGatewayAdmin:"; + + /// + /// 系统字典表缓存Key + /// + public const string Cache_SysDict = $"{CacheConst.Cache_Prefix_Admin}SysDict:"; + + /// + /// 关系表缓存Key + /// + public const string Cache_SysRelation = $"{CacheConst.Cache_Prefix_Admin}SysRelation:"; + + /// + /// 资源表缓存Key + /// + public const string Cache_SysResource = $"{CacheConst.Cache_Prefix_Admin}SysResource:"; + + /// + /// 角色表缓存Key + /// + public const string Cache_SysRole = $"{CacheConst.Cache_Prefix_Admin}SysRole:"; + + /// + /// 用户表缓存Key + /// + public const string Cache_SysUser = $"{CacheConst.Cache_Prefix_Admin}SysUser:"; + + /// + /// 用户账号关系缓存Key + /// + public const string Cache_SysUserAccount = $"{CacheConst.Cache_Prefix_Admin}SysUserAccount:"; + + /// + /// 职位表缓存Key + /// + public const string Cache_SysPosition = $"{CacheConst.Cache_Prefix_Admin}SysPosition:"; + + /// + /// 机构表缓存Key + /// + public const string Cache_SysOrg = $"{CacheConst.Cache_Prefix_Admin}SysOrg:"; + + /// + /// 公司表缓存Key + /// + public const string Cache_SysTenant = $"{CacheConst.Cache_Prefix_Admin}Tenant:"; + /// + /// 公司表缓存Key + /// + public const string Cache_SysOrgTenant = $"{CacheConst.Cache_Prefix_Admin}OrgTenant:"; + + /// + /// Token表缓存Key + /// + public const string Cache_Token = $"{CacheConst.Cache_Prefix_Admin}Token:"; + + #region 登录错误次数 + + /// + /// 登录错误次数缓存Key + /// + public const string Cache_LoginErrorCount = $"{CacheConst.Cache_Prefix_Admin}LoginErrorCount:"; + + #endregion 登录错误次数 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Const/ClaimConst.cs b/src/Admin/ThingsGateway.Admin.Application/Const/ClaimConst.cs new file mode 100644 index 000000000..f746ce60f --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Const/ClaimConst.cs @@ -0,0 +1,50 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 授权用户常量 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class ClaimConst +{ + /// + /// 账号 + /// + public const string Account = "Account"; + + /// + /// SuperAdmin + /// + public const string SuperAdmin = "SuperAdmin"; + + /// + /// 用户Id + /// + public const string UserId = "UserId"; + + /// + /// 验证Id + /// + public const string VerificatId = "VerificatId"; + + /// + /// 组织Id + /// + public const string OrgId = "OrgId"; + + /// + /// 租户Id + /// + public const string TenantId = "TenantId"; + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Const/ResourceConst.cs b/src/Admin/ThingsGateway.Admin.Application/Const/ResourceConst.cs new file mode 100644 index 000000000..3152560fd --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Const/ResourceConst.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 资源表常量 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class ResourceConst +{ + /// + /// 系统内置编码 + /// + public const string System = "System"; + + /// + /// 系统管理内置ID 1 + /// + public const long SystemId = 2; +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Const/RoleConst.cs b/src/Admin/ThingsGateway.Admin.Application/Const/RoleConst.cs new file mode 100644 index 000000000..2ac3c3601 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Const/RoleConst.cs @@ -0,0 +1,50 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 角色常量 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class RoleConst +{ + /// + /// api角色 + /// + public const string ApiRole = "ApiRole"; + + /// + /// 业务管理员 + /// + public const string BizAdmin = "BizAdmin"; + + /// + /// 超级管理员 + /// + public const string SuperAdmin = "SuperAdmin"; + + /// + /// 超级管理员Id + /// + public const long SuperAdminId = 212725263002001; + /// + /// 默认租户Id + /// + public const long DefaultTenantId = 252885263003720; + /// + /// 默认岗位Id + /// + public const long DefaultPositionId = 212725263003001; + /// + /// 超级管理员Id + /// + public const long SuperAdminRoleId = 212725263001001; +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Const/SqlSugarConst.cs b/src/Admin/ThingsGateway.Admin.Application/Const/SqlSugarConst.cs new file mode 100644 index 000000000..6ef6796f2 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Const/SqlSugarConst.cs @@ -0,0 +1,43 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// SqlSugar系统常量 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class SqlSugarConst +{ + /// + /// DB_Admin + /// + public const string DB_Admin = "DB_Admin"; + + /// + /// DB_Custom + /// + public const string DB_Custom = "DB_Custom"; + + /// + /// DB_HardwareInfo + /// + public const string DB_HardwareInfo = "DB_HardwareInfo"; + + /// + /// DB_Log + /// + public const string DB_Log = "DB_Log"; + + /// + /// DB_TokenCache + /// + public const string DB_TokenCache = "DB_TokenCache"; +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Controller/AuthController.cs b/src/Admin/ThingsGateway.Admin.Application/Controller/AuthController.cs new file mode 100644 index 000000000..86f3eeb7d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Controller/AuthController.cs @@ -0,0 +1,42 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ThingsGateway.Admin.Application; + +[ApiDescriptionSettings(false)] +[Route("api/auth")] +[LoggingMonitor] +public class AuthController : ControllerBase +{ + private readonly IAuthService _authService; + + public AuthController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + [AllowAnonymous] + public Task LoginAsync([FromBody] LoginInput input) + { + return _authService.LoginAsync(input); + } + + [HttpPost("logout")] + [Authorize] + [IgnoreRolePermission] + public Task LogoutAsync() + { + return _authService.LoginOutAsync(); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Controller/CultureController.cs b/src/Admin/ThingsGateway.Admin.Application/Controller/CultureController.cs new file mode 100644 index 000000000..2e2649d27 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Controller/CultureController.cs @@ -0,0 +1,72 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Mvc; + +using RouteAttribute = Microsoft.AspNetCore.Mvc.RouteAttribute; + +namespace ThingsGateway.Admin.Application; + +/// +/// 文化 Controller +/// +[ApiDescriptionSettings(false)] +[Route("[controller]/[action]")] +public class CultureController : Controller +{ + /// + /// 重置文化方法 + /// + /// + /// + [HttpGet] + public IActionResult ResetCulture(string redirectUri) + { + HttpContext.Response.Cookies.Delete(CookieRequestCultureProvider.DefaultCookieName); + + return LocalRedirect(redirectUri); + } + + /// + /// 设置文化方法 + /// + /// + /// + /// + [HttpGet] + public IActionResult SetCulture(string culture, string redirectUri) + { + if (string.IsNullOrEmpty(culture)) + { + HttpContext.Response.Cookies.Delete(CookieRequestCultureProvider.DefaultCookieName); + } + else + { + HttpContext.Response.Cookies.Append( + CookieRequestCultureProvider.DefaultCookieName, + CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture, culture)), new CookieOptions() + { + Expires = DateTimeOffset.Now.AddYears(1) + }); + + //更改全局文化,采集后台也会变化 + //var cultureInfo = new CultureInfo(culture); + //CultureInfo.DefaultThreadCurrentCulture = cultureInfo; + //CultureInfo.DefaultThreadCurrentUICulture = cultureInfo; + + //CultureInfo.CurrentCulture = cultureInfo; + //CultureInfo.CurrentUICulture = cultureInfo; + } + + return LocalRedirect(redirectUri); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Controller/FileController.cs b/src/Admin/ThingsGateway.Admin.Application/Controller/FileController.cs new file mode 100644 index 000000000..76f24947b --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Controller/FileController.cs @@ -0,0 +1,50 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ThingsGateway.Admin.Application; + +/// +/// 文件下载 +/// +[ApiDescriptionSettings(false)] +[Route("api/file")] +public class FileController : ControllerBase +{ + /// + /// 下载wwwroot文件夹下的文件 + /// + /// 相对路径 + /// + [HttpGet("download")] + public IActionResult Download(string fileName) + { + // Validate the fileName to prevent path injection + if (string.IsNullOrEmpty(fileName)) + { + return BadRequest("Invalid file name."); + } + + var filePath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", fileName); + + if (!System.IO.File.Exists(filePath)) + { + return NotFound(); + } + + var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + Response.Headers.Append("Access-Control-Expose-Headers", "Content-Disposition"); + + return File(fileStream, "application/octet-stream", (fileName.Replace('/', '_'))); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Controller/OpenApiController.cs b/src/Admin/ThingsGateway.Admin.Application/Controller/OpenApiController.cs new file mode 100644 index 000000000..eea51be0e --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Controller/OpenApiController.cs @@ -0,0 +1,58 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Mapster; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +using System.ComponentModel; + +namespace ThingsGateway.Admin.Application; + + +/// +/// 登录 +/// +[ApiDescriptionSettings("ThingsGateway.OpenApi", Order = 200)] +[Description("登录")] +[Route("openapi/auth")] +[Authorize(AuthenticationSchemes = "Bearer")] +[LoggingMonitor] +public class OpenApiController : ControllerBase +{ + private readonly IAuthService _authService; + + public OpenApiController(IAuthService authService) + { + _authService = authService; + } + + [HttpPost("login")] + [DisplayName("登录")] + [AllowAnonymous] + public async Task LoginAsync([FromBody] OpenApiLoginInput input) + { + var output = await _authService.LoginAsync(input.Adapt(), false).ConfigureAwait(false); + + var openApiLoginOutput = output.Adapt(); + + return openApiLoginOutput; + } + + [HttpPost("logout")] + [Authorize] + [DisplayName("登出")] + [IgnoreRolePermission] + public Task LogoutAsync() + { + return _authService.LoginOutAsync(); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Controller/TestController.cs b/src/Admin/ThingsGateway.Admin.Application/Controller/TestController.cs new file mode 100644 index 000000000..1d6a74389 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Controller/TestController.cs @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------------ +//此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ThingsGateway.Admin.Application; + +[Route("api/[controller]/[action]")] +[RolePermission] +[Authorize(AuthenticationSchemes = "Bearer")] +public class TestController : ControllerBase +{ + [HttpPost] + public async Task Test(string data) + { + await Task.CompletedTask.ConfigureAwait(false); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/BaseEntity.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/BaseEntity.cs new file mode 100644 index 000000000..05720a4dd --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/BaseEntity.cs @@ -0,0 +1,148 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using System.Diagnostics.CodeAnalysis; + +namespace ThingsGateway.Admin.Application; + +/// +/// 主键id基类 +/// +public abstract class PrimaryIdEntity : IPrimaryIdEntity +{ + /// + /// 主键Id + /// + [SugarColumn(ColumnDescription = "Id", IsPrimaryKey = true)] + [IgnoreExcel] + [AutoGenerateColumn(Visible = false, IsVisibleWhenEdit = false, IsVisibleWhenAdd = false, Sortable = true, DefaultSort = true, DefaultSortOrder = SortOrder.Asc)] + public virtual long Id { get; set; } +} + +/// +/// 主键实体基类 +/// +public abstract class PrimaryKeyEntity : PrimaryIdEntity +{ + /// + /// 拓展信息 + /// + [SugarColumn(ColumnDescription = "扩展信息", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Ignore = true)] + public virtual string? ExtJson { get; set; } +} + +public interface IBaseEntity +{ + DateTime? CreateTime { get; set; } + string? CreateUser { get; set; } + long CreateUserId { get; set; } + bool IsDelete { get; set; } + int? SortCode { get; set; } + DateTime? UpdateTime { get; set; } + string? UpdateUser { get; set; } + long? UpdateUserId { get; set; } +} + +/// +/// 框架实体基类 +/// +public abstract class BaseEntity : PrimaryKeyEntity, IBaseEntity +{ + /// + /// 创建时间 + /// + [SugarColumn(ColumnDescription = "创建时间", IsOnlyIgnoreUpdate = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Visible = false, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public virtual DateTime? CreateTime { get; set; } + + /// + /// 创建人 + /// + [SugarColumn(ColumnDescription = "创建人", IsOnlyIgnoreUpdate = true, IsNullable = true)] + [IgnoreExcel] + [NotNull] + [AutoGenerateColumn(Ignore = true)] + public virtual string? CreateUser { get; set; } + + /// + /// 创建者Id + /// + [SugarColumn(ColumnDescription = "创建者Id", IsOnlyIgnoreUpdate = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Ignore = true)] + public virtual long CreateUserId { get; set; } + + /// + /// 软删除 + /// + [SugarColumn(ColumnDescription = "软删除", IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Ignore = true)] + public virtual bool IsDelete { get; set; } = false; + + + /// + /// 更新时间 + /// + [SugarColumn(ColumnDescription = "更新时间", IsOnlyIgnoreInsert = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Visible = false, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public virtual DateTime? UpdateTime { get; set; } + + /// + /// 更新人 + /// + [SugarColumn(ColumnDescription = "更新人", IsOnlyIgnoreInsert = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Ignore = true)] + public virtual string? UpdateUser { get; set; } + + /// + /// 修改者Id + /// + [SugarColumn(ColumnDescription = "修改者Id", IsOnlyIgnoreInsert = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Ignore = true)] + public virtual long? UpdateUserId { get; set; } + + /// + /// 排序码 + /// + [SugarColumn(ColumnDescription = "排序码", IsNullable = true)] + [AutoGenerateColumn(Visible = false, DefaultSort = true, Sortable = true, DefaultSortOrder = SortOrder.Asc)] + [IgnoreExcel] + public int? SortCode { get; set; } +} + +public interface IBaseDataEntity +{ + long CreateOrgId { get; set; } +} + +/// +/// 业务数据实体基类(数据权限) +/// +public abstract class BaseDataEntity : BaseEntity, IBaseDataEntity +{ + /// + /// 创建者部门Id + /// + [SugarColumn(ColumnDescription = "创建者部门Id", IsOnlyIgnoreUpdate = true, IsNullable = true)] + [AutoGenerateColumn(Ignore = true)] + [IgnoreExcel] + public virtual long CreateOrgId { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/SysDict.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/SysDict.cs new file mode 100644 index 000000000..98b4f9a99 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/SysDict.cs @@ -0,0 +1,59 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; + +[SugarTable("sys_dict", TableDescription = "字典表")] +[Tenant(SqlSugarConst.DB_Admin)] +public class SysDict : BaseEntity +{ + /// + /// 类型 + /// + [SugarColumn(ColumnDescription = "类型", Length = 200)] + [AutoGenerateColumn(Ignore = true, Filterable = true, Sortable = true)] + public virtual DictTypeEnum DictType { get; set; } + + /// + /// 分类 + /// + [SugarColumn(ColumnDescription = "分类", Length = 200)] + [Required] + [AutoGenerateColumn(Searchable = true, Filterable = true, Sortable = true)] + public string Category { get; set; } + + /// + /// 名称 + /// + [SugarColumn(ColumnDescription = "名称", Length = 200)] + [Required] + [AutoGenerateColumn(Searchable = true, Filterable = true, Sortable = true)] + public virtual string Name { get; set; } + + /// + /// 代码 + /// + [SugarColumn(ColumnDescription = "代码", ColumnDataType = StaticConfig.CodeFirst_BigString)] + [Required] + [AutoGenerateColumn(Searchable = true, Filterable = true, Sortable = true)] + public virtual string Code { get; set; } + + /// + /// 描述 + /// + [SugarColumn(ColumnDescription = "描述", Length = 200, IsNullable = true)] + public string Remark { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/SysOperateLog.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/SysOperateLog.cs new file mode 100644 index 000000000..9e4be5eb1 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/SysOperateLog.cs @@ -0,0 +1,136 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + +/// +/// 操作日志表 +/// +[SugarTable("sys_operatelog", TableDescription = "操作日志表")] +[Tenant(SqlSugarConst.DB_Log)] +public class SysOperateLog +{ + /// + /// 日志分类 + /// + [SugarColumn(ColumnDescription = "日志分类", Length = 200)] + [AutoGenerateColumn(Order = 1, Filterable = true, Sortable = true)] + public LogCateGoryEnum Category { get; set; } + + /// + /// 日志名称 + /// + [SugarColumn(ColumnDescription = "日志名称", Length = 200)] + [AutoGenerateColumn(Order = 2, Filterable = true, Sortable = true)] + public string Name { get; set; } + + /// + /// 类名称 + /// + [SugarColumn(ColumnDescription = "类名称", Length = 200)] + [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] + public string ClassName { get; set; } + + /// + /// 方法名称 + /// + [SugarColumn(ColumnDescription = "方法名称", Length = 200)] + [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] + public string MethodName { get; set; } + + /// + /// 请求参数 + /// + [SugarColumn(ColumnDescription = "请求参数", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)] + [AutoGenerateColumn(ShowTips = true, Filterable = true, Sortable = true)] + public string? ParamJson { get; set; } + + /// + /// 请求方式 + /// + [SugarColumn(ColumnDescription = "请求方式", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] + public string? ReqMethod { get; set; } + + /// + /// 请求地址 + /// + [SugarColumn(ColumnDescription = "请求地址", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)] + [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] + public string? ReqUrl { get; set; } + + /// + /// 返回结果 + /// + [SugarColumn(ColumnDescription = "返回结果", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)] + [AutoGenerateColumn(ShowTips = true, Filterable = true, Sortable = true)] + public string? ResultJson { get; set; } + + /// + /// 具体消息 + /// + [SugarColumn(ColumnDescription = "具体消息", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)] + [AutoGenerateColumn(ShowTips = true, Filterable = true, Sortable = true)] + public string? ExeMessage { get; set; } + + /// + /// 执行状态 + /// + [SugarColumn(ColumnDescription = "执行状态")] + [AutoGenerateColumn(Filterable = true, Sortable = true)] + public bool ExeStatus { get; set; } + + /// + /// 操作账号 + /// + [SugarColumn(ColumnDescription = "操作账号", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Filterable = true, Sortable = true)] + public string? OpAccount { get; set; } + + /// + /// 操作浏览器 + /// + [SugarColumn(ColumnDescription = "操作浏览器", Length = 200)] + [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] + public string OpBrowser { get; set; } + + /// + /// 操作ip + /// + [SugarColumn(ColumnDescription = "操作ip", Length = 200)] + [AutoGenerateColumn(Filterable = true, Sortable = true)] + public string? OpIp { get; set; } + + /// + /// 操作系统 + /// + [SugarColumn(ColumnDescription = "操作系统", Length = 200)] + [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] + public string OpOs { get; set; } + + /// + /// 操作时间 + /// + [SugarColumn(ColumnDescription = "操作时间")] + [AutoGenerateColumn(Visible = true, DefaultSort = true, Sortable = true, DefaultSortOrder = SortOrder.Desc)] + public DateTime OpTime { get; set; } + + /// + /// 验证Id + /// + [SugarColumn(ColumnDescription = "验证Id")] + [IgnoreExcel] + [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] + public long VerificatId { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/SysOrg.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/SysOrg.cs new file mode 100644 index 000000000..276378d03 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/SysOrg.cs @@ -0,0 +1,81 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; + +/// +/// 组织表 +/// +[SugarTable("sys_org", TableDescription = "组织表")] +[Tenant(SqlSugarConst.DB_Admin)] +public class SysOrg : BaseEntity +{ + /// + /// 父id + /// + [SugarColumn(ColumnName = "ParentId", ColumnDescription = "父id")] + [AutoGenerateColumn(Ignore = true)] + public long ParentId { get; set; } + + [SugarColumn(ColumnName = "ParentIdList", ColumnDescription = "父id列表", IsNullable = true, IsJson = true)] + [AutoGenerateColumn(Ignore = true)] + public List ParentIdList { get; set; } = new List(); + + /// + /// 主管ID + /// + [SugarColumn(ColumnName = "DirectorId", ColumnDescription = "主管ID", IsNullable = true)] + [AutoGenerateColumn(Ignore = true)] + public long? DirectorId { get; set; } + + /// + /// 名称 + /// + [SugarColumn(ColumnName = "Name", ColumnDescription = "名称", Length = 200)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + [Required] + public string Name { get; set; } + + /// + /// 全称 + /// + [SugarColumn(ColumnName = "Names", ColumnDescription = "全称", Length = 500)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public string Names { get; set; } + + /// + /// 编码 + /// + [SugarColumn(ColumnName = "Code", ColumnDescription = "编码", Length = 200)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public string Code { get; set; } + + /// + /// 分类 + /// + [SugarColumn(ColumnName = "Category", ColumnDescription = "分类")] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public OrgEnum Category { get; set; } + + + [SugarColumn(ColumnName = "Status", ColumnDescription = "启用")] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public bool Status { get; set; } = true; + + + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/SysPosition.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/SysPosition.cs new file mode 100644 index 000000000..2ad2c57c8 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/SysPosition.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; + +/// +/// 职位表 +/// +[SugarTable("sys_position", TableDescription = "职位表")] +[Tenant(SqlSugarConst.DB_Admin)] +public class SysPosition : BaseEntity +{ + /// + /// 组织id + /// + [SugarColumn(ColumnName = "OrgId", ColumnDescription = "组织id")] + [AutoGenerateColumn(Ignore = true)] + [MinValue(1)] + public virtual long OrgId { get; set; } + + /// + /// 名称 + /// + [SugarColumn(ColumnName = "Name", ColumnDescription = "名称", Length = 200)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + [Required] + public virtual string Name { get; set; } + + /// + /// 编码 + /// + [SugarColumn(ColumnName = "Code", ColumnDescription = "编码", Length = 200)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public string Code { get; set; } + + + [SugarColumn(ColumnName = "Status", ColumnDescription = "启用")] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public bool Status { get; set; } = true; + + /// + /// 分类 + /// + [SugarColumn(ColumnName = "Category", ColumnDescription = "分类", Length = 200)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public virtual PositionCategoryEnum Category { get; set; } + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/SysRelation.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/SysRelation.cs new file mode 100644 index 000000000..2971c0897 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/SysRelation.cs @@ -0,0 +1,39 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + +/// +/// 系统关系表 +/// +[SugarTable("sys_relation", TableDescription = "系统关系表")] +[Tenant(SqlSugarConst.DB_Admin)] +public class SysRelation : PrimaryKeyEntity +{ + /// + /// 分类 + /// + [SugarColumn(ColumnDescription = "分类", Length = 200)] + public RelationCategoryEnum Category { get; set; } + + /// + /// 对象ID + /// + [SugarColumn(ColumnDescription = "对象ID")] + public long ObjectId { get; set; } + + /// + /// 目标ID + /// + [SugarColumn(ColumnDescription = "目标ID", IsNullable = true)] + public string? TargetId { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/SysResource.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/SysResource.cs new file mode 100644 index 000000000..2fb95059d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/SysResource.cs @@ -0,0 +1,101 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.AspNetCore.Components.Routing; + +using Newtonsoft.Json; + +using SqlSugar; + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; + +/// +/// 系统资源表 +/// +[SugarTable("sys_resource", TableDescription = "系统资源表")] +[Tenant(SqlSugarConst.DB_Admin)] +public class SysResource : BaseEntity +{ + /// + /// 父id + /// + [SugarColumn(ColumnDescription = "父id")] + [AutoGenerateColumn(Ignore = true)] + public virtual long ParentId { get; set; } = 0; + + /// + /// 模块 + /// + [SugarColumn(ColumnDescription = "模块")] + [AutoGenerateColumn(Ignore = true)] + public virtual long Module { get; set; } + + /// + /// 标题 + /// + [SugarColumn(ColumnDescription = "标题", Length = 200)] + [Required] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true, Searchable = true)] + public virtual string Title { get; set; } + + /// + /// 图标 + /// + [SugarColumn(ColumnDescription = "图标", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Visible = true, Sortable = false, Filterable = false)] + public virtual string? Icon { get; set; } + + /// + /// 编码 + /// + [SugarColumn(ColumnDescription = "编码", Length = 200)] + [Required] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public virtual string Code { get; set; } + + /// + /// 分类 + /// + [SugarColumn(ColumnDescription = "分类")] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public ResourceCategoryEnum Category { get; set; } = ResourceCategoryEnum.Menu; + + /// + /// 目标类型 + /// + [SugarColumn(ColumnDescription = "目标类型", IsNullable = true)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public virtual TargetEnum? Target { get; set; } + + /// + /// 菜单匹配类型 + /// + [SugarColumn(ColumnDescription = "菜单匹配类型", IsNullable = true)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public virtual NavLinkMatch? NavLinkMatch { get; set; } + + /// + /// 路径 + /// + [SugarColumn(ColumnDescription = "路径", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true, Searchable = true)] + public virtual string Href { get; set; } + + /// + /// 子节点 + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public List? Children { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/SysRole.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/SysRole.cs new file mode 100644 index 000000000..dc0082d70 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/SysRole.cs @@ -0,0 +1,94 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; + +/// +/// 系统角色表 +/// +[SugarTable("sys_role", TableDescription = "系统角色表")] +[Tenant(SqlSugarConst.DB_Admin)] +public class SysRole : BaseEntity +{ + /// + /// 名称 + /// + [SugarColumn(ColumnDescription = "名称", Length = 200)] + [Required] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public virtual string Name { get; set; } + + /// + /// 编码 + /// + [SugarColumn(ColumnDescription = "编码", Length = 200)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public string Code { get; set; } + + /// + /// 分类 + /// + [SugarColumn(ColumnDescription = "分类", Length = 200, IsNullable = false)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public virtual RoleCategoryEnum Category { get; set; } + + /// + /// 组织id + /// + [SugarColumn(ColumnName = "OrgId", ColumnDescription = "组织id", IsNullable = false)] + [AutoGenerateColumn(Ignore = true)] + public long OrgId { get; set; } + + /// + /// 默认数据范围 + /// + [SugarColumn(ColumnName = "DefaultDataScope", ColumnDescription = "默认数据范围", IsJson = true, ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = false)] + [AutoGenerateColumn(Ignore = true)] + public virtual DefaultDataScope DefaultDataScope { get; set; } = new(); + + public override bool Equals(object? obj) + { + if (obj == null || !(obj is SysRole)) + { + return false; + } + + return Id == ((SysRole)obj).Id; + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } +} + + + +/// +/// 默认数据范围 +/// +public class DefaultDataScope +{ + /// + /// 数据范围 + /// + public DataScopeEnum ScopeCategory { get; set; } + + /// + /// 自定义机构范围列表 + /// + public List ScopeDefineOrgIdList { get; set; } = new List(); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/SysUser.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/SysUser.cs new file mode 100644 index 000000000..b555a26cf --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/SysUser.cs @@ -0,0 +1,253 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Mapster; + +using SqlSugar; + +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; + +namespace ThingsGateway.Admin.Application; + +/// +/// 系统用户表 +/// +[SugarTable("sys_user", TableDescription = "系统用户表")] +[Tenant(SqlSugarConst.DB_Admin)] +public class SysUser : BaseEntity +{ + /// + /// 头像 + /// + [SugarColumn(ColumnDescription = "头像", ColumnDataType = StaticConfig.CodeFirst_BigString, IsNullable = true)] + [AutoGenerateColumn(Visible = true, Sortable = false, Filterable = false)] + [AdaptIgnore] + public virtual string? Avatar { get; set; } + + /// + /// 账号 + /// + [SugarColumn(ColumnDescription = "账号", Length = 200)] + [Required] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public virtual string Account { get; set; } + + /// + /// 密码 + /// + [SugarColumn(ColumnDescription = "密码", ColumnDataType = StaticConfig.CodeFirst_BigString)] + [AutoGenerateColumn(Ignore = true)] + public string Password { get; set; } + + /// + /// 状态 + /// + [SugarColumn(ColumnDescription = "状态")] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public bool Status { get; set; } = true; + + /// + /// 手机 + /// 这里使用了SM4自动加密解密 + /// + [SugarColumn(ColumnDescription = "手机", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public string? Phone { get; set; } + + /// + /// 邮箱 + /// + [SugarColumn(ColumnDescription = "邮箱", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public string? Email { get; set; } + + /// + /// 上次登录ip + /// + [SugarColumn(ColumnDescription = "上次登录ip", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Visible = false, Sortable = true, Filterable = true, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public string? LastLoginIp { get; set; } + + /// + /// 上次登录设备 + /// + [SugarColumn(ColumnDescription = "上次登录设备", IsNullable = true)] + [AutoGenerateColumn(Visible = false, Sortable = true, Filterable = true, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public string? LastLoginDevice { get; set; } + + /// + /// 上次登录时间 + /// + [SugarColumn(ColumnDescription = "上次登录时间", IsNullable = true)] + [AutoGenerateColumn(Visible = false, Sortable = true, Filterable = true, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public DateTime? LastLoginTime { get; set; } + + /// + /// 上次登录地点 + /// + [SugarColumn(ColumnDescription = "上次登录地点", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Visible = false, Sortable = true, Filterable = true, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public string LastLoginAddress { get; set; } + + /// + /// 最新登录ip + /// + [SugarColumn(ColumnDescription = "最新登录ip", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Visible = false, Sortable = true, Filterable = true, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public string? LatestLoginIp { get; set; } + + /// + /// 最新登录时间 + /// + [SugarColumn(ColumnDescription = "最新登录时间", IsNullable = true)] + [AutoGenerateColumn(Visible = false, Sortable = true, Filterable = true, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public DateTime? LatestLoginTime { get; set; } + + /// + /// 最新登录设备 + /// + [SugarColumn(ColumnDescription = "最新登录设备", IsNullable = true)] + [AutoGenerateColumn(Visible = false, Sortable = true, Filterable = true, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public string? LatestLoginDevice { get; set; } + + /// + /// 最新登录地点 + /// + [SugarColumn(ColumnDescription = "最新登录地点", Length = 200, IsNullable = true)] + [AutoGenerateColumn(Visible = false, Sortable = true, Filterable = true, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public string LatestLoginAddress { get; set; } + + /// + /// 机构id + /// + [SugarColumn(ColumnName = "OrgId", ColumnDescription = "机构id", IsNullable = false)] + [AutoGenerateColumn(Ignore = true)] + public virtual long OrgId { get; set; } + + /// + /// 职位id + /// + [SugarColumn(ColumnName = "PositionId", ColumnDescription = "职位id", IsNullable = true)] + [AutoGenerateColumn(Ignore = true)] + [Required] + [NotNull] + public virtual long? PositionId { get; set; } + + /// + /// 主管id + /// + [SugarColumn(ColumnName = "DirectorId", ColumnDescription = "主管id", IsNullable = true)] + [AutoGenerateColumn(Ignore = true)] + public long? DirectorId { get; set; } + + #region other + /// + /// 机构信息 + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public string OrgName { get; set; } + + /// + /// 机构信息全称 + /// + [SugarColumn(IsIgnore = true)] + public string OrgNames { get; set; } + + /// + /// 职位信息 + /// + [SugarColumn(IsIgnore = true)] + public string PositionName { get; set; } + + /// + /// 组织和机构ID列表,组织ID从上到下最后是职位 + /// + [SugarColumn(IsIgnore = true, IsJson = true)] + [AutoGenerateColumn(Ignore = true)] + public List OrgAndPosIdList { get; set; } = new List(); + + /// + /// 主管信息 + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public UserSelectorOutput DirectorInfo { get; set; } + + #endregion + + #region other + + /// + /// 按钮码集合 + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public Dictionary> ButtonCodeList { get; set; } = new(); + + /// + /// 权限码集合 + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public HashSet PermissionCodeList { get; set; } = new(); + + /// + /// 角色ID集合 + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public HashSet RoleIdList { get; set; } = new(); + + /// + /// 机构及以下机构ID集合 + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public HashSet ScopeOrgChildList { get; set; } + + /// + /// 模块集合 + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public List ModuleList { get; set; } = new(); + + /// + /// 租户Id + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public long? TenantId { get; set; } + + + /// + /// 全局用戶 + /// + [SugarColumn(IsIgnore = true)] + [AutoGenerateColumn(Ignore = true)] + public bool IsGlobal { get; set; } + + #endregion other +} + +/// +/// 数据范围类 +/// +public class DataScope +{ + /// + /// API接口 + /// + public string ApiUrl { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Entity/VerificatInfo.cs b/src/Admin/ThingsGateway.Admin.Application/Entity/VerificatInfo.cs new file mode 100644 index 000000000..b886478bb --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Entity/VerificatInfo.cs @@ -0,0 +1,97 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using ThingsGateway.List; + +namespace ThingsGateway.Admin.Application; + +/// +/// 会话信息 +/// + +[SugarTable("verificatinfo", TableDescription = "验证缓存表")] +[Tenant(SqlSugarConst.DB_TokenCache)] +public class VerificatInfo : PrimaryIdEntity +{ + /// + /// 客户端ID列表 + /// + [AutoGenerateColumn(Ignore = true)] + [SugarColumn(ColumnDescription = "客户端ID列表", IsNullable = true, IsJson = true)] + public ConcurrentList ClientIds { get; set; } = new(); + + /// + /// 验证Id + /// + [AutoGenerateColumn(Ignore = true)] + public long UserId { get; set; } + + /// + /// 验证Id + /// + [AutoGenerateColumn(Filterable = true, Sortable = true)] + [SugarColumn(ColumnDescription = "Id", IsPrimaryKey = true)] + [IgnoreExcel] + public override long Id { get; set; } + + /// + /// 登录IP + /// + [AutoGenerateColumn(Filterable = true, Sortable = true, Width = 200)] + public string LoginIp { get; set; } + + /// + /// 登录时间 + /// + [AutoGenerateColumn(Filterable = true, Sortable = true, Width = 200)] + public DateTime LoginTime { get; set; } + + /// + /// 在线状态 + /// + [Newtonsoft.Json.JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] + [AutoGenerateColumn(Filterable = true, Sortable = true, Width = 120)] + [SugarColumn(IsIgnore = true)] + public bool Online => ClientIds.Count > 0; + + /// + /// 过期时间 + /// + [AutoGenerateColumn(Filterable = true, Sortable = true)] + public int Expire { get; set; } + + /// + /// verificat剩余有效期 + /// + [AutoGenerateColumn(Filterable = true, Sortable = true)] + [SugarColumn(IsIgnore = true)] + [Newtonsoft.Json.JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] + public string VerificatRemain { get; set; } + + /// + /// 超时时间 + /// + [AutoGenerateColumn(Filterable = true, Sortable = true)] + public DateTime VerificatTimeout { get; set; } + + /// + /// 登录设备 + /// + [AutoGenerateColumn(Filterable = true, Sortable = true, Width = 100)] + public string Device { get; set; } + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Enum/DataScopeEnum.cs b/src/Admin/ThingsGateway.Admin.Application/Enum/DataScopeEnum.cs new file mode 100644 index 000000000..d25d2af40 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Enum/DataScopeEnum.cs @@ -0,0 +1,41 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public enum DataScopeEnum +{ + + /// + /// 仅自己 + /// + SCOPE_SELF, + + /// + /// 所有 + /// + SCOPE_ALL, + + /// + /// 仅所属组织 + /// + SCOPE_ORG, + + /// + /// 所属组织及以下 + /// + SCOPE_ORG_CHILD, + + /// + /// 自定义 + /// + SCOPE_ORG_DEFINE, + +} diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/IAdapterObject.cs b/src/Admin/ThingsGateway.Admin.Application/Enum/DictTypeEnum.cs similarity index 73% rename from src/Foundation/ThingsGateway.Foundation/Channel/IAdapterObject.cs rename to src/Admin/ThingsGateway.Admin.Application/Enum/DictTypeEnum.cs index 30f6466cb..66edf7525 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/IAdapterObject.cs +++ b/src/Admin/ThingsGateway.Admin.Application/Enum/DictTypeEnum.cs @@ -8,16 +8,20 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -namespace ThingsGateway.Foundation; +namespace ThingsGateway.Admin.Application; /// -/// 能对适配器做配置的客户端 +/// 字典表类型 /// -public interface IAdapterObject +public enum DictTypeEnum { /// - /// 设置数据处理适配器 + /// 系统使用 /// - /// 适配器 - void SetDataHandlingAdapter(DataHandlingAdapter adapter); + System, + + /// + /// 用户自定义 + /// + Define, } diff --git a/src/Admin/ThingsGateway.Admin.Application/Enum/LogCateGoryEnum.cs b/src/Admin/ThingsGateway.Admin.Application/Enum/LogCateGoryEnum.cs new file mode 100644 index 000000000..b1c38244c --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Enum/LogCateGoryEnum.cs @@ -0,0 +1,19 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public enum LogCateGoryEnum +{ + Login, + Logout, + Operate, + Exception +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Enum/OrgEnum.cs b/src/Admin/ThingsGateway.Admin.Application/Enum/OrgEnum.cs new file mode 100644 index 000000000..31dd6794b --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Enum/OrgEnum.cs @@ -0,0 +1,17 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public enum OrgEnum +{ + DEPT, + COMPANY, +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Enum/PositionCategoryEnum.cs b/src/Admin/ThingsGateway.Admin.Application/Enum/PositionCategoryEnum.cs new file mode 100644 index 000000000..7e629aeef --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Enum/PositionCategoryEnum.cs @@ -0,0 +1,18 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public enum PositionCategoryEnum +{ + HIGH, + MIDDLE, + LOW, +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Enum/RelationCategoryEnum.cs b/src/Admin/ThingsGateway.Admin.Application/Enum/RelationCategoryEnum.cs new file mode 100644 index 000000000..e200a75e3 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Enum/RelationCategoryEnum.cs @@ -0,0 +1,25 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public enum RelationCategoryEnum +{ + UserHasRole, + UserHasResource, + UserHasPermission, + UserHasOpenApiPermission, + UserHasModule, + UserWorkbenchData, + RoleHasResource, + RoleHasPermission, + RoleHasOpenApiPermission, + RoleHasModule, +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Enum/ResourceCategoryEnum.cs b/src/Admin/ThingsGateway.Admin.Application/Enum/ResourceCategoryEnum.cs new file mode 100644 index 000000000..1f6e8ee24 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Enum/ResourceCategoryEnum.cs @@ -0,0 +1,18 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public enum ResourceCategoryEnum +{ + Module, + Menu, + Button, +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Enum/RoleCategoryEnum.cs b/src/Admin/ThingsGateway.Admin.Application/Enum/RoleCategoryEnum.cs new file mode 100644 index 000000000..436c63d4c --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Enum/RoleCategoryEnum.cs @@ -0,0 +1,17 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public enum RoleCategoryEnum +{ + Global = 0, + Org = 2, +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Enum/TargetEnum.cs b/src/Admin/ThingsGateway.Admin.Application/Enum/TargetEnum.cs new file mode 100644 index 000000000..e7cc4eb30 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Enum/TargetEnum.cs @@ -0,0 +1,19 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public enum TargetEnum +{ + _self, + _blank, + _parent, + _top +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Extensions/ApplicationBuilderExtensions.cs b/src/Admin/ThingsGateway.Admin.Application/Extensions/ApplicationBuilderExtensions.cs new file mode 100644 index 000000000..f03975eb4 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Extensions/ApplicationBuilderExtensions.cs @@ -0,0 +1,55 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; + +using System.Net; +using System.Text.Json; + +namespace Microsoft.AspNetCore.Builder; + +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class ApplicationBuilderExtensions +{ + public static IApplicationBuilder UseBootstrapBlazor(this IApplicationBuilder builder) + { + // 获得客户端 IP 地址 + builder.UseWhen(context => context.Request.Path.StartsWithSegments("/ip.axd"), app => app.Run(async context => + { + var ip = ""; + var headers = context.Request.Headers; + if (headers.TryGetValue("X-Forwarded-For", out var value)) + { + var ips = new List(); + foreach (var xf in value) + { + if (!string.IsNullOrEmpty(xf)) + { + ips.Add(xf); + } + } + ip = string.Join(";", ips); + } + else + { + ip = context.Connection.RemoteIpAddress.ToIPv4String(); + } + + context.Response.Headers.TryAdd("Content-Type", new Microsoft.Extensions.Primitives.StringValues("application/json; charset=utf-8")); + await context.Response.WriteAsync(JsonSerializer.Serialize(new { Id = context.TraceIdentifier, Ip = ip })).ConfigureAwait(false); + })); + return builder; + } + public static string ToIPv4String(this IPAddress? address) + { + var ipv4Address = (address ?? IPAddress.IPv6Loopback).ToString(); + return ipv4Address.StartsWith("::ffff:") ? (address ?? IPAddress.IPv6Loopback).MapToIPv4().ToString() : ipv4Address; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Extensions/CacheExtensions.cs b/src/Admin/ThingsGateway.Admin.Application/Extensions/CacheExtensions.cs new file mode 100644 index 000000000..454a90081 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Extensions/CacheExtensions.cs @@ -0,0 +1,88 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Configuration; +using Microsoft.Net.Http.Headers; + +namespace ThingsGateway.Admin.Application; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class CacheExtensions +{ + private static int? _age; + + private static List? _files; + + public static void ProcessCache(this StaticFileResponseContext context, IConfiguration configuration) + { + if (context.CanCache(configuration, out var age)) + { + context.Context.Response.Headers[HeaderNames.CacheControl] = $"public, max-age={age}"; + } + } + + private static bool CanCache(this StaticFileResponseContext context, IConfiguration configuration, out int age) + { + var ret = false; + age = 0; + + var files = configuration.GetFiles(); + if (files.Any(i => context.CanCache(i))) + { + ret = true; + age = configuration.GetAge(); + } + return ret; + } + + private static bool CanCache(this StaticFileResponseContext context, string file) + { + var ext = Path.GetExtension(context.File.PhysicalPath) ?? ""; + bool ret = file.Equals(ext, StringComparison.OrdinalIgnoreCase); + if (ret && ext.Equals(".js", StringComparison.OrdinalIgnoreCase)) + { + // process javascript file + ret = false; + if (context.Context.Request.QueryString.HasValue) + { + var paras = QueryHelpers.ParseQuery(context.Context.Request.QueryString.Value); + ret = paras.ContainsKey("v"); + } + } + return ret; + } + + private static int GetAge(this IConfiguration configuration) + { + _age ??= GetAge(); + return _age.Value; + + int GetAge() + { + var cacheSection = configuration.GetSection("Cache-Control"); + return cacheSection.GetValue("Max-Age", 1000 * 60 * 10); + } + } + + private static List GetFiles(this IConfiguration configuration) + { + _files ??= GetFiles(); + return _files; + + List GetFiles() + { + var cacheSection = configuration.GetSection("Cache-Control"); + return cacheSection.GetSection("Files").Get>() ?? new(); + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Extensions/ExportExcelExtensions.cs b/src/Admin/ThingsGateway.Admin.Application/Extensions/ExportExcelExtensions.cs new file mode 100644 index 000000000..9e6bcb9d9 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Extensions/ExportExcelExtensions.cs @@ -0,0 +1,120 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + + +using Mapster; + +using MiniExcelLibs; +using MiniExcelLibs.Attributes; +using MiniExcelLibs.OpenXml; + +using System.Data; +using System.Reflection; + +namespace ThingsGateway.Admin.Application; + +/// +/// 导出excel扩展 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class ExportExcelExtensions +{ + /// + /// 导出excel + /// + /// + /// + /// + /// + public static Dictionary ExportExcel(this IEnumerable? data, string sheetName) + { + //总数据 + Dictionary sheets = new(); + //通道页 + List> valveLogExports = new(); + + #region 列名称 + + var type = typeof(T); + var propertyInfos = type.GetRuntimeProperties().Where(a => a.GetCustomAttribute() == null) + .OrderBy( + a => + { + var order = a.GetCustomAttribute()?.Order ?? int.MaxValue; ; + if (order < 0) + { + order = order + 10000000; + } + else if (order == 0) + { + order = 10000000; + } + return order; + } + ) + ; + + #endregion 列名称 + + foreach (var device in data) + { + Dictionary valveLogExport = new(); + foreach (var item in propertyInfos) + { + //描述 + var desc = type.GetPropertyDisplayName(item.Name); + //数据源增加 + valveLogExport.Add(desc ?? item.Name, item.GetValue(device)?.ToString()); + } + + //添加完整设备信息 + valveLogExports.Add(valveLogExport); + } + //添加设备页 + sheets.Add(sheetName, valveLogExports); + return sheets; + } + + + /// + /// 导出excel + /// + /// + /// + /// + /// + /// + public static async Task ExportExcel(this Stream stream, object input, bool isDynamicExcelColumn) where T : class + { + var config = new OpenXmlConfiguration(); + if (isDynamicExcelColumn) + { + var type = typeof(T); + var data = type.GetRuntimeProperties(); + List dynamicExcelColumns = new(); + int index = 0; + foreach (var item in data) + { + var ignore = item.GetCustomAttribute() != null; + //描述 + var desc = type.GetPropertyDisplayName(item.Name); + //数据源增加 + dynamicExcelColumns.Add(new DynamicExcelColumn(item.Name) { Ignore = ignore, Index = index, Name = desc ?? item.Name, Width = 30 }); + if (!ignore) + index++; + } + config.DynamicColumns = dynamicExcelColumns.ToArray(); + } + config.TableStyles = TableStyles.None; + await MiniExcel.SaveAsAsync(stream, input, configuration: config).ConfigureAwait(false); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Extensions/FileExtensions.cs b/src/Admin/ThingsGateway.Admin.Application/Extensions/FileExtensions.cs new file mode 100644 index 000000000..1967ea67f --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Extensions/FileExtensions.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; + +namespace ThingsGateway.Admin.Application; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class FileExtensions +{ + /// + /// 存储本地文件 + /// + /// 存储的第一层目录 + /// + /// 文件全路径 + public static async Task StorageLocal(this IBrowserFile file, string pPath = "imports") + { + string uploadFileFolder = App.WebHostEnvironment?.WebRootPath ?? "wwwroot"!;//赋值路径 + var now = CommonUtils.GetSingleId(); + var filePath = Path.Combine(uploadFileFolder, pPath); + if (!Directory.Exists(filePath))//如果不存在就创建文件夹 + Directory.CreateDirectory(filePath); + //var fileSuffix = Path.GetExtension(file.Name).ToLower();// 文件后缀 + var fileObjectName = $"{now}{file.Name}";//存储后的文件名 + var fileName = Path.Combine(filePath, fileObjectName);//获取文件全路径 + fileName = fileName.Replace("\\", "/");//格式化一系 + //存储文件 + using (var stream = File.Create(Path.Combine(filePath, fileObjectName))) + { + using var fs = file.OpenReadStream(1024 * 1024 * 500); + await fs.CopyToAsync(stream).ConfigureAwait(false); + } + return fileName; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Extensions/QueryPageOptionsExtensions.cs b/src/Admin/ThingsGateway.Admin.Application/Extensions/QueryPageOptionsExtensions.cs new file mode 100644 index 000000000..4ede1ebb3 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Extensions/QueryPageOptionsExtensions.cs @@ -0,0 +1,97 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class QueryPageOptionsExtensions +{ + + public static IEnumerable GetData(this IEnumerable datas, QueryPageOptions option, FilterKeyValueAction where = null) + { + where ??= option.ToFilter(); + if (where.HasFilters()) + { + datas = datas.Where(where.GetFilterFunc());//name asc模式 + } + + if (option.SortList.Count > 0) + { + datas = datas.Sort(option.SortList);//name asc模式 + } + if (option.AdvancedSortList.Count > 0) + { + datas = datas.Sort(option.AdvancedSortList);//name asc模式 + } + if (option.SortOrder != SortOrder.Unset && !option.SortName.IsNullOrWhiteSpace()) + { + datas = datas.Sort(option.SortName, option.SortOrder); + } + if (option.IsPage) + { + datas = datas.Skip((option.PageIndex - 1) * option.PageItems).Take(option.PageItems); + } + else if (option.IsVirtualScroll) + { + datas = datas.Skip((option.StartIndex) * option.PageItems).Take(option.PageItems); + } + return datas; + } + + /// + /// 根据查询条件返回sqlsugar ISugarQueryable + /// + /// + /// + public static ISugarQueryable GetQuery(this SqlSugarClient db, QueryPageOptions option, ISugarQueryable? query = null, FilterKeyValueAction where = null) + { + query ??= db.Queryable(); + where ??= option.ToFilter(); + + if (where.HasFilters()) + { + query = query.Where(where.GetFilterLambda());//name asc模式 + } + + foreach (var item in option.SortList) + { + query = query.OrderByIF(!string.IsNullOrEmpty(item), $"{item}");//name asc模式 + } + foreach (var item in option.AdvancedSortList) + { + query = query.OrderByIF(!string.IsNullOrEmpty(item), $"{item}");//name asc模式 + } + query = query.OrderByIF(option.SortOrder != SortOrder.Unset, $"{option.SortName} {option.SortOrder}"); + return query; + } + + /// + /// 根据查询条件返回QueryData + /// + public static QueryData GetQueryData(this IEnumerable datas, QueryPageOptions option, FilterKeyValueAction where = null) + { + var ret = new QueryData() + { + IsSorted = option.SortOrder != SortOrder.Unset, + IsFiltered = option.Filters.Count > 0, + IsAdvanceSearch = option.AdvanceSearches.Count > 0 || option.CustomerSearches.Count > 0, + IsSearch = option.Searches.Count > 0 + }; + var items = datas.GetData(option, where); + ret.TotalCount = datas.Count(); + ret.Items = items.ToList(); + return ret; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Extensions/SqlSugarExtensions.cs b/src/Admin/ThingsGateway.Admin.Application/Extensions/SqlSugarExtensions.cs new file mode 100644 index 000000000..a2b766cef --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Extensions/SqlSugarExtensions.cs @@ -0,0 +1,244 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +using System.Linq.Expressions; +using System.Reflection; + +namespace ThingsGateway.Admin.Application; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class SqlSugarExtensions +{ + /// + public static ISugarQueryable ExportIgnoreColumns(this ISugarQueryable queryable) + { + return queryable.IgnoreColumns( + [ + nameof(BaseEntity.Id), + nameof(BaseEntity.CreateTime), + nameof(BaseEntity.CreateUser), + nameof(BaseEntity.CreateUserId), + nameof(BaseDataEntity.CreateOrgId), + nameof(BaseEntity.ExtJson), + nameof(BaseEntity.IsDelete), + nameof(BaseEntity.UpdateTime), + nameof(BaseEntity.UpdateUser), + nameof(BaseEntity.UpdateUserId), + ] + ); + } + + /// + public static SqlSugarPagedList ToPagedList(this ISugarQueryable queryable, int current, + int size) + { + var total = 0; + var records = queryable.ToPageList(current, size, ref total); + var pages = (int)Math.Ceiling(total / (double)size); + return new SqlSugarPagedList + { + Current = current, + Size = size, + Records = records, + Total = total, + Pages = pages, + HasNextPages = current < pages, + HasPrevPages = current - 1 > 0 + }; + } + + /// + /// SqlSugar分页扩展,查询出结果后再转换实体类 + /// + public static SqlSugarPagedList ToPagedList(this ISugarQueryable queryable, + int current, int size) + { + var totalCount = 0; + var records = queryable.ToPageList(current, size, ref totalCount); + var totalPages = (int)Math.Ceiling(totalCount / (double)size); + return new SqlSugarPagedList + { + Current = current, + Size = size, + Records = records.Cast(), + Total = totalCount, + Pages = totalPages, + HasNextPages = current < totalPages, + HasPrevPages = current - 1 > 0 + }; + } + + /// + /// SqlSugar分页扩展,查询前扩展转换实体类 + /// + public static SqlSugarPagedList ToPagedList(this ISugarQueryable queryable, int pageIndex, + int pageSize, Expression> expression) + { + var totalCount = 0; + var items = queryable.ToPageList(pageIndex, pageSize, ref totalCount, expression); + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + return new SqlSugarPagedList + { + Current = pageIndex, + Size = pageSize, + Records = items, + Total = totalCount, + Pages = totalPages, + HasNextPages = pageIndex < totalPages, + HasPrevPages = pageIndex - 1 > 0 + }; + } + + /// + /// 分页查询 + /// + /// + /// 数据列表 + /// 参数 + /// 不分页 + /// 分页集合 + public static SqlSugarPagedList ToPagedList(this IEnumerable list, BasePageInput basePageInput = null, bool isAll = false) + { + if (isAll) + { + list = Sort(list, basePageInput); + var data = new SqlSugarPagedList + { + Current = 1, + Size = list?.Count() ?? 0, + Records = list, + Total = list?.Count() ?? 0, + Pages = 1, + HasNextPages = false, + HasPrevPages = false + }; + return data; + } + + int _PageIndex = basePageInput.Current; + int _PageSize = basePageInput.Size; + var num = list.Count(); + var pageConut = (double)num / _PageSize; + int PageConut = (int)Math.Ceiling(pageConut); + list = Sort(list, basePageInput); + if (PageConut >= _PageIndex) + { + list = list.Skip((_PageIndex - 1) * _PageSize).Take(_PageSize); + } + return new SqlSugarPagedList + { + Current = _PageIndex, + Size = _PageSize, + Records = list, + Total = num, + Pages = PageConut, + HasNextPages = _PageIndex < PageConut, + HasPrevPages = _PageIndex - 1 > 0 + }; + } + + /// + /// SqlSugar分页扩展,查询前扩展转换实体类 + /// + public static async Task> ToPagedListAsync(this ISugarQueryable queryable, + int current, int size) + { + RefAsync totalCount = 0; + var records = await queryable.ToPageListAsync(current, size, totalCount).ConfigureAwait(false); + var totalPages = (int)Math.Ceiling(totalCount / (double)size); + return new SqlSugarPagedList + { + Current = current, + Size = size, + Records = records, + Total = (int)totalCount, + Pages = totalPages, + HasNextPages = current < totalPages, + HasPrevPages = current - 1 > 0 + }; + } + + /// + /// SqlSugar分页扩展,查询出结果后再转换实体类 + /// + public static async Task> ToPagedListAsync(this ISugarQueryable queryable, + int current, int size) + { + RefAsync totalCount = 0; + var records = await queryable.ToPageListAsync(current, size, totalCount).ConfigureAwait(false); + var totalPages = (int)Math.Ceiling(totalCount / (double)size); + return new SqlSugarPagedList + { + Current = current, + Size = size, + Records = records.Cast(), + Total = (int)totalCount, + Pages = totalPages, + HasNextPages = current < totalPages, + HasPrevPages = current - 1 > 0 + }; + } + + /// + /// SqlSugar分页扩展,查询前扩展转换实体类 + /// + public static async Task> ToPagedListAsync( + this ISugarQueryable queryable, int pageIndex, int pageSize, Expression> expression) + { + RefAsync totalCount = 0; + var items = await queryable.ToPageListAsync(pageIndex, pageSize, totalCount, expression).ConfigureAwait(false); + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + return new SqlSugarPagedList + { + Current = pageIndex, + Size = pageSize, + Records = items, + Total = (int)totalCount, + Pages = totalPages, + HasNextPages = pageIndex < totalPages, + HasPrevPages = pageIndex - 1 > 0 + }; + } + + /// + public static async Task UpdateRangeAsync(this SqlSugarClient db, List updateObjs) where T : class, new() + { + return await db.Updateable(updateObjs).ExecuteCommandAsync().ConfigureAwait(false) > 0; + } + + /// + public static async Task UpdateSetColumnsTrueAsync(this SqlSugarClient db, Expression> columns, Expression> whereExpression) where T : class, new() + { + return await db.Updateable().SetColumns(columns, appendColumnsByDataFilter: true).Where(whereExpression) + .ExecuteCommandAsync().ConfigureAwait(false) > 0; + } + + private static IEnumerable Sort(this IEnumerable list, BasePageInput basePageInput) + { + if (basePageInput != null && basePageInput.SortField != null) + { + for (int i = 0; i < basePageInput.SortField.Count; i++) + { + var pro = typeof(T).GetRuntimeProperty(basePageInput.SortField[i]); + if (pro != null) + { + if (!basePageInput.SortDesc[i]) + list = list.OrderBy(a => pro.GetValue(a)); + else + list = list.OrderByDescending(a => pro.GetValue(a)); + } + } + } + return list; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/FodyWeavers.xml b/src/Admin/ThingsGateway.Admin.Application/FodyWeavers.xml new file mode 100644 index 000000000..a6a2edf1b --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Application/GlobalUsings.cs b/src/Admin/ThingsGateway.Admin.Application/GlobalUsings.cs new file mode 100644 index 000000000..12f87ac5d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/GlobalUsings.cs @@ -0,0 +1,12 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +global using ThingsGateway; +global using ThingsGateway.NewLife.Extension; \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/HardwareInfo.cs b/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/HardwareInfo.cs new file mode 100644 index 000000000..a37759916 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/HardwareInfo.cs @@ -0,0 +1,60 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using ThingsGateway.NewLife; + +namespace ThingsGateway.Admin.Application; + +/// +public class HardwareInfo +{ + /// + /// 当前磁盘信息 + /// + public DriveInfo DriveInfo { get; set; } + + /// + /// 硬件信息获取 + /// + public MachineInfo? MachineInfo { get; set; } + + /// + /// 主机环境 + /// + public string Environment { get; set; } + + /// + /// NET框架 + /// + public string FrameworkDescription { get; set; } + + /// + /// 系统架构 + /// + public string OsArchitecture { get; set; } + + /// + /// 唯一编码 + /// + public string UUID { get; set; } + + /// + /// 进程占用内存 + /// + [AutoGenerateColumn(Ignore = true)] + public string WorkingSet { get; set; } + + /// + /// 更新时间 + /// + public string UpdateTime { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/HardwareJob.cs b/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/HardwareJob.cs new file mode 100644 index 000000000..da705e674 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/HardwareJob.cs @@ -0,0 +1,147 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using System.Runtime.InteropServices; + +using ThingsGateway.DataEncryption; +using ThingsGateway.Extension; +using ThingsGateway.NewLife; +using ThingsGateway.NewLife.Threading; +using ThingsGateway.Schedule; + +namespace ThingsGateway.Admin.Application; + +/// +/// 获取硬件信息作业任务 +/// +[JobDetail("hardware_log", Description = "获取硬件信息", GroupName = "Hardware", Concurrent = false)] +[PeriodSeconds(30, TriggerId = "trigger_hardware", Description = "获取硬件信息", RunOnStart = true)] +public class HardwareJob : IJob, IHardwareJob +{ + private readonly ILogger _logger; + private readonly IStringLocalizer _localizer; + + /// + public HardwareJob(ILogger logger, IStringLocalizer localizer, IOptions options) + { + _logger = logger; + _localizer = localizer; + HardwareInfoOptions = options.Value; + } + #region 属性 + + /// + /// 运行信息获取 + /// + public HardwareInfo HardwareInfo { get; } = new(); + + /// + public HardwareInfoOptions HardwareInfoOptions { get; private set; } + + #endregion 属性 + + /// + public async Task> GetHistoryHardwareInfos() + { + using var db = DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); + return await db.Queryable().ToListAsync().ConfigureAwait(false); + } + + private bool error = false; + private DateTime hisInsertTime = default; + + public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) + { + if (HardwareInfoOptions.Enable) + { + try + { + if (HardwareInfo.MachineInfo == null) + { + await MachineInfo.RegisterAsync().ConfigureAwait(false); + HardwareInfo.MachineInfo = MachineInfo.Current; + + string currentPath = Directory.GetCurrentDirectory(); + DriveInfo drive = new(Path.GetPathRoot(currentPath)); + + HardwareInfoOptions.DaysAgo = Math.Min(Math.Max(HardwareInfoOptions.DaysAgo, 1), 7); + if (HardwareInfoOptions.HistoryInterval < 60000) HardwareInfoOptions.HistoryInterval = 60000; + HardwareInfo.DriveInfo = drive; + HardwareInfo.OsArchitecture = Environment.OSVersion.Platform.ToString() + " " + RuntimeInformation.OSArchitecture.ToString(); // 系统架构 + HardwareInfo.FrameworkDescription = RuntimeInformation.FrameworkDescription; // NET框架 + HardwareInfo.Environment = App.HostEnvironment.IsDevelopment() ? "Development" : "Production"; + HardwareInfo.UUID = DESEncryption.Encrypt(HardwareInfo.MachineInfo.UUID + HardwareInfo.MachineInfo.Guid + HardwareInfo.MachineInfo.DiskID); + + HardwareInfo.UpdateTime = TimerX.Now.ToDefaultDateTimeFormat(); + + } + } + catch + { + } + try + { + HardwareInfo.MachineInfo.Refresh(); + HardwareInfo.UpdateTime = TimerX.Now.ToDefaultDateTimeFormat(); + HardwareInfo.WorkingSet = (Environment.WorkingSet / 1024.0 / 1024.0).ToString("F2"); + error = false; + } + catch (Exception ex) + { + if (!error) + _logger.LogWarning(ex, _localizer["GetHardwareInfoFail"]); + error = true; + } + + try + { + if (HardwareInfoOptions.Enable) + { + if (DateTime.Now > hisInsertTime.Add(TimeSpan.FromMilliseconds(HardwareInfoOptions.HistoryInterval))) + { + hisInsertTime = DateTime.Now; + using var db = DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); + { + var his = new HistoryHardwareInfo() + { + Date = TimerX.Now, + DriveUsage = (100 - (HardwareInfo.DriveInfo.TotalFreeSpace * 100.00 / HardwareInfo.DriveInfo.TotalSize)).ToString("F2"), + Battery = (HardwareInfo.MachineInfo.Battery * 100).ToString("F2"), + MemoryUsage = (100 - (HardwareInfo.MachineInfo.AvailableMemory * 100.00 / HardwareInfo.MachineInfo.Memory)).ToString("F2"), + CpuUsage = (HardwareInfo.MachineInfo.CpuRate * 100).ToString("F2"), + Temperature = (HardwareInfo.MachineInfo.Temperature).ToString("F2"), + }; + await db.Insertable(his).ExecuteCommandAsync(stoppingToken).ConfigureAwait(false); + } + var sevenDaysAgo = TimerX.Now.AddDays(-HardwareInfoOptions.DaysAgo); + //删除特定信息 + await db.Deleteable(a => a.Date <= sevenDaysAgo).ExecuteCommandAsync(stoppingToken).ConfigureAwait(false); + } + } + error = false; + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + if (!error) + _logger.LogWarning(ex, _localizer["GetHardwareInfoFail"]); + error = true; + } + } + } + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/HistoryHardwareInfo.cs b/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/HistoryHardwareInfo.cs new file mode 100644 index 000000000..b11d11e20 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/HistoryHardwareInfo.cs @@ -0,0 +1,43 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + +/// +[SugarTable("his_hardwareinfo", TableDescription = "硬件信息历史表")] +[Tenant(SqlSugarConst.DB_HardwareInfo)] +public class HistoryHardwareInfo +{ + /// + [SugarColumn(ColumnDescription = "磁盘使用率")] + public string DriveUsage { get; set; } + + /// + [SugarColumn(ColumnDescription = "内存使用率")] + public string MemoryUsage { get; set; } + + /// + [SugarColumn(ColumnDescription = "CPU使用率")] + public string CpuUsage { get; set; } + + /// + [SugarColumn(ColumnDescription = "温度")] + public string Temperature { get; set; } + + /// + [SugarColumn(ColumnDescription = "电池")] + public string Battery { get; set; } + + /// + [SugarColumn(ColumnDescription = "时间")] + public DateTime Date { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/IHardwareJob.cs b/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/IHardwareJob.cs new file mode 100644 index 000000000..6d20cefdb --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Job/Hardware/IHardwareJob.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public interface IHardwareJob +{ + HardwareInfo HardwareInfo { get; } + HardwareInfoOptions HardwareInfoOptions { get; } + Task> GetHistoryHardwareInfos(); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Job/JobPersistence.cs b/src/Admin/ThingsGateway.Admin.Application/Job/JobPersistence.cs new file mode 100644 index 000000000..b048087b2 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Job/JobPersistence.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using ThingsGateway.Schedule; + +namespace ThingsGateway.Admin.Application; + +/// +/// 作业持久化(数据库) +/// +public class JobPersistence : IJobPersistence +{ + + /// + /// 作业调度服务启动时 + /// + /// + /// + /// + public async Task> PreloadAsync(CancellationToken stoppingToken) + { + await Task.CompletedTask.ConfigureAwait(false); + // 获取所有定义的作业 + var allJobs = App.EffectiveTypes.ScanToBuilders().ToList(); + return allJobs; + } + + /// + /// 作业计划初始化通知 + /// + /// + /// + /// + public Task OnLoadingAsync(SchedulerBuilder builder, CancellationToken stoppingToken) + { + return Task.FromResult(builder); + } + + /// + /// 作业计划Scheduler的JobDetail变化时 + /// + /// + /// + public async Task OnChangedAsync(PersistenceContext context) + { + await Task.CompletedTask.ConfigureAwait(false); + } + + /// + /// 作业计划Scheduler的触发器Trigger变化时 + /// + /// + /// + public async Task OnTriggerChangedAsync(PersistenceTriggerContext context) + { + await Task.CompletedTask.ConfigureAwait(false); + + } + + /// + /// 作业触发器运行记录 + /// + /// + /// + public async Task OnExecutionRecordAsync(PersistenceExecutionRecordContext context) + { + await Task.CompletedTask.ConfigureAwait(false); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Job/LogJob/LogJob.cs b/src/Admin/ThingsGateway.Admin.Application/Job/LogJob/LogJob.cs new file mode 100644 index 000000000..8f89f473c --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Job/LogJob/LogJob.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using ThingsGateway.Schedule; + +namespace ThingsGateway.Admin.Application; + +/// +/// 清理日志作业任务 +/// +[JobDetail("job_log", Description = "清理操作日志", GroupName = "Log", Concurrent = false)] +[Daily(TriggerId = "trigger_log", Description = "清理操作日志", RunOnStart = true)] +public class LogJob : IJob +{ + public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) + { + var daysAgo = App.GetOptions()?.OperateLogDaysAgo ?? 7; + await LogJob.DeleteSysOperateLog(daysAgo, stoppingToken).ConfigureAwait(false); + } + + private static async Task DeleteSysOperateLog(int daysAgo, CancellationToken stoppingToken) + { + using var db = DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); + await db.DeleteableWithAttr().Where(u => u.OpTime < DateTime.Now.AddDays(-daysAgo)).ExecuteCommandAsync(stoppingToken).ConfigureAwait(false); // 删除操作日志 + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Locales/en-US.json b/src/Admin/ThingsGateway.Admin.Application/Locales/en-US.json new file mode 100644 index 000000000..1a700b77a --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Locales/en-US.json @@ -0,0 +1,464 @@ +{ + "ThingsGateway.Admin.Application.SysUser": { + "Disable": "Disable", + "Enable": "Enable", + "GrantRole": "GrantRole", + "ExitVerificat": "You have been forcibly logged out", + "PasswordEdited": "Password changed, logged out", + + "Avatar": "Avatar", + "Account": "Account", + "Account.Required": "Account.Required", + "Password": "Password", + "Status": "Status", + "Phone": "Phone", + "Email": "Email", + "LastLoginIp": "LastLoginIp", + "LastLoginDevice": "LastLoginDevice", + "LastLoginTime": "LastLoginTime", + "LastLoginAddress": "LastLoginAddress", + "LatestLoginIp": "LatestLoginIp", + "LatestLoginTime": "LatestLoginTime", + "LatestLoginDevice": "LatestLoginDevice", + "LatestLoginAddress": "LatestLoginAddress", + "SortCode": "SortCode", + "CreateTime": "CreateTime", + "UpdateTime": "UpdateTime", + "OrgNames": "Org", + "PositionName": "Position", + "OrgId": "Org", + "PositionId": "Position", + "DirectorId": "Director", + + "CheckSelf": "Prohibit {0} yourself", + "CanotDeleteAdminUser": "Cannot delete built-in super admin user", + "CanotEditAdminUser": "Cannot edit super admin user", + "CanotGrantAdmin": "Cannot assign admins roles", + "EmailDup": "Duplicate email {0} exists", + "AccountDup": "Duplicate account {0} exists", + "CanotDeleteSelf": "Cannot delete yourself", + "EmailError": "Email format error {0}", + "PhoneError": "Phone number format error {0}", + "NoOrg": "The organization does not exist", + "DirectorSelf": "Cannot set oneself as the supervisor", + + "DemoCanotUpdatePassword": "DEMO environment does not allow password modification", + "OldPasswordError": "Incorrect old password", + "ConfirmPasswordDiff": "Passwords entered twice are inconsistent", + "PasswordLengthLess": "Password length cannot be less than {0}", + "PasswordMustNum ": "Password must contain numbers", + "PasswordMustLow": "Password must contain lowercase letters", + "PasswordMustUpp": "Password must contain uppercase letters", + "PasswordMustSpecial": "Password must contain special characters" + }, + + "ThingsGateway.Admin.Application.SysRole": { + "Code": "Code", + "Name": "Name", + "Name.Required": "{0} is required", + "Category": "Category", + "SortCode": "Sort", + "CreateTime": "CreateTime", + "UpdateTime": "UpdateTime", + "OrgId": "Org", + "Global": "Global", + "Status": "Status", + "CanotDeleteAdmin": "Cannot delete built-in super admin role", + "CanotEditAdmin": "Cannot edit super admin role", + "CanotGrantAdmin": "Cannot assign admins roles", + "NameDup": "Duplicate role name {0}", + + "OrgNotNull": "Organization cannot be null", + "SameOrgNameDup": "Duplicate role name exists: {0}", + "CannotRoleScopeAll": "Organization role cannot select global data scope", + "CodeDup": "Duplicate code exists: {0}" + }, + + "ThingsGateway.Admin.Application.RoleCategoryEnum": { + "Global": "Global", + "Org": "Org" + }, + "ThingsGateway.Admin.Application.DataScopeEnum": { + "SCOPE_SELF": "Self", + "SCOPE_ALL": "All", + "SCOPE_ORG": "OnlyOrg", + "SCOPE_ORG_CHILD": "OrgChild", + "SCOPE_ORG_DEFINE": "Define" + }, + "ThingsGateway.Admin.Application.DefaultDataScope": { + "ScopeCategory": "DataScope", + "ScopeDefineOrgIdList": "DefineOrgList" + }, + + + "ThingsGateway.Admin.Application.SysResource": { + "Title": "Title", + "Module": "Module", + "Title.Required": "{0} is required", + "Href.Required": "{0} is required", + "Icon": "Icon", + "Href": "Path", + "Code": "Code", + "Category": "Category", + "Target": "Target", + "NavLinkMatch": "NavLinkMatch", + "SortCode": "Sort", + "CreateTime": "CreateTime", + "UpdateTime": "UpdateTime", + "ParentId": "Parent", + "ResourceDup": "Duplicate name {0} exists", + "ResourceParentChoiceSelf": "Parent cannot choose itself", + "ResourceParentNull": "Parent does not exist {0}", + "NotFoundResource": "System exception, menu not found", + "ModuleIdDiff": "Module is inconsistent with parent menu", + "CanotDeleteSystemResource": "Cannot delete system resource {0}", + "ResourceMenuHrefNotNull": "Menu href cannot null" + }, + "ThingsGateway.Admin.Application.SysOrgCopyInput": { + "TargetId": "Target", + "ContainsChild": "ContainsChild", + "ContainsPosition": "ContainsPosition" + }, + + "ThingsGateway.Admin.Application.SysPosition": { + "Category.Required": "{0} is a required field", + "Name.Required": "{0} is a required field", + "Code.Required": "{0} is a required field", + "OrgId.MinValue": "{0} is a required field", + "Category": "Category", + "Name": "Name", + "Code": "Code", + "Status": "Status", + "OrgId": "Organization", + "Remark": "Remarks", + "SortCode": "SortCode", + "CreateTime": "CreateTime", + "UpdateTime": "UpdateTime", + "Dup": "Duplicate position exists with Category {0} and Name {1}", + "CodeDup": "Duplicate code {0} exists", + "NameDup": "Duplicate name {0} exists", + "CanotContainsSelf": "Cannot contain itself", + "TargetNameDup": "Target node has duplicate name {0}", + "ParentChoiceSelf": "Parent cannot be itself", + "ParentNull": "Parent does not exist {0}", + "DeleteUserFirst": "Please remove the users under the position first" + }, + + "ThingsGateway.Admin.Application.SysOrg": { + "Category.Required": "{0} is a required field", + "Name.Required": "{0} is a required field", + "Code.Required": "{0} is a required field", + "Category": "Category", + "Name": "Name", + "Code": "Code", + "Status": "Status", + "ParentId": "ParentOrg", + "Names": "Names", + "Remark": "Remarks", + "DirectorId": "Director", + "SortCode": "SortCode", + "CreateTime": "CreateTime", + "UpdateTime": "UpdateTime", + "Dup": "Duplicate organization exists with Category {0} and Name {1}", + "CodeDup": "Duplicate code {0} exists", + "NameDup": "Duplicate name {0} exists", + "CanotContainsSelf": "Cannot contain itself", + "TargetNameDup": "Target node has duplicate name {0}", + "ParentChoiceSelf": "Parent cannot be itself", + "ParentNull": "Parent does not exist {0}", + "DeleteUserFirst": "Please remove the users under the organization first", + "DeleteRoleFirst": "Please remove the roles under the organization first", + "DeletePositionFirst": "Please remove the positions under the organization first", + "RootOrg": "Unable to create top-level organization" + }, + + "ThingsGateway.Admin.Application.OrgEnum": { + "COMPANY": "Company", + "DEPT": "Dept" + }, + "ThingsGateway.Admin.Application.PositionCategoryEnum": { + "HIGH": "High", + "MIDDLE": "Middle", + "LOW": "Low" + }, + //controller + "ThingsGateway.Admin.Application.AuthController": { + //auth + "AuthController": "Login API", + "LoginAsync": "Login", + "LogoutAsync": "Logout" + }, + "ThingsGateway.Admin.Application.TestController": { + //auth + "TestController": "Test API", + "Test": "Test" + }, + "ThingsGateway.Admin.Application.OpenApiAuthController": { + //auth + "OpenApiAuthController": "Login API", + "LoginAsync": "Login", + "LogoutAsync": "Logout" + }, + + "ThingsGateway.Admin.Application.FileService": { + "FileNullError": "File cannot be empty", + "FileLengthError": "File size cannot exceed {0} M", + "FileTypeError": "Not supported format {0}" + }, + + "ThingsGateway.Admin.Application.UnifyResultProvider": { + "TokenOver": "Login has expired, please login again", + "NoPermission": "Access denied, no permission" + }, + + "ThingsGateway.Admin.Application.AuthService": { + + "TenantNull": "The tenant does not exist", + "OrgDisable": "The affiliated company/department has been deactivated, please contact the administrator", + + "SingleLoginWarn": "Your account is logged in elsewhere", + "UserNull": "User {0} does not exist", + "MustDesc": "Password needs to be encrypted with DESC before passing", + "PasswordError": "Too many password errors, please try again in {0} minutes", + "UserDisable": "Account {0} has been disabled", + "UserNoModule": "This account has not been assigned a module. Please contact the administrator", + "AuthErrorMax": "Account password error, will be locked for {1} minutes after exceeding {0} times, error count {2}" + }, + + "ThingsGateway.Admin.Application.HardwareInfo": { + "Environment": "HostEnvironment", + "FrameworkDescription": ".NETFramework", + "OsArchitecture": "System Architecture", + "UUID": "UUID", + "UpdateTime": "UpdateTime" + }, + "ThingsGateway.Admin.Application.HistoryHardwareInfo": { + "DriveUsage": "Disk Usage", + "MemoryUsage": "Memory Usage", + "CpuUsage": "CPU Usage", + "Temperature": "Temperature", + "Battery": "Battery" + }, + + + //oper + "ThingsGateway.Admin.Application.OperDescAttribute": { + //dict + "SaveDict": "Modify dictionary", + "DeleteDict": "Delete dictionary", + "EditLoginPolicy": "Modify login policy", + "EditPasswordPolicy": "Modify password policy", + "EditPagePolicy": "Modify page policy", + "EditWebsitePolicy": "Modify website settings", + //operlog + "DeleteOperLog": "Delete operation log", + "ExportOperLog": "Export operation log", + + //resource + "SaveResource": "Modify resource", + "DeleteResource": "Delete resource", + + //role + "SaveRole": "Modify role", + "DeleteRole": "Delete role", + "RoleGrantResource": "Role grant resource", + "RoleGrantUser": "Role grant user", + "RoleGrantApiPermission": "Role grant OpenApi", + "GrantApi": "GrantApi", + "GrantUser": "GrantUser", + "GrantRole": "GrantRole", + "GrantResource": "GrantResource", + //user + "SaveUser": "Modify user", + "DeleteuSER": "Delete user", + "ResetPassword": "Reset pw", + "UserGrantRole": "User grant role", + "UserGrantResource": "User grant resource", + "UserGrantApiPermission": "User grant OpenApi", + + //usercenter + "UpdateUserInfo": "Update personal information", + "WorkbenchInfo": "Update personal workbench", + "UpdatePassword": "Update personal password", + + //session + "ExitVerificat": "Force token off", + "ExitSession": "Force session off", + + + "CopyOrg": "Copy Organization", + "DeleteOrg": "Delete Organization", + "SaveOrg": "Save Organization", + + "DeletePosition": "Delete Position", + "SavePosition": "Save Position", + + "NoPermission": "No Permission", + + + "CopyResource": "CopyResource", + "ChangeParentResource": "ChangeParentResource" + }, + + + //service + + "ThingsGateway.Admin.Application.HardwareJob": { + "GetHardwareInfoFail": "Get Hardwareinfo Fail" + }, + + //dto + "ThingsGateway.Admin.Application.UserSelectorOutput": { + "Account": "Account", + "OrgId": "Org" + }, + "ThingsGateway.Admin.Application.ResourceTableSearchModel": { + "Module": "Module", + "Href": "Path", + "Title": "Title" + }, + "ThingsGateway.Admin.Application.WorkbenchInfo": { + "Razor": "Homepage", + "Shortcuts": "Shortcuts" + }, + "ThingsGateway.Admin.Application.UpdatePasswordInput": { + "Password": "Password", + "NewPassword": "New password", + "ConfirmPassword": "Confirm password", + "Password.Required": "{0} is required", + "NewPassword.Required": "{0} is required", + "ConfirmPassword.Required": "{0} is required" + }, + + "ThingsGateway.Admin.Application.VerificatInfo": { + "Expire": "Expire(min)", + "Online": "Online", + "VerificatRemain": "VerificatRemain", + "VerificatTimeout": "VerificatTimeout", + "Device": "Device", + "LoginIp": "LoginIp", + "LoginTime": "LoginTime" + }, + + "ThingsGateway.Admin.Application.SessionOutput": { + "Account": "Account", + "Online": "Online status", + "LatestLoginIp": "Latest login IP", + "LatestLoginTime": "Latest login time", + "VerificatCount": "Token count" + }, + + "ThingsGateway.Admin.Application.SysDict": { + "Category.Required": "{0} is required", + "Name.Required": "{0} is required", + "Code.Required": "{0} is required", + "Category": "Category", + "Name": "Name", + "Code": "Code", + "Remark": "Remark", + "SortCode": "Sort", + "CreateTime": "CreateTime", + "UpdateTime": "UpdateTime", + "DemoCanotUpdateWebsitePolicy": "DEMO environment does not allow modifying website settings", + "DictDup": "Duplicate configuration exists, category {0}, name {1}" + }, + + "ThingsGateway.Admin.Application.SysOperateLog": { + "ClassName": "ClassName", + "ExeMessage": "ExeMessage", + "MethodName": "MethodName", + "ParamJson": "ParamJson", + "ReqMethod": "RequestMethod", + "ReqUrl": "RequestUrl", + "ResultJson": "ResultJson", + "Category": "Category", + "ExeStatus": "ExeStatus", + "Name": "Name", + "OpAccount": "OpAccount", + "OpBrowser": "OpBrowser", + "OpIp": "OpIp", + "OpOs": "OpOs", + "OpTime": "OpTime", + "VerificatId": "VerificatId" + + }, + "ThingsGateway.Admin.Application.OperateLogPageInput": { + "SearchDate": "SearchDate", + "Account": "Account", + "Category": "Category" + }, + "ThingsGateway.Admin.Application.LoginInput": { + "Account": "Account", + "Password": "Password", + "Account.Required": "{0} is required", + "Password.Required": "{0} is required" + }, + "ThingsGateway.Admin.Application.LogoutInput": { + "VerificatId.Required": "{0} is required" + }, + "ThingsGateway.Admin.Application.AppConfig": { + "LoginPolicy": "LoginPolicy", + "PasswordPolicy": "PasswordPolicy", + "PagePolicy": "PagePolicy", + "WebsitePolicy": "WebsitePolicy" + }, + "ThingsGateway.Admin.Application.LoginPolicy": { + "SingleOpen": "Single user login switch", + "ErrorLockTime": "Login error lock duration (min)", + "ErrorResetTime": "Login error count expiration duration (min)", + "ErrorCount": "Login error count lock threshold", + "VerificatExpireTime": "Login expiration time (min)", + "ErrorLockTime.MinValue": "{0} value is too small", + "ErrorResetTime.MinValue": "{0} value is too small", + "ErrorCount.MinValue": "{0} value is too small", + "VerificatExpireTime.MinValue": "{0} value is too small" + }, + "ThingsGateway.Admin.Application.PagePolicy": { + "Shortcuts": "Default shortcuts", + "Razor": "Default homepage" + }, + "ThingsGateway.Admin.Application.PasswordPolicy": { + "DefaultPassword": "Default user password", + "DefaultPassword.Required": "{0} is required", + "PasswordMinLen": "Minimum password length", + "PasswordMinLen.MinValue": "{0} value is too small", + "PasswordContainNum": "Contain numbers", + "PasswordContainLower": "Contain lowercase letters", + "PasswordContainUpper": "Contain uppercase letters", + "PasswordContainChar": "Contain special characters" + }, + "ThingsGateway.Admin.Application.WebsitePolicy": { + "WebStatus": "WebStatus", + "CloseTip": "CloseTip", + "CloseTip.Required": "{0} is required" + }, + //enum + "ThingsGateway.Admin.Application.ResourceCategoryEnum": { + "Module": "Module", + "Menu": "Menu", + "Button": "Button" + }, + + "ThingsGateway.Admin.Application.TargetEnum": { + "_self": "Current window", + "_blank": "New window", + "_parent": "Parent window", + "_top": "Top window" + }, + "ThingsGateway.Admin.Application.DictTypeEnum": { + "System": "System", + "Define": "Business" + }, + + "ThingsGateway.Admin.Application.LogCateGoryEnum": { + "Login": "Login", + "Logout": "Logout", + "Operate": "Operation", + "Exception": "Exception" + }, + + "ThingsGateway.Admin.Application.LogEnum": { + "SUCCESS": "Success", + "FAIL": "Fail" + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Locales/zh-CN.json b/src/Admin/ThingsGateway.Admin.Application/Locales/zh-CN.json new file mode 100644 index 000000000..9bb6ef174 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Locales/zh-CN.json @@ -0,0 +1,466 @@ +{ + "ThingsGateway.Admin.Application.SysUser": { + "Disable": "禁用", + "Enable": "启用", + "GrantRole": "分配角色", + "ExitVerificat": "您已被强制下线", + "PasswordEdited": "密码被修改,已退出登录", + "Avatar": "头像", + "Account": "账号", + "Account.Required": " {0} 是必填项", + "Password": "密码", + "Status": "状态", + "Phone": "手机", + "Email": "邮箱", + "LastLoginIp": "上次登录ip", + "LastLoginDevice": "上次登录设备", + "LastLoginTime": "上次登录时间", + "LastLoginAddress": "上次登录地点", + "LatestLoginIp": "最新登录ip", + "LatestLoginTime": "最新登录时间", + "LatestLoginDevice": "最新登录设备", + "LatestLoginAddress": "最新登录地点", + "SortCode": "排序", + "CreateTime": "创建时间", + "UpdateTime": "更新时间", + "OrgNames": "机构", + "PositionName": "职位", + "OrgId": "机构", + "PositionId": "职位", + "DirectorId": "主管", + "CheckSelf": "禁止 {0} 自己", + "CanotDeleteAdminUser": "不可删除系统内置超管用户", + "CanotEditAdminUser": "不可编辑超管用户", + "CanotGrantAdmin": "不能分配超管角色", + "EmailDup": "存在重复的邮箱 {0}", + "AccountDup": "存在重复的账号 {0}", + "CanotDeleteSelf": "不可删除自己", + "EmailError": "邮箱 {0} 格式错误", + "PhoneError": "手机号码 {0} 格式错误", + "NoOrg": "组织机构不存在", + "DirectorSelf": "不能设置自己为主管", + + + "DemoCanotUpdatePassword": "DEMO环境不允许修改密码", + "OldPasswordError": "原密码错误", + "ConfirmPasswordDiff": "两次输入的密码不一致", + "PasswordLengthLess": "密码长度不能小于 {0} ", + "PasswordMustNum ": "密码必须包含数字", + "PasswordMustLow": "密码必须包含小写字母", + "PasswordMustUpp": "密码必须包含大写字母", + "PasswordMustSpecial": "密码必须包含特殊字符" + }, + + "ThingsGateway.Admin.Application.SysRole": { + "Code": "编码", + "Name": "名称", + "Name.Required": " {0} 是必填项", + "Category": "分类", + "SortCode": "排序", + "Global": "全局", + "Status": "状态", + "OrgId": "机构", + "CreateTime": "创建时间", + "UpdateTime": "更新时间", + + "CanotDeleteAdmin": "不可删除系统内置超管角色", + "CanotEditAdmin": "不可编辑超管角色", + "CanotGrantAdmin": "不能分配超管角色", + + "NameDup": "存在重复的角色名称 {0}", + "OrgNotNull": "机构不能为空", + "SameOrgNameDup": "存在重复的角色名称 {0}", + "CannotRoleScopeAll": "机构角色不能选择全局数据范围", + "CodeDup": "存在重复的编码 {0}" + }, + "ThingsGateway.Admin.Application.RoleCategoryEnum": { + "Global": "全局", + "Org": "机构" + }, + "ThingsGateway.Admin.Application.DataScopeEnum": { + "SCOPE_SELF": "仅自己", + "SCOPE_ALL": "全部", + "SCOPE_ORG": "仅所属组织", + "SCOPE_ORG_CHILD": "所属组织及以下", + "SCOPE_ORG_DEFINE": "自定义" + }, + "ThingsGateway.Admin.Application.DefaultDataScope": { + "ScopeCategory": "数据范围", + "ScopeDefineOrgIdList": "自定义列表" + }, + "ThingsGateway.Admin.Application.SysResource": { + "Title": "标题", + "Module": "模块", + "Title.Required": "{0} 是必填项", + "Href.Required": "{0} 是必填项", + "Icon": "图标", + "Href": "路径", + "Code": "编码", + "Category": "分类", + "Target": "跳转类型", + "NavLinkMatch": "匹配类型", + "SortCode": "排序", + "ParentId": "上级菜单", + "CreateTime": "创建时间", + "UpdateTime": "更新时间", + "ResourceDup": "存在重复的名称 {0}", + "ResourceParentChoiceSelf": "父级不能选择自己", + "ResourceParentNull": "父级不存在 {0}", + "NotFoundResource": "系统异常,没找到该菜单", + "ModuleIdDiff": "模块与上级菜单不一致", + "CanotDeleteSystemResource": "不可删除系统资源 {0}", + "ResourceMenuHrefNotNull": "菜单的路径不能为空" + }, + + "ThingsGateway.Admin.Application.SysOrgCopyInput": { + "TargetId": "目标机构", + "ContainsChild": "包含下级", + "ContainsPosition": "包含职位" + }, + "ThingsGateway.Admin.Application.SysPosition": { + "Category.Required": "{0} 是必填项", + "Name.Required": "{0} 是必填项", + "Code.Required": "{0} 是必填项", + "OrgId.MinValue": "{0} 是必填项", + "Category": "分类", + "Name": "名称", + "Code": "代码", + "Status": "状态", + "OrgId": "机构", + "Remark": "备注", + "SortCode": "排序", + "CreateTime": "创建时间", + "UpdateTime": "更新时间", + "Dup": "存在重复的岗位 分类 {0} 名称 {1}", + "CodeDup": "存在重复的编码 {0}", + "NameDup": "存在重复的名称 {0}", + "CanotContainsSelf": "不可包含自己", + "TargetNameDup": "目标节点存在重复的名称 {0}", + "ParentChoiceSelf": "父级不能选择自己", + "ParentNull": "父级不存在 {0}", + "DeleteUserFirst": "请先删除职位下的用户" + + }, + + "ThingsGateway.Admin.Application.SysOrg": { + "Category.Required": "{0} 是必填项", + "Name.Required": "{0} 是必填项", + "Code.Required": "{0} 是必填项", + "Category": "分类", + "Name": "名称", + "Code": "代码", + "Status": "状态", + "ParentId": "上级机构", + "Names": "机构全称", + "Remark": "备注", + "DirectorId": "主管", + "SortCode": "排序", + "CreateTime": "创建时间", + "UpdateTime": "更新时间", + "Dup": "存在重复的机构 分类 {0} 名称 {1}", + "CodeDup": "存在重复的编码 {0}", + "NameDup": "存在重复的名称 {0}", + "CanotContainsSelf": "不可包含自己", + "TargetNameDup": "目标节点存在重复的名称 {0}", + "ParentChoiceSelf": "父级不能选择自己", + "ParentNull": "父级不存在 {0}", + "DeleteUserFirst": "请先删除机构下的用户", + "DeleteRoleFirst": "请先删除机构下的角色", + "DeletePositionFirst": "请先删除机构下的职位", + "RootOrg": "无法创建顶层机构" + }, + "ThingsGateway.Admin.Application.OrgEnum": { + "COMPANY": "公司", + "DEPT": "部门" + }, + "ThingsGateway.Admin.Application.PositionCategoryEnum": { + "HIGH": "高层", + "MIDDLE": "中层", + "LOW": "低层" + }, + + //controller + "ThingsGateway.Admin.Application.AuthController": { + //auth + "AuthController": "登录API", + "LoginAsync": "登录", + "LogoutAsync": "注销" + }, + "ThingsGateway.Admin.Application.TestController": { + //auth + "TestController": "测试API", + "Test": "测试" + }, + "ThingsGateway.Admin.Application.OpenApiAuthController": { + //auth + "OpenApiAuthController": "登录API", + "LoginAsync": "登录", + "LogoutAsync": "注销" + }, + + + + + "ThingsGateway.Admin.Application.FileService": { + "FileNullError": "文件不能为空", + "FileLengthError": "文件大小不允许超过 {0} M", + "FileTypeError": "不支持 {0} 格式" + }, + + "ThingsGateway.Admin.Application.UnifyResultProvider": { + "TokenOver": "登录已过期,请重新登录", + "NoPermission": "禁止访问,没有权限" + }, + + "ThingsGateway.Admin.Application.AuthService": { + "TenantNull": "租户不存在", + "OrgDisable": "所属公司/部门已停用,请联系管理员", + "SingleLoginWarn": "您的账号已在别处登录", + "UserNull": "用户 {0} 不存在", + "PasswordError": "密码错误次数过多,请 {0} 分钟后再试", + "AuthErrorMax": "账号密码错误,超过 {0} 次后将锁定 {1} 分钟,错误次数 {2} ", + "UserDisable": "账号 {0} 已停用", + "MustDesc": "密码需要DESC加密后传入", + "UserNoModule": "该账号未分配模块,请联系管理员" + }, + + + "ThingsGateway.Admin.Application.HardwareInfo": { + "Environment": "主机环境", + "FrameworkDescription": "NET框架", + "OsArchitecture": "系统架构", + "UUID": "唯一编码", + "UpdateTime": "更新时间" + }, + "ThingsGateway.Admin.Application.HistoryHardwareInfo": { + "DriveUsage": "磁盘使用率", + "MemoryUsage": "内存使用率", + "CpuUsage": "CPU使用率", + "Temperature": "温度", + "Battery": "电池" + }, + + //oper + "ThingsGateway.Admin.Application.OperDescAttribute": { + //dict + "SaveDict": "修改字典", + "DeleteDict": "删除字典", + "EditLoginPolicy": "修改登录策略", + "EditPasswordPolicy": "修改密码策略", + "EditPagePolicy": "修改页面策略", + "EditWebsitePolicy": "修改网站设置", + //operlog + "DeleteOperLog": "删除操作日志", + "ExportOperLog": "导出操作日志", + + //resource + "SaveResource": "修改资源", + "DeleteResource": "删除资源", + + //role + "SaveRole": "修改角色", + "DeleteRole": "删除角色", + "RoleGrantResource": "角色授权资源", + "RoleGrantUser": "角色授权用户", + "RoleGrantApiPermission": "角色授权OpenApi", + "GrantApi": "API", + "GrantUser": "用户", + "GrantRole": "角色", + "GrantResource": "资源", + //user + "SaveUser": "修改用户", + "DeleteuSER": "删除用户", + "ResetPassword": "重置密码", + "UserGrantRole": "用户授权角色", + "UserGrantResource": "用户授权资源", + "UserGrantApiPermission": "用户授权OpenApi", + + //usercenter + "UpdateUserInfo": "更新个人信息", + "WorkbenchInfo": "更新个人工作台", + "UpdatePassword": "更新个人密码", + + //session + "ExitVerificat": "强退令牌", + "ExitSession": "强退会话", + + + "CopyOrg": "复制机构", + "DeleteOrg": "删除机构", + "SaveOrg": "保存机构", + + "DeletePosition": "删除岗位", + "SavePosition": "保存岗位", + + "NoPermission": "无权限操作", + + "CopyResource": "复制资源", + "ChangeParentResource": "更改父节点" + }, + + //service + + "ThingsGateway.Admin.Application.HardwareJob": { + "GetHardwareInfoFail": "获取硬件信息出错" + }, + + //dto + "ThingsGateway.Admin.Application.UserSelectorOutput": { + "Account": "账号", + "OrgId": "机构" + }, + "ThingsGateway.Admin.Application.ResourceTableSearchModel": { + "Module": "模块", + "Href": "路径", + "Title": "标题" + }, + "ThingsGateway.Admin.Application.WorkbenchInfo": { + "Razor": "主页", + "Shortcuts": "快捷方式" + }, + "ThingsGateway.Admin.Application.UpdatePasswordInput": { + "Password": "密码", + "NewPassword": "新密码", + "ConfirmPassword": "确认密码", + "Password.Required": " {0} 是必填项", + "NewPassword.Required": " {0} 是必填项", + "ConfirmPassword.Required": " {0} 是必填项" + }, + + + "ThingsGateway.Admin.Application.VerificatInfo": { + "Expire": "过期时间(分)", + "Online": "在线状态", + "VerificatRemain": "剩余有效期", + "VerificatTimeout": "超时时间", + "Device": "登录设备", + "LoginIp": "登录IP", + "LoginTime": "登录时间" + }, + + "ThingsGateway.Admin.Application.SessionOutput": { + "Account": "账号", + "Online": "在线状态", + "LatestLoginIp": "最新登录ip", + "LatestLoginTime": "最新登录时间", + "VerificatCount": "令牌数量" + }, + + "ThingsGateway.Admin.Application.SysDict": { + "Category.Required": "{0} 是必填项", + "Name.Required": "{0} 是必填项", + "Code.Required": "{0} 是必填项", + "Category": "分类", + "Name": "名称", + "Code": "代码", + "Remark": "备注", + "SortCode": "排序", + "CreateTime": "创建时间", + "UpdateTime": "更新时间", + "DictDup": "存在重复的配置 分类 {0} 名称 {1}", + "DemoCanotUpdateWebsitePolicy": "DEMO环境不允许修改网站设置" + }, + + + "ThingsGateway.Admin.Application.SysOperateLog": { + "ClassName": "类名", + "ExeMessage": "具体消息", + "MethodName": "方法名称", + "ParamJson": "请求参数", + "ReqMethod": "请求方式", + "ReqUrl": "请求地址", + "ResultJson": "返回结果", + "Category": "日志分类", + "ExeStatus": "执行状态", + "Name": "日志名称", + "OpAccount": "账号", + "OpBrowser": "浏览器", + "OpIp": "ip", + "OpOs": "系统", + "OpTime": "操作时间", + "VerificatId": "验证Id" + + }, + "ThingsGateway.Admin.Application.OperateLogPageInput": { + "SearchDate": "时间范围", + "Account": "操作账号", + "Category": "分类" + }, + "ThingsGateway.Admin.Application.LoginInput": { + "Account": "登录账号", + "Password": "登录密码", + "Account.Required": "{0} 是必填项", + "Password.Required": "{0} 是必填项" + }, + "ThingsGateway.Admin.Application.LogoutInput": { + "VerificatId.Required": "{0} 是必填项" + }, + "ThingsGateway.Admin.Application.AppConfig": { + "LoginPolicy": "登录策略", + "PasswordPolicy": "密码策略", + "PagePolicy": "页面设置", + "WebsitePolicy": "网站设置" + }, + "ThingsGateway.Admin.Application.LoginPolicy": { + "SingleOpen": "单用户登录开关", + "ErrorLockTime": "登录错误锁定时长(分)", + "ErrorResetTime": "登录错误次数过期时长(分)", + "ErrorCount": "登录错误次数锁定阈值", + "VerificatExpireTime": "登录过期时间(分)", + "ErrorLockTime.MinValue": " {0} 值太小", + "ErrorResetTime.MinValue": " {0} 值太小", + "ErrorCount.MinValue": " {0} 值太小", + "VerificatExpireTime.MinValue": " {0} 值太小" + }, + "ThingsGateway.Admin.Application.PagePolicy": { + "Shortcuts": "默认快捷方式", + "Razor": "默认主页" + }, + "ThingsGateway.Admin.Application.PasswordPolicy": { + "DefaultPassword": "默认用户密码", + "DefaultPassword.Required": " {0} 是必填项", + "PasswordMinLen": "密码最小长度", + "PasswordMinLen.MinValue": " {0} 值太小", + "PasswordContainNum": "包含数字", + "PasswordContainLower": "包含小写字母", + "PasswordContainUpper": "包含大写字母", + "PasswordContainChar": "包含特殊字符" + }, + "ThingsGateway.Admin.Application.WebsitePolicy": { + "WebStatus": "是否开放", + "CloseTip": "关闭提示", + "CloseTip.Required": " {0} 是必填项" + }, + + + + //enum + "ThingsGateway.Admin.Application.ResourceCategoryEnum": { + "Module": "模块", + "Menu": "菜单", + "Button": "按钮" + }, + + "ThingsGateway.Admin.Application.TargetEnum": { + "_self": "本窗口", + "_blank": "新窗口", + "_parent": "父级窗口", + "_top": "顶级窗口" + }, + "ThingsGateway.Admin.Application.DictTypeEnum": { + "System": "系统配置", + "Define": "业务配置" + }, + + "ThingsGateway.Admin.Application.LogCateGoryEnum": { + "Login": "登录", + "Logout": "注销", + "Operate": "操作", + "Exception": "异常" + }, + + "ThingsGateway.Admin.Application.LogEnum": { + "SUCCESS": "成功", + "FAIL": "失败" + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Logging/DatabaseLoggingWriter.cs b/src/Admin/ThingsGateway.Admin.Application/Logging/DatabaseLoggingWriter.cs new file mode 100644 index 000000000..212fc1a61 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Logging/DatabaseLoggingWriter.cs @@ -0,0 +1,214 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +using System.Collections.Concurrent; + +using ThingsGateway.Extension; +using ThingsGateway.FriendlyException; +using ThingsGateway.Logging; +using ThingsGateway.NewLife.Json.Extension; +using ThingsGateway.Razor; + +using UAParser; + +namespace ThingsGateway.Admin.Application; + +/// +/// 数据库写入器 +/// +public class DatabaseLoggingWriter : IDatabaseLoggingWriter +{ + /// + /// 日志消息队列(线程安全) + /// + private readonly ConcurrentQueue _operateLogMessageQueue = new(); + + private SqlSugarClient SqlSugarClient; + + /// + /// 此方法只会写入经由MVCFilter捕捉的方法日志,对于BlazorServer的内部操作,由执行 + /// + /// + /// + public async Task WriteAsync(LogMessage logMsg, bool flush) + { + //获取请求json字符串 + var jsonString = logMsg.Context.Get("loggingMonitor").ToString(); + //转成实体 + var loggingMonitor = jsonString.FromJsonNetString(); + //日志时间赋值 + loggingMonitor.LogDateTime = logMsg.LogDateTime; + // loggingMonitor.ReturnInformation.Value + //验证失败不记录日志 + bool save = false; + if (loggingMonitor.Validation == null) + { + var operation = logMsg.Context.Get(LoggingConst.Operation).ToString();//获取操作名称 + var client = (ClientInfo)logMsg.Context.Get(LoggingConst.Client);//获取客户端信息 + var path = logMsg.Context.Get(LoggingConst.Path).ToString();//获取操作名称 + var method = logMsg.Context.Get(LoggingConst.Method).ToString();//获取方法 + //表示访问日志 + if (path == "/api/auth/login" || path == "/api/auth/logout") + { + //如果没有异常信息 + if (loggingMonitor.Exception == null) + { + save = await CreateVisitLog(operation, path, loggingMonitor, client, flush).ConfigureAwait(false);//添加到访问日志 + } + else + { + //添加到异常日志 + save = await CreateOperationLog(operation, path, loggingMonitor, client, flush).ConfigureAwait(false); + } + } + else + { + //只有定义了Title的POST方法才记录日志 + if (!operation.IsNullOrWhiteSpace() && method == "POST") + { + //添加到操作日志 + save = await CreateOperationLog(operation, path, loggingMonitor, client, flush).ConfigureAwait(false); + } + } + } + if (save) + { + await Task.Delay(1000).ConfigureAwait(false); + } + } + + /// + /// 创建操作日志 + /// + /// 操作名称 + /// 请求地址 + /// loggingMonitor + /// 客户端信息 + /// + /// + private async Task CreateOperationLog(string operation, string path, LoggingMonitorJson loggingMonitor, ClientInfo clientInfo, bool flush) + { + //账号 + var opAccount = loggingMonitor.AuthorizationClaims?.Where(it => it.Type == ClaimConst.Account).Select(it => it.Value).FirstOrDefault(); + + //获取参数json字符串, + var paramJson = loggingMonitor.Parameters == null || loggingMonitor.Parameters.Count == 0 ? null : loggingMonitor.Parameters[0].Value.ToJsonNetString(); + + //获取结果json字符串 + var resultJson = string.Empty; + if (loggingMonitor.ReturnInformation != null)//如果有返回值 + { + if (loggingMonitor.ReturnInformation.Value != null)//如果返回值不为空 + { + resultJson = loggingMonitor.ReturnInformation.Value.ToJsonNetString(); + } + } + + //操作日志表实体 + var sysLogOperate = new SysOperateLog + { + Name = operation, + Category = LogCateGoryEnum.Operate, + ExeStatus = true, + OpIp = loggingMonitor.RemoteIPv4, + OpBrowser = clientInfo?.UA?.Family + clientInfo?.UA?.Major, + OpOs = clientInfo?.OS?.Family + clientInfo?.OS?.Major, + OpTime = loggingMonitor.LogDateTime.LocalDateTime, + OpAccount = opAccount, + ReqMethod = loggingMonitor.HttpMethod, + ReqUrl = path, + ResultJson = resultJson, + ClassName = loggingMonitor.DisplayName, + MethodName = loggingMonitor.ActionName, + ParamJson = paramJson, + VerificatId = UserManager.VerificatId, + }; + //如果异常不为空 + if (loggingMonitor.Exception != null) + { + sysLogOperate.Category = LogCateGoryEnum.Exception;//操作类型为异常 + sysLogOperate.ExeStatus = false;//操作状态为失败 + + if (loggingMonitor.Exception.Type == typeof(AppFriendlyException).ToString()) + sysLogOperate.ExeMessage = loggingMonitor?.Exception.Message; + else + sysLogOperate.ExeMessage = $"{loggingMonitor.Exception.Type}:{loggingMonitor.Exception.Message}{Environment.NewLine}{loggingMonitor.Exception.StackTrace}"; + } + + _operateLogMessageQueue.Enqueue(sysLogOperate); + + if (flush) + { + SqlSugarClient ??= DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); + await SqlSugarClient.InsertableWithAttr(_operateLogMessageQueue.ToListWithDequeue()).ExecuteCommandAsync().ConfigureAwait(false);//入库 + return true; + } + return false; + } + + /// + /// 创建访问日志 + /// + /// 访问类型 + /// + /// loggingMonitor + /// 客户端信息 + /// + private async Task CreateVisitLog(string operation, string path, LoggingMonitorJson loggingMonitor, ClientInfo clientInfo, bool flush) + { + long verificatId = 0;//验证Id + var opAccount = "";//用户账号 + if (path == "/api/auth/login") + { + //如果是登录,用户信息就从返回值里拿 + var result = loggingMonitor.ReturnInformation?.Value?.ToJsonNetString();//返回值转json + var userInfo = result.FromJsonNetString>();//格式化成user表 + opAccount = userInfo.Data.Account;//赋值账号 + verificatId = userInfo.Data.VerificatId; + } + else + { + //如果是登录出,用户信息就从AuthorizationClaims里拿 + opAccount = loggingMonitor.AuthorizationClaims.Where(it => it.Type == ClaimConst.Account).Select(it => it.Value).FirstOrDefault(); + verificatId = loggingMonitor.AuthorizationClaims.Where(it => it.Type == ClaimConst.VerificatId).Select(it => it.Value).FirstOrDefault().ToLong(); + } + //日志表实体 + var sysLogVisit = new SysOperateLog + { + Name = operation, + Category = path == "/api/auth/login" ? LogCateGoryEnum.Login : LogCateGoryEnum.Logout, + ExeStatus = true, + OpIp = loggingMonitor.RemoteIPv4, + OpBrowser = clientInfo?.UA?.Family + clientInfo?.UA?.Major, + OpOs = clientInfo?.OS?.Family + clientInfo?.OS?.Major, + OpTime = loggingMonitor.LogDateTime.LocalDateTime, + VerificatId = verificatId, + OpAccount = opAccount, + + ReqMethod = loggingMonitor.HttpMethod, + ReqUrl = path, + ResultJson = loggingMonitor.ReturnInformation?.Value?.ToJsonNetString(), + ClassName = loggingMonitor.DisplayName, + MethodName = loggingMonitor.ActionName, + ParamJson = loggingMonitor.Parameters?.ToJsonNetString(), + }; + _operateLogMessageQueue.Enqueue(sysLogVisit); + + if (flush) + { + SqlSugarClient ??= DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); + await SqlSugarClient.InsertableWithAttr(_operateLogMessageQueue.ToListWithDequeue()).ExecuteCommandAsync().ConfigureAwait(false);//入库 + return true; + } + return false; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Logging/LoggingConst.cs b/src/Admin/ThingsGateway.Admin.Application/Logging/LoggingConst.cs new file mode 100644 index 000000000..470676394 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Logging/LoggingConst.cs @@ -0,0 +1,42 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 日志常量 +/// +public class LoggingConst +{ + /// + /// 分类 + /// + public const string CateGory = "CateGory"; + + /// + /// 客户端信息 + /// + public const string Client = "Client"; + + /// + /// 请求方法:POST/GET + /// + public const string Method = "Method"; + + /// + /// 操作名称 + /// + public const string Operation = "Operation"; + + /// + /// 请求地址 + /// + public const string Path = "Path"; +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Logging/LoggingMonitorJson.cs b/src/Admin/ThingsGateway.Admin.Application/Logging/LoggingMonitorJson.cs new file mode 100644 index 000000000..72adcdfd6 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Logging/LoggingMonitorJson.cs @@ -0,0 +1,187 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 认证信息 +/// +public class AuthorizationClaims +{ + /// + /// 类型 + /// + public string Type { get; set; } + + /// + /// 值 + /// + public string Value { get; set; } +} + +/// +/// 异常信息 +/// +public class LogException +{ + /// + /// 异常内容 + /// + public string Message { get; set; } + + /// + /// 堆栈信息 + /// + public string StackTrace { get; set; } + + /// + /// 异常类型 + /// + public string Type { get; set; } +} + +/// +/// 请求信息格式化 +/// +public class LoggingMonitorJson +{ + /// + /// 方法名称 + /// + public string ActionName { get; set; } + + /// + /// 认证信息 + /// + public List AuthorizationClaims { get; set; } + + /// + /// 控制器名 + /// + public string ControllerName { get; set; } + + /// + /// 类名称 + /// + public string DisplayName { get; set; } + + /// + /// 环境 + /// + public string Environment { get; set; } + + /// + /// 异常信息 + /// + public LogException Exception { get; set; } + + /// + /// 请求方法 + /// + public string HttpMethod { get; set; } + + /// + /// 服务端 + /// + public string LocalIPv4 { get; set; } + + /// + /// 日志时间 + /// + public DateTimeOffset LogDateTime { get; set; } + + /// + /// 系统架构 + /// + public string OsArchitecture { get; set; } + + /// + /// 系统名称 + /// + public string OsDescription { get; set; } + + /// + /// 参数列表 + /// + public List Parameters { get; set; } + + /// + /// 客户端IPV4地址 + /// + public string RemoteIPv4 { get; set; } + + /// + /// 认证信息 + /// + public string RequestHeaderAuthorization { get; set; } + + /// + /// 认证信息 + /// + public string RequestHeaderCookies { get; set; } + + /// + /// 请求地址 + /// + public string RequestUrl { get; set; } + + /// + /// 返回信息 + /// + public ReturnInformation ReturnInformation { get; set; } + + /// + /// 浏览器标识 + /// + public string UserAgent { get; set; } + + /// + /// 验证错误信息 + /// + public Validation Validation { get; set; } +} + +/// +/// 请求参数 +/// +public class Parameters +{ + /// + /// 参数名 + /// + public string Name { get; set; } + + /// + /// 值 + /// + public object Value { get; set; } +} + +/// +/// 返回信息 +/// +public class ReturnInformation +{ + /// + /// 返回值 + /// + public object Value { get; set; } +} + +/// +/// 验证失败信息 +/// +public class Validation +{ + /// + /// 错误详情 + /// + public string Message { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Options/AdminLogOptions.cs b/src/Admin/ThingsGateway.Admin.Application/Options/AdminLogOptions.cs new file mode 100644 index 000000000..a5ff07f47 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Options/AdminLogOptions.cs @@ -0,0 +1,21 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Admin.Application; + + +public sealed class AdminLogOptions : IConfigurableOptions +{ + + public int OperateLogDaysAgo { get; set; } + +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Application/Options/EmailOptions.cs b/src/Admin/ThingsGateway.Admin.Application/Options/EmailOptions.cs new file mode 100644 index 000000000..1f13e95f4 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Options/EmailOptions.cs @@ -0,0 +1,60 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Admin.Application; + + +/// +/// 邮件配置选项 +/// +public sealed class EmailOptions : IConfigurableOptions +{ + /// + /// 主机 + /// + public string Host { get; set; } + + /// + /// 端口 + /// + public int Port { get; set; } + + /// + /// 默认发件者邮箱 + /// + public string DefaultFromEmail { get; set; } + + /// + /// 默认接收人邮箱 + /// + public string DefaultToEmail { get; set; } + + /// + /// 启用SSL + /// + public bool EnableSsl { get; set; } + + /// + /// 邮箱账号 + /// + public string UserName { get; set; } + + /// + /// 邮箱密码 + /// + public string Password { get; set; } + + /// + /// 默认邮件标题 + /// + public string DefaultFromName { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Application/Options/HardwareInfoOptions.cs b/src/Admin/ThingsGateway.Admin.Application/Options/HardwareInfoOptions.cs new file mode 100644 index 000000000..34aec8090 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Options/HardwareInfoOptions.cs @@ -0,0 +1,34 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Admin.Application; + +/// +/// 历史硬件信息 +/// +public class HardwareInfoOptions : IConfigurableOptions +{ + /// + /// 启用 + /// + public bool Enable { get; set; } = true; + + /// + /// 历史保存间隔 + /// + public int HistoryInterval { get; set; } = 60000; + + /// + /// 历史保留天数 + /// + public int DaysAgo { get; set; } = 7; +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Options/TenantOptions.cs b/src/Admin/ThingsGateway.Admin.Application/Options/TenantOptions.cs new file mode 100644 index 000000000..c5dba59fa --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Options/TenantOptions.cs @@ -0,0 +1,23 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Admin.Application; + + +public sealed class TenantOptions : IConfigurableOptions +{ + /// + /// 启用 + /// + public bool Enable { get; set; } + +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Application/Provider/BlazorAuthenticationStateProvider.cs b/src/Admin/ThingsGateway.Admin.Application/Provider/BlazorAuthenticationStateProvider.cs new file mode 100644 index 000000000..5e9560d64 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Provider/BlazorAuthenticationStateProvider.cs @@ -0,0 +1,247 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Http; + +using System.Security.Claims; + +using ThingsGateway.Authorization; +using ThingsGateway.DataEncryption; + +namespace ThingsGateway.Admin.Application; + +/// +public class BlazorAuthenticationStateProvider : AppAuthorizeHandler +{ + private readonly ISysDictService _sysDictService; + private readonly ISysRoleService _sysRoleService; + private readonly ISysUserService _sysUserService; + private readonly IVerificatInfoService _verificatInfoService; + + public BlazorAuthenticationStateProvider(IVerificatInfoService verificatInfoService, ISysUserService sysUserService, ISysRoleService sysRoleService, ISysDictService sysDictService) + { + _sysUserService = sysUserService; + _sysRoleService = sysRoleService; + _sysDictService = sysDictService; + _verificatInfoService = verificatInfoService; + } + + /// + public override async Task HandleAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext) + { + var isAuthenticated = context.User.Identity?.IsAuthenticated; + if (isAuthenticated == true) + { + if (await CheckVerificatFromCacheAsync(context).ConfigureAwait(false)) + { + await AuthorizeHandleAsync(context).ConfigureAwait(false); + } + else + { + if (App.HttpContext != null) + { + var identity = new ClaimsIdentity(); + App.HttpContext.User = new ClaimsPrincipal(identity); + } + Fail(context); + } + } + else + { + Fail(context);// 授权失败 + } + + static void Fail(AuthorizationHandlerContext context) + { + context.Fail(); // 授权失败 + DefaultHttpContext currentHttpContext = context.GetCurrentHttpContext(); + if (currentHttpContext == null) + return; + currentHttpContext.Response.StatusCode = 401; //返回401给授权筛选器用 + currentHttpContext.SignoutToSwagger(); + currentHttpContext.SignOutAsync(); + } + } + + /// + public override async Task PipelineAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext) + { + var userId = context.User.Claims.FirstOrDefault(it => it.Type == ClaimConst.UserId)?.Value?.ToLong(0) ?? 0; + + var user = await _sysUserService.GetUserByIdAsync(userId).ConfigureAwait(false); + if (context.Resource is Microsoft.AspNetCore.Components.RouteData routeData) + { + var roles = await _sysRoleService.GetRoleListByUserIdAsync(userId).ConfigureAwait(false); + + //这里鉴别用户使能状态 + if (user == null || !user.Status) + { + return false; + } + + //超级管理员都能访问 + var isSuperAdmin = context.User.Claims.FirstOrDefault(it => it.Type == ClaimConst.SuperAdmin)?.Value.ToBoolean(); + if (isSuperAdmin == true) return true; + + // 获取超级管理员特性 + var superAdminAttr = routeData.PageType.CustomAttributes.FirstOrDefault(x => + x.AttributeType == typeof(SuperAdminAttribute)); + + if (superAdminAttr != null) //如果是超级管理员才能访问的接口 + { + return false; //直接没权限 + } + //获取角色授权特性 + var isRolePermission = routeData.PageType.CustomAttributes.FirstOrDefault(x => + x.AttributeType == typeof(RolePermissionAttribute)); + if (isRolePermission != null) + { + //获取忽略角色授权特性 + var isIgnoreRolePermission = routeData.PageType.CustomAttributes.FirstOrDefault(x => + x.AttributeType == typeof(IgnoreRolePermissionAttribute)); + if (isIgnoreRolePermission == null) + { + // 路由名称 + var routeName = routeData.PageType.CustomAttributes.FirstOrDefault(x => + x.AttributeType == typeof(RouteAttribute))?.ConstructorArguments?[0].Value as string; + if (routeName == null) return true; + + if ((!user.PermissionCodeList.Contains(routeName.CutStart("/")) && !user.PermissionCodeList.Contains(routeName))) //如果当前路由信息不包含在角色授权路由列表中则认证失败 + return false; + else + return true; + } + else + { + return true; + } + } + else + { + return true; + } + } + else + { + //这里鉴别用户使能状态 + if (user == null || !user.Status) + { + return false; + } + + //超级管理员都能访问 + var isSuperAdmin = context.User.Claims.FirstOrDefault(it => it.Type == ClaimConst.SuperAdmin)?.Value?.ToBoolean(); + if (isSuperAdmin == true) return true; + + if (httpContext == null) + { + //非API请求 + return true; + } + + var superAdminAttr = httpContext.GetMetadata(); + + if (superAdminAttr != null) //如果是超级管理员才能访问的接口 + { + //获取忽略超级管理员特性 + var ignoreSpuerAdmin = httpContext.GetMetadata(); + if (ignoreSpuerAdmin == null && isSuperAdmin != true) //如果只能超级管理员访问并且用户不是超级管理员 + return false; //直接没权限 + } + + //获取角色授权特性 + var isRolePermission = httpContext.GetMetadata(); + if (isRolePermission != null) + { + //获取忽略角色授权特性 + var ignoreRolePermission = httpContext.GetMetadata(); + if (ignoreRolePermission == null) + { + // 路由名称 + var routeName = httpContext.Request.Path.Value; + if (routeName == null) return true; + if ((!user.PermissionCodeList.Contains(routeName.CutStart("/")) && !user.PermissionCodeList.Contains(routeName))) //如果当前路由信息不包含在角色授权路由列表中则认证失败 + return false; + else + { + return true; //没有用户信息则返回认证失败 + } + } + } + else + { + return true; + } + } + + return true; + } + + /// + /// 检查 BearerToken/Cookie 有效性 + /// + /// DefaultHttpContext + /// + private async Task CheckVerificatFromCacheAsync(AuthorizationHandlerContext context) + { + DefaultHttpContext currentHttpContext = context.GetCurrentHttpContext(); + var userId = context.User.Claims.FirstOrDefault(it => it.Type == ClaimConst.UserId)?.Value?.ToLong(); + var verificatId = context.User.Claims.FirstOrDefault(it => it.Type == ClaimConst.VerificatId)?.Value?.ToLong(); + var expire = (await _sysDictService.GetAppConfigAsync().ConfigureAwait(false)).LoginPolicy.VerificatExpireTime; + if (currentHttpContext == null) + { + var verificatInfo = userId != null ? _verificatInfoService.GetOne(verificatId ?? 0) : null;//获取token信息 + + if (verificatInfo != null) + { + if (verificatInfo.VerificatTimeout < DateTime.Now.AddMinutes(5)) + { + verificatInfo.VerificatTimeout = DateTime.Now.AddMinutes(30); //新的过期时间 + _verificatInfoService.Update(verificatInfo); //更新tokne信息到cache + } + return true; + } + else + { + return false; + } + } + else + { + if (await JWTEncryption.AutoRefreshToken(context, currentHttpContext, expire, expire * 2).ConfigureAwait(false)) + { + //var token = JWTEncryption.GetJwtBearerToken(currentHttpContext); //获取当前token + + var verificatInfo = userId != null ? _verificatInfoService.GetOne(verificatId ?? 0) : null;//获取token信息 + if (verificatInfo != null) + { + if (verificatInfo.VerificatTimeout < DateTime.Now.AddMinutes(5)) + { + verificatInfo.VerificatTimeout = DateTime.Now.AddMinutes(verificatInfo.Expire); //新的过期时间 + _verificatInfoService.Update(verificatInfo); //更新tokne信息到cache + } + return true; + } + else + { + return false; + } + } + else + { + //失败 + return false; + } + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Provider/UnifyResultProvider.cs b/src/Admin/ThingsGateway.Admin.Application/Provider/UnifyResultProvider.cs new file mode 100644 index 000000000..b33aa403a --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Provider/UnifyResultProvider.cs @@ -0,0 +1,102 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Localization; + +using ThingsGateway.DataValidation; +using ThingsGateway.FriendlyException; +using ThingsGateway.Razor; +using ThingsGateway.UnifyResult; + +namespace ThingsGateway.Admin.Application; + +/// +/// 规范化RESTful风格返回值 +/// +[UnifyModel(typeof(UnifyResult<>))] +public class UnifyResultProvider : IUnifyResultProvider +{ + private static IStringLocalizer Localizer = App.CreateLocalizerByType(typeof(UnifyResultProvider))!; + + /// + /// 状态码响应拦截 + /// + public async Task OnResponseStatusCodes(HttpContext context, int statusCode, UnifyResultSettingsOptions unifyResultSettings = null) + { + switch (statusCode) + { + // 处理 401 状态码 + case StatusCodes.Status401Unauthorized: + await context.Response.WriteAsJsonAsync(RESTfulResult(statusCode, false, Localizer["TokenOver"].Value)).ConfigureAwait(false); + break; + // 处理 403 状态码 + case StatusCodes.Status403Forbidden: + await context.Response.WriteAsJsonAsync(RESTfulResult(statusCode, false, default, Localizer["NoPermission"].Value)).ConfigureAwait(false); + break; + + default: break; + } + } + + /// + /// 成功返回 + /// + /// + /// + /// + public IActionResult OnSucceeded(ActionExecutedContext context, object? data) + { + return new JsonResult(RESTfulResult(StatusCodes.Status200OK, true, data)); + } + + + + /// + /// 返回 RESTful 风格结果集 + /// + /// 状态码 + /// 是否成功 + /// 数据 + /// 错误信息 + /// + private static UnifyResult RESTfulResult(int statusCode, bool succeeded = default, object? data = default, object? errors = default) + { + return new UnifyResult + { + Code = statusCode, + Msg = statusCode == StatusCodes.Status200OK ? "Success" : errors, + Data = data, + Time = DateTime.Now, + }; + } + + public IActionResult OnAuthorizeException(DefaultHttpContext context, ExceptionMetadata metadata) + { + return new JsonResult(RESTfulResult(metadata.StatusCode, data: metadata.Data, errors: metadata.Errors ?? metadata.Exception?.Message) + , UnifyContext.GetSerializerSettings(context)); + } + + public IActionResult OnException(ExceptionContext context, ExceptionMetadata metadata) + { + return new JsonResult(RESTfulResult(metadata.StatusCode, data: metadata.Data, errors: metadata.Errors ?? metadata.Exception?.Message) + , UnifyContext.GetSerializerSettings(context)); + } + + public IActionResult OnValidateFailed(ActionExecutingContext context, ValidationMetadata metadata) + { + return new JsonResult(RESTfulResult(metadata.StatusCode ?? StatusCodes.Status400BadRequest, data: metadata.Data, errors: metadata.ValidationResult) // 如果需要只显示第一条错误,修改为:errors: metadata.FirstErrorMessage + , UnifyContext.GetSerializerSettings(context)); + } + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_dict.json b/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_dict.json new file mode 100644 index 000000000..f383841b5 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_dict.json @@ -0,0 +1,124 @@ +{ + "RECORDS": [ + { + "Id": 252875263003121, + "DictType": "System", + "Category": "LoginPolicy", + "Name": "VerificatExpireTime", + "Code": "14400", + "Remark": "Verificat过期时间(分)", + "IsDelete": "0", + "SortCode": 9 + }, + { + "Id": 252875263003720, + "DictType": "System", + "Category": "PagePolicy", + "Name": "Shortcuts", + "Code": "[100]", + "Remark": "系统默认工作台数据", + "IsDelete": "0", + "SortCode": 1 + }, + { + "Id": 252875263003728, + "DictType": "System", + "Category": "PasswordPolicy", + "Name": "DefaultPassword", + "Code": "111111", + "Remark": "默认用户密码", + "IsDelete": "0", + "SortCode": 1 + }, + { + "Id": 364564896825413, + "DictType": "System", + "Category": "LoginPolicy", + "Name": "SingleOpen", + "Code": "False", + "Remark": "单用户登录开关", + "IsDelete": "0", + "SortCode": 1 + }, + { + "Id": 432079540875333, + "DictType": "System", + "Category": "LoginPolicy", + "Name": "ErrorCount", + "Code": "10", + "Remark": "登录错误次数", + "IsDelete": "0", + "SortCode": 99 + }, + { + "Id": 432079718375493, + "DictType": "System", + "Category": "LoginPolicy", + "Name": "ErrorResetTime", + "Code": "1", + "Remark": "错误重置时间", + "IsDelete": "0", + "SortCode": 99 + }, + { + "Id": 432079850803269, + "DictType": "System", + "Category": "LoginPolicy", + "Name": "ErrorLockTime", + "Code": "2", + "Remark": "登录错误锁定时长", + "IsDelete": "0", + "SortCode": 99 + }, + { + "Id": 432111701160005, + "DictType": "System", + "Category": "PasswordPolicy", + "Name": "PasswordMinLen", + "Code": "6", + "Remark": "密码最小长度", + "IsDelete": "0", + "SortCode": 99 + }, + { + "Id": 432112135159877, + "DictType": "System", + "Category": "PasswordPolicy", + "Name": "PasswordContainNum", + "Code": "False", + "Remark": "包含数字", + "IsDelete": "0", + "SortCode": 99 + }, + { + "Id": 432112321953861, + "DictType": "System", + "Category": "PasswordPolicy", + "Name": "PasswordContainLower", + "Code": "False", + "Remark": "包含小写字母", + "IsDelete": "0", + "SortCode": 99 + }, + { + "Id": 432112380907589, + "DictType": "System", + "Category": "PasswordPolicy", + "Name": "PasswordContainUpper", + "Code": "False", + "Remark": "包含大写字母", + "IsDelete": "0", + "SortCode": 99 + }, + { + "Id": 432112495247429, + "DictType": "System", + "Category": "PasswordPolicy", + "Name": "PasswordContainChar", + "Code": "False", + "Remark": "包含特殊字符", + "IsDelete": "0", + "SortCode": 99 + } + ] +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_relation.json b/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_relation.json new file mode 100644 index 000000000..36b524092 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_relation.json @@ -0,0 +1,11 @@ +{ + "RECORDS": [ + { + "Id": 503217527844933, + "Category": 0, + "ObjectId": 212725263002001, + "TargetId": "212725263001001", + "ExtJson": null + } + ] +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_resource.json b/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_resource.json new file mode 100644 index 000000000..394e9fad0 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_resource.json @@ -0,0 +1,288 @@ +{ + "RECORDS": [ + { + "Id": 2, + "ParentId": 0, + "Title": "系统管理", + "Code": "System", + "Category": "MODULE", + "Target": "_self", + "Href": "", + "Icon": "fa-solid fa-house-user", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 100001, + "ParentId": 0, + "Module": 2, + "Title": "权限管理", + "Code": "System", + "NavLinkMatch": "All", + "Category": "MENU", + "Target": "_self", + "Href": null, + "Icon": "fa-solid fa-users-gear", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 100, + "ExtJson": null + }, + { + "Id": 100002, + "ParentId": 0, + "Module": 2, + "Title": "系统运维", + "NavLinkMatch": "All", + "Code": "System", + "Category": "MENU", + "Target": "_self", + "Href": null, + "Icon": "fa-solid fa-file-circle-check", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 101, + "ExtJson": null + }, + { + "Id": 100001001, + "Module": 2, + "ParentId": 100001, + "Title": "用户管理", + "NavLinkMatch": "All", + "Code": "System", + "Category": "MENU", + "Target": "_self", + "Href": "/admin/user", + "Icon": "fa-solid fa-user-gear", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 100001005, + "Module": 2, + "ParentId": 100001, + "Title": "机构管理", + "NavLinkMatch": "All", + "Code": "System", + "Category": "MENU", + "Target": "_self", + "Href": "/admin/org", + "Icon": "fa-solid fa-user-gear", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 100001004, + "Module": 2, + "ParentId": 100001, + "Title": "职位管理", + "NavLinkMatch": "All", + "Code": "System", + "Category": "MENU", + "Target": "_self", + "Href": "/admin/position", + "Icon": "fa-solid fa-user-gear", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 100001002, + "Module": 2, + "ParentId": 100001, + "Title": "角色管理", + "Code": "System", + "Category": "MENU", + "Target": "_self", + "NavLinkMatch": "All", + "Href": "/admin/role", + "Icon": "fa-solid fa-users-gear", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 2, + "ExtJson": null + }, + { + "Id": 100001003, + "Module": 2, + "ParentId": 100001, + "Title": "菜单管理", + "Code": "System", + "Category": "MENU", + "Target": "_self", + "Href": "/admin/resource", + "NavLinkMatch": "All", + "Icon": "fa-solid fa-bars", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 3, + "ExtJson": null + }, + { + "Id": 100002001, + "ParentId": 100002, + "Module": 2, + "Title": "系统配置", + "Code": "System", + "Category": "MENU", + "NavLinkMatch": "All", + "Target": "_self", + "Href": "/admin/config", + "Icon": "fa-solid fa-gear", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 100002002, + "Module": 2, + "ParentId": 100002, + "Title": "字典管理", + "Code": "System", + "NavLinkMatch": "All", + "Category": "MENU", + "Target": "_self", + "Href": "/admin/dict", + "Icon": "fa-solid fa-gear", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 100002003, + "Module": 2, + "ParentId": 100002, + "Title": "操作日志", + "NavLinkMatch": "All", + "Code": "System", + "Category": "MENU", + "Target": "_self", + "Href": "/admin/oplog", + "Icon": "fa-solid fa-edit", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 3, + "ExtJson": null + }, + { + "Id": 100002004, + "Module": 2, + "ParentId": 100002, + "Title": "会话管理", + "NavLinkMatch": "All", + "Code": "System", + "Category": "MENU", + "Target": "_self", + "Href": "/admin/session", + "Icon": "fa-solid fa-person-circle-check", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 4, + "ExtJson": null + }, + { + "Id": 100002005, + "Module": 2, + "ParentId": 100002, + "Title": "硬件信息", + "NavLinkMatch": "All", + "Code": "System", + "Category": "MENU", + "Target": "_self", + "Href": "/admin/hardwareinfo", + "Icon": "fas fa-server", + + "CreateTime": null, + "CreateUser": null, + "CreateUserId": 0, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 4, + "ExtJson": null + } + ] +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_resourcebutton.json b/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_resourcebutton.json new file mode 100644 index 000000000..9ccdbea2b --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/Admin/seed_sys_resourcebutton.json @@ -0,0 +1,845 @@ +{ + "RECORDS": [ + { + "Id": 501949744332869, + "ParentId": 100001001, + "Module": 2, + "Title": "查询", + "Code": "System", + "Category": "Button", + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 502949744332869, + "ParentId": 100001001, + "Module": 2, + "Title": "新增", + "Code": "System", + "Category": "Button", + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 502949744332870, + "ParentId": 100001001, + "Module": 2, + "Title": "编辑", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 2, + "ExtJson": null + }, + { + "Id": 502949744332871, + "ParentId": 100001001, + "Module": 2, + "Title": "删除", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 3, + "ExtJson": null + }, + { + "Id": 502950809124933, + "ParentId": 100001001, + "Module": 2, + "Title": "重置密码", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:52:06.903", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 502950980558917, + "ParentId": 100001001, + "Module": 2, + "Title": "授权角色", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:52:48.757", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 502951076454469, + "ParentId": 100001001, + "Module": 2, + "Title": "授权资源", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:53:12.17", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": "11/1/2024 14:55:44.917", + "UpdateUser": "SuperAdmin", + "UpdateUserId": 212725263002001, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 502951133163589, + "ParentId": 100001001, + "Module": 2, + "Title": "授权Api", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:53:26.017", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": "11/1/2024 15:01:16.98", + "UpdateUser": "SuperAdmin", + "UpdateUserId": 212725263002001, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 513951271813189, + "ParentId": 100001002, + "Module": 2, + "Title": "查询", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:53:59.867", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + + { + "Id": 502951271813189, + "ParentId": 100001002, + "Module": 2, + "Title": "新增", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:53:59.867", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 502951271813190, + "ParentId": 100001002, + "Module": 2, + "Title": "编辑", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:53:59.867", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 2, + "ExtJson": null + }, + { + "Id": 502951271813191, + "ParentId": 100001002, + "Module": 2, + "Title": "删除", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:53:59.867", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 3, + "ExtJson": null + }, + { + "Id": 502951661793349, + "ParentId": 100001002, + "Module": 2, + "Title": "授权资源", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:55:35.077", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 502952928694341, + "ParentId": 100001002, + "Module": 2, + "Title": "授权用户", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:00:44.377", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 502952990781509, + "ParentId": 100001002, + "Module": 2, + "Title": "授权Api", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:00:59.537", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": "11/1/2024 15:01:10.2", + "UpdateUser": "SuperAdmin", + "UpdateUserId": 212725263002001, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 512953216376901, + "ParentId": 100001003, + "Module": 2, + "Title": "查询", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:01:54.617", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + + { + "Id": 502953216376901, + "ParentId": 100001003, + "Module": 2, + "Title": "新增", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:01:54.617", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 502953216376902, + "ParentId": 100001003, + "Module": 2, + "Title": "编辑", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:01:54.617", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 2, + "ExtJson": null + }, + { + "Id": 502953216376903, + "ParentId": 100001003, + "Module": 2, + "Title": "删除", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:01:54.617", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 3, + "ExtJson": null + }, + { + "Id": 512953961062469, + "ParentId": 100002002, + "Module": 2, + "Title": "查询", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:04:56.42", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + + { + "Id": 502953961062469, + "ParentId": 100002002, + "Module": 2, + "Title": "新增", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:04:56.42", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 502953961062470, + "ParentId": 100002002, + "Module": 2, + "Title": "编辑", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:04:56.42", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 2, + "ExtJson": null + }, + { + "Id": 502953961062471, + "ParentId": 100002002, + "Module": 2, + "Title": "删除", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:04:56.42", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 3, + "ExtJson": null + }, + + { + "Id": 532953961062469, + "ParentId": 100002001, + "Module": 2, + "Title": "查询", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:04:56.42", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + + { + "Id": 502954345615429, + "ParentId": 100002001, + "Module": 2, + "Code": "System", + "Title": "密码策略保存", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:06:30.307", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 502954382938181, + "ParentId": 100002001, + "Module": 2, + "Code": "System", + "Title": "登录策略保存", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:06:39.417", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 502954382931181, + "ParentId": 100002001, + "Module": 2, + "Code": "System", + "Title": "页面策略保存", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:06:39.417", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 502954382931281, + "ParentId": 100002001, + "Module": 2, + "Title": "网站策略保存", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:06:39.417", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 505954686193733, + "ParentId": 100002003, + "Module": 2, + "Title": "查询", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:07:53.453", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + + { + "Id": 502954686193733, + "ParentId": 100002003, + "Module": 2, + "Title": "日志清空", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:07:53.453", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 501954811879493, + "ParentId": 100002004, + "Module": 2, + "Title": "查询", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:08:24.14", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 502954811879493, + "ParentId": 100002004, + "Module": 2, + "Title": "会话强退", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 15:08:24.14", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 0, + "ExtJson": null + }, + { + "Id": 401949744332869, + "ParentId": 100001005, + "Module": 2, + "Title": "查询", + "Code": "System", + "Category": "Button", + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 402949744332869, + "ParentId": 100001005, + "Module": 2, + "Title": "新增", + "Code": "System", + "Category": "Button", + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 402949744332870, + "ParentId": 100001005, + "Module": 2, + "Title": "编辑", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 2, + "ExtJson": null + }, + { + "Id": 402949744332871, + "ParentId": 100001005, + "Module": 2, + "Title": "删除", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 3, + "ExtJson": null + }, + + { + "Id": 301949744332869, + "ParentId": 100001004, + "Module": 2, + "Title": "查询", + "Code": "System", + "Category": "Button", + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 302949744332869, + "ParentId": 100001004, + "Module": 2, + "Title": "新增", + "Code": "System", + "Category": "Button", + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 1, + "ExtJson": null + }, + { + "Id": 302949744332870, + "ParentId": 100001004, + "Module": 2, + "Title": "编辑", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 2, + "ExtJson": null + }, + { + "Id": 302949744332871, + "ParentId": 100001004, + "Module": 2, + "Title": "删除", + "Code": "System", + "Category": "Button", + + "Href": null, + "Icon": null, + + "CreateTime": "11/1/2024 14:47:46.973", + "CreateUser": "SuperAdmin", + "CreateUserId": 212725263002001, + "IsDelete": "0", + "UpdateTime": null, + "UpdateUser": null, + "UpdateUserId": null, + "SortCode": 3, + "ExtJson": null + } + ] +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/SysDictSeedData.cs b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysDictSeedData.cs new file mode 100644 index 000000000..ff7d0e794 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysDictSeedData.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 系统配置种子数据 +/// +public class SysDictSeedData : ISqlSugarEntitySeedData +{ + /// + public IEnumerable SeedData() + { + var data = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Admin", "seed_sys_dict.json")); + + var assembly = GetType().Assembly; + return SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Admin.seed_sys_dict.json")).Concat(data); + } + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/SysOrgSeedData.cs b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysOrgSeedData.cs new file mode 100644 index 000000000..3b30b1ae6 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysOrgSeedData.cs @@ -0,0 +1,42 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 职位表种子数据 +/// +public class SysOrgSeedData : ISqlSugarEntitySeedData +{ + /// + public IEnumerable SeedData() + { + var data = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Admin", "seed_sys_org.json")); + + var assembly = GetType().Assembly; + return new List() + { + new SysOrg() + { + Id=RoleConst.DefaultTenantId, + Status=true, + IsDelete=false, + DirectorId=RoleConst.SuperAdminId, + Name="Default", + Code="Diego", + Category=OrgEnum.COMPANY, + CreateUserId=RoleConst.SuperAdminId, + Names="Default", + SortCode=0 + } + }.Concat(SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Admin.seed_sys_org.json")).Concat(data)); + + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/SysPositionSeedData.cs b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysPositionSeedData.cs new file mode 100644 index 000000000..201c2759a --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysPositionSeedData.cs @@ -0,0 +1,41 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 职位表种子数据 +/// +public class SysPositionSeedData : ISqlSugarEntitySeedData +{ + /// + public IEnumerable SeedData() + { + var data = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Admin", "seed_sys_position.json")); + + var assembly = GetType().Assembly; + return new List() + { + new SysPosition() + { + Id=RoleConst.DefaultPositionId, + Status=true, + IsDelete=false, + Name="管理员", + OrgId=RoleConst.DefaultTenantId, + Code="ThingsGateway", + Category=PositionCategoryEnum.HIGH, + CreateUserId=RoleConst.SuperAdminId, + SortCode=0 + } + }.Concat(SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Admin.seed_sys_position.json")).Concat(data)); + + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/SysRelationSeedData.cs b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysRelationSeedData.cs new file mode 100644 index 000000000..0a32706d1 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysRelationSeedData.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 关系表种子数据 +/// +public class SysRelationSeedData : ISqlSugarEntitySeedData +{ + /// + public IEnumerable SeedData() + { + var db = DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); + if (db.Queryable().Any(a => a.ObjectId == RoleConst.SuperAdminId)) + return Enumerable.Empty(); + var data = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Admin", "seed_sys_relation.json")); + var assembly = GetType().Assembly; + return SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Admin.seed_sys_relation.json")).Concat(data); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/SysResourceSeedData.cs b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysResourceSeedData.cs new file mode 100644 index 000000000..e5f9097da --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysResourceSeedData.cs @@ -0,0 +1,36 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 资源表种子数据 +/// +public class SysResourceSeedData : ISqlSugarEntitySeedData +{ + /// + public IEnumerable SeedData() + { + var data1 = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Admin", "seed_sys_resource.json")); + var data2 = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Admin", "seed_sys_resourcebutton.json")); + var data3 = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Admin", "seed_biz_resource.json")); + + var assembly = GetType().Assembly; + return SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Admin.seed_sys_resource.json")) + .Concat(SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Admin.seed_sys_resourcebutton.json"))) + .Concat(SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Admin.seed_biz_resource.json"))) + + .Concat(data1) + .Concat(data2) + .Concat(data3) + ; + + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/SysRoleSeedData.cs b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysRoleSeedData.cs new file mode 100644 index 000000000..53637659f --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysRoleSeedData.cs @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 角色种子数据 +/// +public class SysRoleSeedData : ISqlSugarEntitySeedData +{ + /// + public IEnumerable SeedData() + { + var data = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Admin", "seed_sys_role.json")); + var assembly = GetType().Assembly; + return new List() + { + new SysRole() + { + Id=RoleConst.SuperAdminRoleId, + Code=RoleConst.SuperAdmin, + Name="超级管理员", + Category=RoleCategoryEnum.Global, + DefaultDataScope=new(), + IsDelete=false, + SortCode=0 + } + }.Concat(SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Admin.seed_sys_role.json")).Concat(data)); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SeedData/SysUserSeedData.cs b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysUserSeedData.cs new file mode 100644 index 000000000..b9fb6f92e --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SeedData/SysUserSeedData.cs @@ -0,0 +1,40 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 用户表种子数据 +/// +public class SysUserSeedData : ISqlSugarEntitySeedData +{ + /// + public IEnumerable SeedData() + { + var data = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Admin", "seed_sys_user.json")); + var assembly = GetType().Assembly; + return new List() + { + new SysUser() + { + Id=RoleConst.SuperAdminId, + Account=RoleConst.SuperAdmin, + Password="7DA385A25A98388E", + OrgId=RoleConst.DefaultTenantId, + PositionId=RoleConst.DefaultPositionId, + Status=true, + IsDelete=false, + SortCode=0 + } + }.Concat(SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Admin.seed_sys_user.json"))).Concat(data); + } + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/ApiPermission/ApiPermissionService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/ApiPermission/ApiPermissionService.cs new file mode 100644 index 000000000..7f25f690d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/ApiPermission/ApiPermissionService.cs @@ -0,0 +1,194 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + + +using BootstrapBlazor.Components; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.Extensions.Options; + +using Swashbuckle.AspNetCore.SwaggerGen; + +using System.Globalization; +using System.Reflection; + +using ThingsGateway.Extension; +using ThingsGateway.SpecificationDocument; + +namespace ThingsGateway.Admin.Application; + +internal sealed class ApiPermissionService : IApiPermissionService +{ + private readonly IApiDescriptionGroupCollectionProvider _apiDescriptionGroupCollectionProvider; + private readonly SwaggerGeneratorOptions _generatorOptions; + + public ApiPermissionService( + IOptions generatorOptions, + IApiDescriptionGroupCollectionProvider apiDescriptionGroupCollectionProvider) + { + _generatorOptions = generatorOptions.Value; + _apiDescriptionGroupCollectionProvider = apiDescriptionGroupCollectionProvider; + } + private IEnumerable GetDocumentNames() + { + return _generatorOptions.SwaggerDocs.Keys; + } + + /// + public List ApiPermissionTreeSelector() + { + var cacheKey = $"{nameof(ApiPermissionTreeSelector)}-{CultureInfo.CurrentUICulture.Name}"; + var permissions = App.CacheService.Get>(cacheKey); + if (permissions == null) + { + permissions = new(); + + Dictionary groupOpenApis = new(); + foreach (var item in GetDocumentNames()) + { + OpenApiPermissionTreeSelector openApiPermissionTreeSelector = new() { ApiName = item ?? "Default" }; + groupOpenApis.TryAdd(openApiPermissionTreeSelector.ApiName, openApiPermissionTreeSelector); + } + var apiDescriptions = _apiDescriptionGroupCollectionProvider.ApiDescriptionGroups.Items; + + // 获取所有需要数据权限的控制器 + var controllerTypes = + App.EffectiveTypes.Where(u => !u.IsInterface && !u.IsAbstract && u.IsClass && u.IsDefined(typeof(RolePermissionAttribute), false)); + + foreach (var groupOpenApi in groupOpenApis) + { + + foreach (var apiDescriptionGroup in apiDescriptions) + { + + + var routes = apiDescriptionGroup.Items.Where(api => api.ActionDescriptor is ControllerActionDescriptor); + + OpenApiPermissionTreeSelector openApiPermissionTreeSelector = groupOpenApi.Value; + + Dictionary openApiPermissionTreeSelectorDict = new(); + + foreach (var route in routes) + { + if (!SpecificationDocumentBuilder.CheckApiDescriptionInCurrentGroup(groupOpenApi.Key, route)) + { + continue; + } + + var actionDesc = (ControllerActionDescriptor)route.ActionDescriptor; + if (!actionDesc.ControllerTypeInfo.CustomAttributes.Any(a => a.AttributeType == typeof(RolePermissionAttribute))) + continue; + var controllerDescription = actionDesc.ControllerTypeInfo.GetTypeDisplayName(); + + if (openApiPermissionTreeSelectorDict.TryGetValue(actionDesc.ControllerName, out var openApiControllerGroup)) + { + + } + else + { + openApiControllerGroup = new() { ApiName = controllerDescription, ApiRoute = actionDesc.ControllerName }; + + openApiPermissionTreeSelectorDict.Add(actionDesc.ControllerName, openApiControllerGroup); + } + + var ignoreRolePermission = actionDesc.MethodInfo.CustomAttributes.Any(a => a.AttributeType == typeof(IgnoreRolePermissionAttribute)); + if (ignoreRolePermission) + continue; + var routePath = route.RelativePath; + var methodDesc = actionDesc.MethodInfo.DeclaringType.GetMethodDisplayName(actionDesc.MethodInfo.Name); + + //添加到权限列表 + openApiControllerGroup.Children ??= new(); + openApiControllerGroup.Children.Add(new OpenApiPermissionTreeSelector + { + ApiName = methodDesc, + ApiRoute = routePath, + }); + } + + + openApiPermissionTreeSelector.Children.AddRange(openApiPermissionTreeSelectorDict.Values); + + if (openApiPermissionTreeSelector.Children.Any(a => a.Children.Count > 0)) + permissions.Add(openApiPermissionTreeSelector); + + } + + } + + App.CacheService.Set(cacheKey, permissions); + } + return permissions; + } + + + /// + /// 获取路由地址名称 + /// + /// 控制器地址 + /// 路由名称 + /// + public string GetRouteName(string controllerName, string template) + { + if (!template.StartsWith('/')) + template = "/" + template;//如果路由名称不是/开头则加上/防止控制器没写 + if (template.Contains("[controller]")) + { + controllerName = controllerName.Replace("Controller", "");//去掉Controller + controllerName = controllerName.ToLowerCamelCase();//转首字母小写写 + template = template.Replace("[controller]", controllerName);//替换[controller] + } + + return template; + } + + /// + public IEnumerable PermissionTreeSelector(IEnumerable routes) + { + List permissions = PermissionTreeSelector(); + return permissions.Where(a => routes.ToHashSet().Contains(a.ApiRoute)); + } + + /// + public List PermissionTreeSelector() + { + var cacheKey = $"{nameof(PermissionTreeSelector)}-{CultureInfo.CurrentUICulture.Name}"; + var permissions = App.CacheService.GetOrAdd(cacheKey, entry => + { + List permissions = new();//权限列表 + + // 获取所有需要数据权限的控制器 + var controllerTypes = App.EffectiveTypes.Where(u => !u.IsInterface && !u.IsAbstract && u.IsClass + && u.IsDefined(typeof(AuthorizeAttribute), false) + && u.IsDefined(typeof(Microsoft.AspNetCore.Components.RouteAttribute), false)); + + foreach (var controller in controllerTypes) + { + //获取数据权限特性 + var route = controller.GetCustomAttributes().FirstOrDefault(); + if (route == null) continue; + var apiRoute = GetRouteName(controller.Name, route.Template);//赋值路由名称 + + var desc = controller.GetTypeDisplayName(); + //添加到权限列表 + permissions.Add(new PermissionTreeSelector + { + ApiName = desc, + ApiRoute = apiRoute, + }); + } + return permissions; + }); + + return permissions; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/ApiPermission/IApiPermissionService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/ApiPermission/IApiPermissionService.cs new file mode 100644 index 000000000..c4b1a7209 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/ApiPermission/IApiPermissionService.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + + +using ThingsGateway.Admin.Application; + +public interface IApiPermissionService +{ + /// + /// 获取API树 + /// + /// + public List ApiPermissionTreeSelector(); + string GetRouteName(string controllerName, string template); + List PermissionTreeSelector(); + IEnumerable PermissionTreeSelector(IEnumerable routes); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/AppService/AppService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/AppService/AppService.cs new file mode 100644 index 000000000..184fd64ff --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/AppService/AppService.cs @@ -0,0 +1,77 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; + +using System.Net; +using System.Reflection; +using System.Security.Claims; + +using UAParser; + +namespace ThingsGateway.Admin.Application; + +public class AppService : IAppService +{ + public string GetReturnUrl(string returnUrl) + { + var url = QueryHelpers.AddQueryString(CookieAuthenticationDefaults.LoginPath, new Dictionary + { + ["ReturnUrl"] = returnUrl + }); + return url; + } + + public async Task LoginOutAsync() + { + try + { + await App.HttpContext!.SignOutAsync().ConfigureAwait(false); + App.HttpContext!.SignoutToSwagger(); + } + catch + { + } + } + + public ClientInfo? ClientInfo + { + get + { + var str = App.HttpContext?.Request?.Headers?.UserAgent; + ClientInfo? clientInfo = null; + if (!string.IsNullOrEmpty(str)) + { + clientInfo = Parser.GetDefault().Parse(str); + } + return clientInfo; + } + } + + public async Task LoginAsync(ClaimsIdentity identity) + { + var diffTime = DateTime.MaxValue; + //var diffTime = DateTime.Now.AddMinutes(expire); + await App.HttpContext!.SignInAsync(Assembly.GetEntryAssembly().GetName().Name, new ClaimsPrincipal(identity), new AuthenticationProperties() + { + IsPersistent = true, + AllowRefresh = true, + ExpiresUtc = diffTime, + }).ConfigureAwait(false); + } + public ClaimsPrincipal? User => App.User; + + public IPAddress? RemoteIpAddress => App.HttpContext?.Connection?.RemoteIpAddress; + + public int LocalPort => App.HttpContext.Connection.LocalPort; +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/AppService/IAppService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/AppService/IAppService.cs new file mode 100644 index 000000000..09e577a01 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/AppService/IAppService.cs @@ -0,0 +1,58 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + + +using System.Net; +using System.Security.Claims; + +using UAParser; + +namespace ThingsGateway.Admin.Application; + +public interface IAppService +{ + /// + /// ClientInfo + /// + public ClientInfo? ClientInfo { get; } + + /// + /// ClaimsPrincipal + /// + public ClaimsPrincipal? User { get; } + + /// + /// RemoteIpAddress + /// + public IPAddress? RemoteIpAddress { get; } + + /// + /// GetReturnUrl + /// + /// + /// + public string GetReturnUrl(string returnUrl); + + /// + /// LoginOutAsync + /// + public Task LoginOutAsync(); + + /// + /// LoginAsync + /// + /// + public Task LoginAsync(ClaimsIdentity claimsIdentity); + + + +} + + diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Auth/AuthRazorService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/AuthRazorService.cs new file mode 100644 index 000000000..e94833281 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/AuthRazorService.cs @@ -0,0 +1,71 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using ThingsGateway.NewLife.Json.Extension; +using ThingsGateway.Razor; + + +namespace ThingsGateway.Admin.Application; + +internal sealed class AuthRazorService : IAuthRazorService +{ + + private AjaxService AjaxService { get; set; } + public AuthRazorService(AjaxService ajaxService) + { + AjaxService = ajaxService; + } + /// + /// 用户登录 + /// + public async Task> LoginAsync(LoginInput input) + { + var ajaxOption = new AjaxOption + { + Url = "/api/auth/login", + Method = "POST", + Data = input, + }; + var str = await AjaxService.InvokeAsync(ajaxOption).ConfigureAwait(false); + if (str != null) + { + var ret = str.RootElement.GetRawText().FromJsonNetString>(); + return ret; + } + else + { + throw new ArgumentNullException(); + } + } + + /// + /// 注销当前用户 + /// + public async Task> LoginOutAsync() + { + var ajaxOption = new AjaxOption + { + Url = "/api/auth/logout", + Method = "POST", + }; + using var str = await AjaxService.InvokeAsync(ajaxOption).ConfigureAwait(false); + if (str != null) + { + var ret = str.RootElement.GetRawText().FromJsonNetString>(); + return ret; + } + else + { + throw new ArgumentNullException(); + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Auth/AuthService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/AuthService.cs new file mode 100644 index 000000000..37b2dda28 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/AuthService.cs @@ -0,0 +1,409 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Localization; + +using SqlSugar; + +using System.Security.Claims; + +using ThingsGateway.DataEncryption; +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.Admin.Application; + +public class AuthService : IAuthService +{ + private readonly ISysDictService _configService; + private readonly ISysResourceService _sysResourceService; + private readonly IUserCenterService _userCenterService; + private readonly ISysUserService _sysUserService; + private readonly ISysOrgService _sysOrgService; + private IStringLocalizer _localizer; + private IVerificatInfoService _verificatInfoService; + private IAppService _appService; + + public AuthService(ISysDictService configService, ISysResourceService sysResourceService, + ISysUserService userService, IUserCenterService userCenterService, + ISysOrgService sysOrgService, + IVerificatInfoService verificatInfoService, IAppService appService, + IStringLocalizer localizer) + { + _sysOrgService = sysOrgService; + _configService = configService; + _appService = appService; + _sysUserService = userService; + _sysResourceService = sysResourceService; + _userCenterService = userCenterService; + _localizer = localizer; + _verificatInfoService = verificatInfoService; + } + + /// + /// 登录 + /// + /// 登录参数 + /// cookie方式登录 + /// 登录输出 + public async Task LoginAsync(LoginInput input, bool isCookie = true) + { + var appConfig = await _configService.GetAppConfigAsync().ConfigureAwait(false); + + //判断是否开启web访问 + if (!appConfig.WebsitePolicy.WebStatus + && input.Account != RoleConst.SuperAdmin)//如果禁用了网站并且不是超级管理员 + { + throw Oops.Bah(appConfig.WebsitePolicy.CloseTip); + } + string? password = input.Password; + if (isCookie) //openApi登录不再需要解密 + { + try + { + password = DESEncryption.Decrypt(input.Password);//解密 + } + catch (Exception) + { + throw Oops.Bah(_localizer["MustDesc"]); + } + } + + await BeforeLoginAsync(appConfig, input).ConfigureAwait(false);//登录前校验 + + var userInfo = await _sysUserService.GetUserByAccountAsync(input.Account, input.TenantId).ConfigureAwait(false);//获取用户信息 + if (userInfo == null) + throw Oops.Bah(_localizer["UserNull", input.Account]);//用户不存在 + + if (userInfo.Password != password) + { + LoginError(appConfig.LoginPolicy, input.Account);//登录错误操作 + } + var result = await ExecLogin(appConfig.LoginPolicy, input, userInfo, isCookie).ConfigureAwait(false);// 执行登录 + return result; + } + + /// + /// 注销当前用户 + /// + public async Task LoginOutAsync() + { + if (UserManager.UserId == 0) + return; + var verificatId = UserManager.UserId; + //获取用户信息 + var userinfo = await _sysUserService.GetUserByAccountAsync(UserManager.UserAccount, UserManager.TenantId).ConfigureAwait(false); + if (userinfo != null) + { + var loginEvent = new LoginEvent + { + Ip = _appService.RemoteIpAddress?.MapToIPv4()?.ToString(), + SysUser = userinfo, + VerificatId = verificatId + }; + RemoveTokenFromCache(loginEvent);//移除verificat + } + await _appService.LoginOutAsync().ConfigureAwait(false); + } + + #region 方法 + + /// + /// 登录之前执行的方法 + /// + /// 配置 + /// input + private async Task BeforeLoginAsync(AppConfig appConfig, LoginInput input) + { + var tenantEnable = App.GetOptions()?.Enable ?? false; + + if (tenantEnable) + { + //如果租户ID为空表示用域名登录 + if (input.TenantId == null) + { + //获取域名 + var origin = App.HttpContext.Request.Headers["Origin"].ToString(); + // 如果Origin头不存在,可以尝试使用Referer头作为备选 + if (string.IsNullOrEmpty(origin)) + origin = App.HttpContext.Request.Headers["Referer"].ToString(); + //根据域名获取二级域名 + var domain = origin.Split("//")[1].Split(".")[0]; + //根据二级域名获取租户 + var tenantList = await _sysOrgService.GetTenantListAsync().ConfigureAwait(false); + var tenant = tenantList.FirstOrDefault(x => x.Code.Equals(domain, StringComparison.OrdinalIgnoreCase));//获取租户默认是机构编码 + if (tenant != null) + input.TenantId = tenant.Id; + else + input.TenantId = RoleConst.DefaultTenantId; + } + } + else + { + input.TenantId = RoleConst.DefaultTenantId; + } + + var key = CacheConst.Cache_LoginErrorCount + input.Account + input.TenantId;//获取登录错误次数Key值 + var errorCountCache = App.CacheService.Get(key);//获取登录错误次数 + + if (errorCountCache >= appConfig.LoginPolicy.ErrorCount) + { + App.CacheService.SetExpire(key, TimeSpan.FromMinutes(appConfig.LoginPolicy.ErrorLockTime));//设置缓存 + throw Oops.Bah(_localizer["PasswordError", appConfig.LoginPolicy.ErrorLockTime]); + } + } + + /// + /// 执行登录 + /// + /// 登录策略 + /// 用户登录参数 + /// 用户信息 + /// cookie方式登录 + /// 登录输出结果 + private async Task ExecLogin(LoginPolicy loginPolicy, LoginInput input, SysUser sysUser, bool isCookie = true) + { + if (sysUser.Status == false) + throw Oops.Bah(_localizer["UserDisable", sysUser.Account]);//账号已停用 + + var verificatId = CommonUtils.GetSingleId(); + var expire = loginPolicy.VerificatExpireTime; + string accessToken = string.Empty; + string refreshToken = string.Empty; + if (!isCookie) + { + #region Token + + //生成Token + accessToken = JWTEncryption.Encrypt(new Dictionary + { + { + ClaimConst.UserId, sysUser.Id + }, + { + ClaimConst.Account, sysUser.Account + }, + { + ClaimConst.SuperAdmin, sysUser.RoleIdList.Contains(RoleConst.SuperAdminRoleId) + }, + { + ClaimConst.VerificatId, verificatId + }, + { + ClaimConst.OrgId, sysUser.OrgId + }, + { + ClaimConst.TenantId, input.TenantId + } + }); + // 生成刷新Token令牌 + refreshToken = JWTEncryption.GenerateRefreshToken(accessToken, expire * 2); + // 设置Swagger自动登录 + App.HttpContext?.SigninToSwagger(accessToken); + // 设置响应报文头 + App.HttpContext?.SetTokensOfResponseHeaders(accessToken, refreshToken); + + #endregion Token + } + else + { + if (sysUser.ModuleList.Count == 0) + throw Oops.Bah(_localizer["UserNoModule"]);//未分配模块 + var org = await _sysOrgService.GetSysOrgByIdAsync(sysUser.OrgId).ConfigureAwait(false);//获取机构 + if (!org.Status) throw Oops.Bah(_localizer["OrgDisable"]);//机构冻结 + #region cookie + + var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(ClaimConst.VerificatId, verificatId.ToString())); + identity.AddClaim(new Claim(ClaimConst.UserId, sysUser.Id.ToString())); + identity.AddClaim(new Claim(ClaimConst.Account, sysUser.Account)); + identity.AddClaim(new Claim(ClaimConst.SuperAdmin, sysUser.RoleIdList.Contains(RoleConst.SuperAdminRoleId).ToString())); + identity.AddClaim(new Claim(ClaimConst.OrgId, sysUser.OrgId.ToString())); + identity.AddClaim(new Claim(ClaimConst.TenantId, input.TenantId?.ToString() ?? "0")); + + await _appService.LoginAsync(identity).ConfigureAwait(false); + + #endregion cookie + } + //登录事件参数 + var logingEvent = new LoginEvent + { + Ip = _appService.RemoteIpAddress?.MapToIPv4()?.ToString(), + Device = input.Device?.ToString(), + Expire = expire, + SysUser = sysUser, + VerificatId = verificatId + }; + await WriteTokenToCache(loginPolicy, logingEvent).ConfigureAwait(false);//写入verificat到cache + await UpdateUser(logingEvent).ConfigureAwait(false); + if (sysUser.Account == RoleConst.SuperAdmin) + { + var modules = (await _sysResourceService.GetAllAsync().ConfigureAwait(false)).Where(a => a.Category == ResourceCategoryEnum.Module);//获取模块列表 + sysUser.ModuleList = modules.ToList();//模块列表赋值给用户 + } + //返回结果 + return new LoginOutput + { + VerificatId = verificatId, + Account = sysUser.Account, + Id = sysUser.Id, + ModuleList = sysUser.ModuleList, + AccessToken = accessToken, + RefreshToken = refreshToken + }; + } + + /// + /// 登录错误反馈 + /// + /// 登录策略 + /// 用户名称 + private void LoginError(LoginPolicy loginPolicy, string userName) + { + var key = CacheConst.Cache_LoginErrorCount + userName;//获取登录错误次数Key值 + App.CacheService.Increment(key, 1);// 登录错误次数+1 + App.CacheService.SetExpire(key, TimeSpan.FromMinutes(loginPolicy.ErrorResetTime));//设置过期时间 + var errorCountCache = App.CacheService.Get(key);//获取登录错误次数 + throw Oops.Bah(_localizer["AuthErrorMax", loginPolicy.ErrorCount, loginPolicy.ErrorLockTime, errorCountCache]);//账号密码错误 + } + + /// + /// 从cache删除用户verificat + /// + /// 登录事件参数 + private void RemoveTokenFromCache(LoginEvent loginEvent) + { + //更新verificat列表 + _verificatInfoService.Delete(loginEvent.VerificatId); + } + + /// + /// 单用户登录通知用户下线 + /// + /// 用户Id + private async Task SingleLogin(long userId) + { + var clientIds = _verificatInfoService.GetClientIdListByUserId(userId); + await NoticeUtil.UserLoginOut(new UserLoginOutEvent + { + Message = _localizer["SingleLoginWarn"], + ClientIds = clientIds, + }).ConfigureAwait(false); + } + + /// + /// 登录事件 + /// + /// + /// + private async Task UpdateUser(LoginEvent loginEvent) + { + var sysUser = loginEvent.SysUser; + + #region 登录/密码策略 + + var key = CacheConst.Cache_LoginErrorCount + sysUser.Account;//获取登录错误次数Key值 + App.CacheService.Remove(key);//移除登录错误次数 + + //获取用户verificat列表 + var userToken = _verificatInfoService.GetOne(loginEvent.VerificatId); + + #endregion 登录/密码策略 + + #region 重新赋值属性,设置本次登录信息为最新的信息 + + sysUser.LastLoginIp = sysUser.LatestLoginIp; + sysUser.LastLoginTime = sysUser.LatestLoginTime; + sysUser.LatestLoginIp = loginEvent.Ip; + sysUser.LatestLoginTime = loginEvent.DateTime; + + #endregion 重新赋值属性,设置本次登录信息为最新的信息 + + using var db = DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); + //更新用户登录信息 + if (await db.Updateable(sysUser).UpdateColumns(it => new + { + it.LastLoginIp, + it.LastLoginTime, + it.LatestLoginIp, + it.LatestLoginTime, + }).ExecuteCommandAsync().ConfigureAwait(false) > 0) + App.CacheService.HashAdd(CacheConst.Cache_SysUser, sysUser.Id.ToString(), sysUser);//更新Cache信息 + } + + /// + /// 写入用户verificat到cache + /// + /// 登录策略 + /// 登录事件参数 + private async Task WriteTokenToCache(LoginPolicy loginPolicy, LoginEvent loginEvent) + { + //获取verificat列表 + var tokenTimeout = loginEvent.DateTime.AddMinutes(loginEvent.Expire); + //生成verificat信息 + var verificatInfo = new VerificatInfo + { + Device = loginEvent.Device, + Expire = loginEvent.Expire, + VerificatTimeout = tokenTimeout, + Id = loginEvent.VerificatId, + UserId = loginEvent.SysUser.Id, + LoginIp = loginEvent.Ip, + LoginTime = loginEvent.DateTime + }; + //判断是否单用户登录 + if (loginPolicy.SingleOpen) + { + await SingleLogin(loginEvent.SysUser.Id).ConfigureAwait(false);//单用户登录方法 + } + + //添加到verificat列表 + _verificatInfoService.Add(verificatInfo); + } + + #endregion 方法 +} + +/// +/// 登录事件参数 +/// +public class LoginEvent +{ + /// + /// 时间 + /// + public DateTime DateTime = DateTime.Now; + + /// + /// 过期时间 + /// + public int Expire { get; set; } + + /// + /// Ip地址 + /// + public string? Ip { get; set; } + + /// + /// 用户信息 + /// + public SysUser SysUser { get; set; } + + /// + /// VerificatId + /// + public long VerificatId { get; set; } + + /// + /// 登录设备 + /// + public string Device { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Auth/Dto/AuthInput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/Dto/AuthInput.cs new file mode 100644 index 000000000..1bdd277aa --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/Dto/AuthInput.cs @@ -0,0 +1,72 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; + +/// +/// 登录输入参数 +/// +public class OpenApiLoginInput +{ + /// + /// 账号 + /// + /// SuperAdmin + [Required] + public string Account { get; set; } + + /// + /// 密码 + /// + ///111111 + [Required] + public string Password { get; set; } + + + /// + /// 租户Id + /// + ///252885263003720 + [Required] + public long TenantId { get; set; } +} + +/// +/// 登录输入参数 +/// +public class LoginInput +{ + /// + /// 账号 + /// + /// SuperAdmin + [Required] + public string Account { get; set; } + + /// + /// 密码 + /// + ///111111 + [Required] + public string Password { get; set; } + /// + /// 租户ID + /// + ///252885263003720 + public long? TenantId { get; set; } = RoleConst.DefaultTenantId; + + /// + /// 设备类型,默认PC + /// + /// 0 + public string Device { get; set; } = "PC"; +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Auth/Dto/AuthOutput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/Dto/AuthOutput.cs new file mode 100644 index 000000000..1bade499e --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/Dto/AuthOutput.cs @@ -0,0 +1,72 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public class OpenApiLoginOutput +{ + /// + /// 令牌Token + /// + public string AccessToken { get; set; } + + /// + /// 账号 + /// + public string Account { get; set; } + + /// + /// 用户Id + /// + public long Id { get; set; } + + /// + /// 刷新Token + /// + public string RefreshToken { get; set; } + + /// + /// 验证ID + /// + public long VerificatId { get; set; } +} + +public class LoginOutput +{ + /// + /// 令牌Token + /// + public string AccessToken { get; set; } + + /// + /// 账号 + /// + public string Account { get; set; } + + /// + /// 用户Id + /// + public long Id { get; set; } + + /// + /// 模块列表 + /// + public IEnumerable ModuleList { get; set; } = Enumerable.Empty(); + + /// + /// 刷新Token + /// + public string RefreshToken { get; set; } + + /// + /// 验证ID + /// + public long VerificatId { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Auth/IAuthRazorService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/IAuthRazorService.cs new file mode 100644 index 000000000..768d6a237 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/IAuthRazorService.cs @@ -0,0 +1,26 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Razor; + +namespace ThingsGateway.Admin.Application; + +public interface IAuthRazorService +{ + /// + /// 用户登录 + /// + Task> LoginAsync(LoginInput input); + + /// + /// 注销当前用户 + /// + Task> LoginOutAsync(); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Auth/IAuthService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/IAuthService.cs new file mode 100644 index 000000000..e305d1728 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Auth/IAuthService.cs @@ -0,0 +1,30 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 定义身份验证服务的接口 +/// +public interface IAuthService +{ + /// + /// 用户登录 + /// + /// 登录参数 + /// 是否使用 cookie 登录方式 + /// 登录输出 + Task LoginAsync(LoginInput input, bool isCookie = true); + + /// + /// 注销当前用户 + /// + Task LoginOutAsync(); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/AppConfig.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/AppConfig.cs new file mode 100644 index 000000000..ee3319a20 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/AppConfig.cs @@ -0,0 +1,31 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public class AppConfig +{ + /// + /// 登录策略 + /// + public LoginPolicy LoginPolicy { get; set; } + + /// + /// 页面策略 + /// + public PagePolicy PagePolicy { get; set; } + + /// + /// 密码策略 + /// + public PasswordPolicy PasswordPolicy { get; set; } + + public WebsitePolicy WebsitePolicy { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/LoginPolicy.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/LoginPolicy.cs new file mode 100644 index 000000000..fd4378d1a --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/LoginPolicy.cs @@ -0,0 +1,43 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public class LoginPolicy +{ + /// + /// 登录错误次数锁定阈值 + /// + [MinValue(3)] + public int ErrorCount { get; set; } + + /// + /// 登录错误锁定时间(分) + /// + [MinValue(1)] + public int ErrorLockTime { get; set; } + + /// + /// 登录错误次数过期时间(分) + /// + [MinValue(1)] + public int ErrorResetTime { get; set; } + + /// + /// 单用户登录开关 + /// + public bool SingleOpen { get; set; } + + /// + /// 登录过期时间(分) + /// + [MinValue(1)] + public int VerificatExpireTime { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/PagePolicy.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/PagePolicy.cs new file mode 100644 index 000000000..d0e74e519 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/PagePolicy.cs @@ -0,0 +1,19 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public class PagePolicy +{ + /// + /// 系统默认快捷方式菜单ID列表 + /// + public List Shortcuts { get; set; } = new(); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/PasswordPolicy.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/PasswordPolicy.cs new file mode 100644 index 000000000..8ee7269b8 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/PasswordPolicy.cs @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; + +public class PasswordPolicy +{ + /// + /// 默认用户密码 + /// + [Required] + public string DefaultPassword { get; set; } + + /// + /// 包含特殊字符 + /// + public bool PasswordContainChar { get; set; } + + /// + /// 包含小写字母 + /// + public bool PasswordContainLower { get; set; } + + /// + /// 包含数字 + /// + public bool PasswordContainNum { get; set; } + + /// + /// 包含大写字母 + /// + public bool PasswordContainUpper { get; set; } + + /// + /// 密码最小长度 + /// + [MinValue(1)] + public int PasswordMinLen { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/WebsitePolicy.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/WebsitePolicy.cs new file mode 100644 index 000000000..30c9ad1f2 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/Dto/WebsitePolicy.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; + +public class WebsitePolicy +{ + /// + /// 关闭提示 + /// + [Required] + public string CloseTip { get; set; } + + /// + /// 是否开放 + /// + public bool WebStatus { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Dict/ISysDictService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/ISysDictService.cs new file mode 100644 index 000000000..749705012 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/ISysDictService.cs @@ -0,0 +1,92 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +/// 系统字典服务接口,提供对系统字典的操作 +/// +public interface ISysDictService +{ + /// + /// 删除业务配置 + /// + /// 待删除配置项的ID列表 + /// 是否成功删除 + Task DeleteDictAsync(IEnumerable ids); + + /// + /// 修改登录策略 + /// + /// 登录策略 + Task EditLoginPolicyAsync(LoginPolicy input); + + /// + /// 修改页面策略 + /// + /// 页面策略 + Task EditPagePolicyAsync(PagePolicy input); + + /// + /// 修改密码策略 + /// + /// 密码策略 + Task EditPasswordPolicyAsync(PasswordPolicy input); + + /// + /// 修改网站设置 + /// + /// 网站设置 + Task EditWebsitePolicyAsync(WebsitePolicy input); + + /// + /// 获取系统配置 + /// + /// 系统配置信息 + Task GetAppConfigAsync(); + + /// + /// 根据分类和名称获取系统字典项 + /// + /// 分类 + /// 名称 + /// 系统字典项 + Task GetByKeyAsync(string category, string name); + + + /// + /// 从缓存/数据库获取自定义配置列表 + /// + /// 自定义配置列表 + Task> GetDefineConfigAsync(); + + /// + /// 从缓存/数据库获取系统配置列表 + /// + /// 系统配置列表 + Task> GetSystemConfigAsync(); + + /// + /// 表格查询 + /// + /// 查询选项 + /// 查询结果 + Task> PageAsync(QueryPageOptions option); + + /// + /// 修改业务配置 + /// + /// 配置项 + /// 保存类型 + /// 是否成功保存 + Task SaveDictAsync(SysDict input, ItemChangedType type); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Dict/SysDictService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/SysDictService.cs new file mode 100644 index 000000000..5e0fefe38 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Dict/SysDictService.cs @@ -0,0 +1,336 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using ThingsGateway.FriendlyException; +using ThingsGateway.NewLife.Json.Extension; +using ThingsGateway.Razor; + +namespace ThingsGateway.Admin.Application; + +internal sealed class SysDictService : BaseService, ISysDictService +{ + /// + /// 删除业务配置 + /// + /// id列表 + [OperDesc("DeleteDict")] + public async Task DeleteDictAsync(IEnumerable ids) + { + var result = await base.DeleteAsync(ids).ConfigureAwait(false); + if (result) + RefreshCache(DictTypeEnum.Define); + return result; + } + + /// + /// 修改登录策略 + /// + /// 登录策略 + [OperDesc("EditLoginPolicy")] + public async Task EditLoginPolicyAsync(LoginPolicy input) + { + using var db = GetDB(); + //更新数据 + List dicts = new List() + { + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(LoginPolicy), Name = nameof(LoginPolicy.SingleOpen), Code = input.SingleOpen.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(LoginPolicy), Name = nameof(LoginPolicy.ErrorCount), Code = input.ErrorCount.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(LoginPolicy), Name = nameof(LoginPolicy.ErrorLockTime), Code = input.ErrorLockTime.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(LoginPolicy), Name = nameof(LoginPolicy.ErrorResetTime), Code = input.ErrorResetTime.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(LoginPolicy), Name = nameof(LoginPolicy.VerificatExpireTime), Code = input.VerificatExpireTime.ToString() }, + }; + var storageable = await db.Storageable(dicts).WhereColumns(it => new { it.DictType, it.Category, it.Name }).ToStorageAsync().ConfigureAwait(false); + + //事务 + var result = await db.UseTranAsync(async () => + { + await storageable.AsUpdateable.UpdateColumns(it => new { it.Code }).ExecuteCommandAsync().ConfigureAwait(false); + await storageable.AsInsertable.ExecuteCommandAsync().ConfigureAwait(false); + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + RefreshCache(DictTypeEnum.System);//刷新缓存 + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + + /// + /// 修改页面策略 + /// + /// 页面策略 + [OperDesc("EditPagePolicy")] + public async Task EditPagePolicyAsync(PagePolicy input) + { + using var db = GetDB(); + //更新数据 + List dicts = new List() + { + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(PagePolicy), Name = nameof(PagePolicy.Shortcuts), Code = input.Shortcuts.ToJsonNetString() }, + }; + var storageable = await db.Storageable(dicts).WhereColumns(it => new { it.DictType, it.Category, it.Name }).ToStorageAsync().ConfigureAwait(false); + + //事务 + var result = await db.UseTranAsync(async () => + { + await storageable.AsUpdateable.UpdateColumns(it => new { it.Code }).ExecuteCommandAsync().ConfigureAwait(false); + await storageable.AsInsertable.ExecuteCommandAsync().ConfigureAwait(false); + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + RefreshCache(DictTypeEnum.System);//刷新缓存 + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + + /// + /// 修改密码策略 + /// + /// 密码策略 + [OperDesc("EditPasswordPolicy")] + public async Task EditPasswordPolicyAsync(PasswordPolicy input) + { + using var db = GetDB(); + //更新数据 + List dicts = new List() + { + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(PasswordPolicy), Name = nameof(PasswordPolicy.PasswordContainLower), Code = input.PasswordContainLower.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(PasswordPolicy), Name = nameof(PasswordPolicy.DefaultPassword), Code = input.DefaultPassword.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(PasswordPolicy), Name = nameof(PasswordPolicy.PasswordContainChar), Code = input.PasswordContainChar.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(PasswordPolicy), Name = nameof(PasswordPolicy.PasswordContainUpper), Code = input.PasswordContainUpper.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(PasswordPolicy), Name = nameof(PasswordPolicy.PasswordMinLen), Code = input.PasswordMinLen.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(PasswordPolicy), Name = nameof(PasswordPolicy.PasswordContainNum), Code = input.PasswordContainNum.ToString() }, + }; + var storageable = await db.Storageable(dicts).WhereColumns(it => new { it.DictType, it.Category, it.Name }).ToStorageAsync().ConfigureAwait(false); + + //事务 + var result = await db.UseTranAsync(async () => + { + await storageable.AsUpdateable.UpdateColumns(it => new { it.Code }).ExecuteCommandAsync().ConfigureAwait(false); + await storageable.AsInsertable.ExecuteCommandAsync().ConfigureAwait(false); + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + RefreshCache(DictTypeEnum.System);//刷新缓存 + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + + /// + /// 修改网站设置 + /// + /// + [OperDesc("EditWebsitePolicy")] + public async Task EditWebsitePolicyAsync(WebsitePolicy input) + { + var websiteOptions = App.GetOptions()!; + if (websiteOptions.Demo) + { + throw Oops.Bah(Localizer["DemoCanotUpdateWebsitePolicy"]); + } + + using var db = GetDB(); + //更新数据 + List dicts = new List() + { + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(WebsitePolicy), Name = nameof(WebsitePolicy.WebStatus), Code = input.WebStatus.ToString() }, + new SysDict() { DictType = DictTypeEnum.System, Category = nameof(WebsitePolicy), Name = nameof(WebsitePolicy.CloseTip), Code = input.CloseTip }, + }; + var storageable = await db.Storageable(dicts).WhereColumns(it => new { it.DictType, it.Category, it.Name }).ToStorageAsync().ConfigureAwait(false); + + //事务 + var result = await db.UseTranAsync(async () => + { + await storageable.AsUpdateable.UpdateColumns(it => new { it.Code }).ExecuteCommandAsync().ConfigureAwait(false); + await storageable.AsInsertable.ExecuteCommandAsync().ConfigureAwait(false); + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + RefreshCache(DictTypeEnum.System);//刷新缓存 + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + + /// + /// 获取系统配置 + /// + public async Task GetAppConfigAsync() + { + var key = $"{CacheConst.Cache_SysDict}{DictTypeEnum.System}{nameof(AppConfig)}";//系统配置key + var appConfig = App.CacheService.Get(key); + if (appConfig == null) + { + List sysDicts = await GetSystemConfigAsync().ConfigureAwait(false); + + appConfig = new AppConfig() { LoginPolicy = new(), PasswordPolicy = new(), PagePolicy = new(), WebsitePolicy = new() }; + //登录策略 + appConfig.LoginPolicy.ErrorCount = sysDicts.FirstOrDefault(a => a.Category == nameof(LoginPolicy) && a.Name == nameof(LoginPolicy.ErrorCount))?.Code.ToInt() ?? 3; + appConfig.LoginPolicy.ErrorLockTime = sysDicts.FirstOrDefault(a => a.Category == nameof(LoginPolicy) && a.Name == nameof(LoginPolicy.ErrorLockTime))?.Code.ToInt() ?? 1; + appConfig.LoginPolicy.ErrorResetTime = sysDicts.FirstOrDefault(a => a.Category == nameof(LoginPolicy) && a.Name == nameof(LoginPolicy.ErrorResetTime))?.Code.ToInt() ?? 1; + appConfig.LoginPolicy.SingleOpen = sysDicts.FirstOrDefault(a => a.Category == nameof(LoginPolicy) && a.Name == nameof(LoginPolicy.SingleOpen))?.Code.ToBoolean() ?? false; + appConfig.LoginPolicy.VerificatExpireTime = sysDicts.FirstOrDefault(a => a.Category == nameof(LoginPolicy) && a.Name == nameof(LoginPolicy.VerificatExpireTime))?.Code.ToInt() ?? 14400; + //密码策略 + appConfig.PasswordPolicy.PasswordContainChar = sysDicts.FirstOrDefault(a => a.Category == nameof(PasswordPolicy) && a.Name == nameof(PasswordPolicy.PasswordContainChar))?.Code.ToBoolean() ?? false; + appConfig.PasswordPolicy.PasswordContainNum = sysDicts.FirstOrDefault(a => a.Category == nameof(PasswordPolicy) && a.Name == nameof(PasswordPolicy.PasswordContainNum))?.Code.ToBoolean() ?? false; + appConfig.PasswordPolicy.PasswordContainLower = sysDicts.FirstOrDefault(a => a.Category == nameof(PasswordPolicy) && a.Name == nameof(PasswordPolicy.PasswordContainLower))?.Code.ToBoolean() ?? false; + appConfig.PasswordPolicy.PasswordMinLen = sysDicts.FirstOrDefault(a => a.Category == nameof(PasswordPolicy) && a.Name == nameof(PasswordPolicy.PasswordMinLen))?.Code.ToInt() ?? 6; + appConfig.PasswordPolicy.PasswordContainUpper = sysDicts.FirstOrDefault(a => a.Category == nameof(PasswordPolicy) && a.Name == nameof(PasswordPolicy.PasswordContainUpper))?.Code.ToBoolean() ?? false; + appConfig.PasswordPolicy.DefaultPassword = sysDicts.FirstOrDefault(a => a.Category == nameof(PasswordPolicy) && a.Name == nameof(PasswordPolicy.DefaultPassword))?.Code ?? "111111"; + + //页面策略 + appConfig.PagePolicy.Shortcuts = sysDicts.FirstOrDefault(a => a.Category == nameof(PagePolicy) && a.Name == nameof(PagePolicy.Shortcuts))?.Code.FromJsonNetString>() ?? new List(); + + //网站设置 + appConfig.WebsitePolicy.WebStatus = sysDicts.FirstOrDefault(a => a.Category == nameof(WebsitePolicy) && a.Name == nameof(WebsitePolicy.WebStatus))?.Code.ToBoolean() ?? true; + appConfig.WebsitePolicy.CloseTip = sysDicts.FirstOrDefault(a => a.Category == nameof(WebsitePolicy) && a.Name == nameof(WebsitePolicy.CloseTip))?.Code ?? ""; + App.CacheService.Set(key, appConfig); + } + + return appConfig; + } + + /// + /// 根据分类从缓存/数据库获取列表 + /// + /// 分类 + /// 名称 + /// 配置列表 + public async Task GetByKeyAsync(string category, string name) + { + var key = CacheConst.Cache_SysDict + DictTypeEnum.Define; + var field = $"{category}:sysdict:{name}"; + var sysDict = App.CacheService.HashGetOne(key, field); + if (sysDict == null) + { + using var db = GetDB(); + sysDict = await db.Queryable().FirstAsync(a => a.DictType == DictTypeEnum.Define && a.Category == category && a.Name == name).ConfigureAwait(false); + App.CacheService.HashAdd(key, field, sysDict); + } + return sysDict; + } + + /// + /// 从缓存/数据库获取系统配置列表 + /// + /// + public async Task> GetDefineConfigAsync() + { + var key = $"{CacheConst.Cache_SysDict}{DictTypeEnum.Define}";//系统配置key + var sysDicts = App.CacheService.HashGetAll(key); + if (sysDicts.Count == 0) + { + using var db = GetDB(); + sysDicts = (await db.Queryable().Where(a => a.DictType == DictTypeEnum.Define).ToListAsync().ConfigureAwait(false)).ToDictionary(a => + $"{a.Category}:sysdict:{a.Name}", a => a); + App.CacheService.Set(key, sysDicts); + } + + return sysDicts; + } + + + /// + /// 从缓存/数据库获取系统配置列表 + /// + /// + public async Task> GetSystemConfigAsync() + { + var key = $"{CacheConst.Cache_SysDict}{DictTypeEnum.System}";//系统配置key + var sysDicts = App.CacheService.Get>(key); + if (sysDicts == null) + { + using var db = GetDB(); + sysDicts = await db.Queryable().Where(a => a.DictType == DictTypeEnum.System).ToListAsync().ConfigureAwait(false); + App.CacheService.Set(key, sysDicts); + } + + return sysDicts; + } + + + /// + /// 表格查询 + /// + /// + /// + public Task> PageAsync(QueryPageOptions option) + { + return QueryAsync(option, + a => a.Where(it => it.DictType == DictTypeEnum.Define) + .WhereIF(!option.SearchText.IsNullOrWhiteSpace(), a => a.Category.Contains(option.SearchText!))); + } + + /// + /// 修改业务配置 + /// + /// 配置项 + /// 保存类型 + [OperDesc("SaveDict")] + public async Task SaveDictAsync(SysDict input, ItemChangedType type) + { + await CheckInput(input).ConfigureAwait(false);//检查参数 + var reuslt = await base.SaveAsync(input, type).ConfigureAwait(false); + if (reuslt) + RefreshCache(DictTypeEnum.Define); + + return reuslt; + } + + #region 方法 + + /// + /// 检查输入参数 + /// + /// 配置项 + private async Task CheckInput(SysDict input) + { + + //设置类型为业务 + input.DictType = DictTypeEnum.Define; + + var dict = await GetByKeyAsync(input.Category, input.Name).ConfigureAwait(false);//获取全部字典 + + //判断是否从存在重复 + + if (dict != null && dict.Id != input.Id) + { + throw Oops.Bah(Localizer["DictDup", input.Category, input.Name]); + } + + } + + /// + /// 刷新缓存 + /// + /// 类型 + /// + private void RefreshCache(DictTypeEnum define) + { + App.CacheService.Remove($"{CacheConst.Cache_SysDict}{define}"); + if (define == DictTypeEnum.System) + App.CacheService.Remove($"{CacheConst.Cache_SysDict}{define}{nameof(AppConfig)}"); + } + + + #endregion 方法 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Event/EventService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Event/EventService.cs new file mode 100644 index 000000000..ee224a5cf --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Event/EventService.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace ThingsGateway.Admin.Application; + +/// +/// 内存推送事件服务 +/// +/// +public class EventService : IEventService +{ + private ConcurrentDictionary> Cache { get; } = new(); + + public void Dispose() + { + Cache.Clear(); + } + + /// + public async Task Publish(string key, TEntry payload) + { + if (Cache.TryGetValue(key, out var func)) + { + await func(payload).ConfigureAwait(false); + } + } + + /// + public void Subscribe(string key, Func callback) + { + Cache.TryAdd(key, callback); + } + + /// + public void UnSubscribe(string key) + { + Cache.Remove(key); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Event/IEventService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Event/IEventService.cs new file mode 100644 index 000000000..44409e1c2 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Event/IEventService.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 推送事件服务 +/// +/// +public interface IEventService : IDisposable +{ + /// + /// 发布 + /// + /// + /// + /// + Task Publish(string key, TEntry payload); + /// + /// 订阅 + /// + /// + /// + void Subscribe(string key, Func callback); + /// + /// 取消订阅 + /// + /// + void UnSubscribe(string key); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/File/FileService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/File/FileService.cs new file mode 100644 index 000000000..b6f1a95f7 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/File/FileService.cs @@ -0,0 +1,96 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; + +using System.Text; +using System.Web; + +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.Admin.Application; + +internal sealed class FileService : IFileService +{ + private IStringLocalizer _localizer; + public FileService( + IStringLocalizer localizer) + { + _localizer = localizer; + + } + /// + /// 获取本地存储文件流 + /// + /// 文件夹 + /// 文件名称 + /// 第一个参数是否是包含文件名称的全路径 + /// 文件流 + public FileStreamResult GetFileStreamResult(string path, string fileName, bool isPathFolder = false) + { + if (isPathFolder) path = path.CombinePathWithOs(fileName); + fileName = HttpUtility.UrlEncode(fileName, Encoding.GetEncoding("UTF-8"));//文件名转utf8不然前端下载会乱码 + //文件转流 + var result = new FileStreamResult(new FileStream(path, FileMode.Open), "application/octet-stream") + { + FileDownloadName = fileName + }; + return result; + } + + /// + /// 按字节数组转为文件流 + /// + /// 字节数组 + /// 文件名称 + /// 文件流 + public FileStreamResult GetFileStreamResult(byte[] byteArray, string fileName) + { + fileName = HttpUtility.UrlEncode(fileName, Encoding.GetEncoding("UTF-8"));//文件名转utf8不然前端下载会乱码 + //文件转流 + var result = new FileStreamResult(new MemoryStream(byteArray), "application/octet-stream") + { + FileDownloadName = fileName + }; + return result; + } + + /// + /// 上传文件,保存在磁盘中 + /// + /// 保存路径 + /// 文件 + /// 最终全路径 + public Task UploadFileAsync(IBrowserFile file, string pPath = "imports") + { + return file.StorageLocal(pPath); + } + + /// + /// 验证文件信息 + /// + /// 文件 + /// 最大文件大小 + /// 扩展名称匹配 + public void Verification(IBrowserFile file, int maxSize = 200, string[]? allowTypes = null) + { + if (file == null) throw Oops.Bah(_localizer["FileNullError"]); + if (file.Size > maxSize * 1024 * 1024) throw Oops.Bah(_localizer["FileLengthError", maxSize]); + var fileSuffix = Path.GetExtension(file.Name).ToLower().Split(".")[1]; // 文件后缀 + string[] allowTypeS = allowTypes == null ? ["xlsx"] : allowTypes;//允许上传的文件类型 + if (!allowTypeS.Contains(fileSuffix)) throw Oops.Bah(_localizer["FileTypeError", fileSuffix]); + } + + #region 方法 + + #endregion 方法 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/File/IFileService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/File/IFileService.cs new file mode 100644 index 000000000..44cb968a2 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/File/IFileService.cs @@ -0,0 +1,50 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Mvc; + +namespace ThingsGateway.Admin.Application; + +public interface IFileService +{ + /// + /// 获取本地存储文件流 + /// + /// 文件夹路径 + /// 文件名称 + /// 路径是否包含文件名称 + /// 文件流 + FileStreamResult GetFileStreamResult(string path, string fileName, bool isPathFolder = false); + + /// + /// 按字节数组转为文件流 + /// + /// 字节数组 + /// 文件名称 + /// 文件流 + FileStreamResult GetFileStreamResult(byte[] byteArray, string fileName); + + /// + /// 上传文件,保存在磁盘中 + /// + /// 保存路径 + /// 文件流 + /// 最终全路径 + Task UploadFileAsync(IBrowserFile file, string pPath = "imports"); + + /// + /// 验证文件信息 + /// + /// 文件流 + /// 最大文件大小(单位:MB) + /// 允许上传的文件类型 + void Verification(IBrowserFile file, int maxSize = 200, string[]? allowTypes = null); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/ImportExport/IImportExportService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/ImportExport/IImportExportService.cs new file mode 100644 index 000000000..3ba5d220d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/ImportExport/IImportExportService.cs @@ -0,0 +1,51 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Mvc; + +namespace ThingsGateway.Admin.Application; + +public interface IImportExportService +{ + /// + /// 导出excel文件流 + /// + /// 实体 + /// 实体对象或者IDataReader + /// 文件名称 + /// 动态excel列,根据实体的属性判断是否生成 + /// 导出的文件流 + Task ExportAsync(object input, string fileName, bool isDynamicExcelColumn = true) where T : class; + + /// + /// 创建文件 + /// + /// 实体 + /// 实体对象 + /// 文件名称 + /// 动态excel列,根据实体的属性判断是否生成 + /// 路径 + Task CreateFileAsync(object input, string fileName, bool isDynamicExcelColumn = true) where T : class; + + /// + /// 获取文件名,默认xlsx类型 + /// + /// 文件名称,不含类型名称的话默认xlsx + /// 编码后的文件名 + string GetUrlEncodeFileName(string fileName); + + /// + /// 上传文件 + /// + /// 文件 + /// 保存全路径 + Task UploadFileAsync(IBrowserFile file); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/ImportExport/ImportExportService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/ImportExport/ImportExportService.cs new file mode 100644 index 000000000..58ecb5d33 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/ImportExport/ImportExportService.cs @@ -0,0 +1,101 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Mvc; + +using System.Text; +using System.Web; + +namespace ThingsGateway.Admin.Application; + +internal sealed class ImportExportService : IImportExportService +{ + private readonly IFileService _fileService; + + public ImportExportService(IFileService fileService) + { + _fileService = fileService; + } + + #region 导出 + + /// + /// 导出excel文件流 + /// + /// 实体 + /// 实体对象或者IDataReader + /// 文件名称 + /// 动态excel列,根据实体的属性判断是否生成 + /// + public async Task ExportAsync(object input, string fileName, bool isDynamicExcelColumn = true) where T : class + { + + var path = ImportExportUtil.GetFileDir(ref fileName); + + fileName = CommonUtils.GetSingleId() + fileName; + var filePath = Path.Combine(path, fileName); + using (FileStream fs = new(filePath, FileMode.Create)) + { + await fs.ExportExcel(input, isDynamicExcelColumn).ConfigureAwait(false); + } + var result = _fileService.GetFileStreamResult(filePath, fileName); + return result; + } + + public async Task CreateFileAsync(object input, string fileName, bool isDynamicExcelColumn = true) where T : class + { + var path = ImportExportUtil.GetFileDir(ref fileName); + + fileName = CommonUtils.GetSingleId() + fileName; + var filePath = Path.Combine(path, fileName); + using (FileStream fs = new(filePath, FileMode.Create)) + { + await fs.ExportExcel(input, isDynamicExcelColumn).ConfigureAwait(false); + } + return filePath; + } + + #endregion 导出 + + #region 导入 + + /// + /// 上传文件 + /// + /// 文件 + /// 保存全路径 + public Task UploadFileAsync(IBrowserFile file) + { + _fileService.Verification(file); + return _fileService.UploadFileAsync(file); + } + + #endregion 导入 + + #region 方法 + + /// + /// 获取文件名,默认xlsx类型 + /// + /// 文件名称,不含类型名称的话默认xlsx + /// + public string GetUrlEncodeFileName(string fileName) + { + if (!fileName.Contains('.')) + fileName += ".xlsx"; + fileName = HttpUtility.UrlEncode(fileName, Encoding.GetEncoding("UTF-8"));//文件名转utf8不然前端下载会乱码 + return fileName; + } + + + + #endregion 方法 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Dto/AppMessage.cs b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Dto/AppMessage.cs new file mode 100644 index 000000000..fc1a5335a --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Dto/AppMessage.cs @@ -0,0 +1,29 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Admin.Application; + +/// +/// 消息 +/// +public class AppMessage +{ + /// + /// 消息内容 + /// + public string Data { get; set; } + + /// + /// 消息等级 + /// + public LogLevel LogLevel { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Dto/NavigationUri.cs b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Dto/NavigationUri.cs new file mode 100644 index 000000000..67bf5a878 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Dto/NavigationUri.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public class NavigationUri +{ + public NavigationUri(string navigationUri, string confirmMessage) + { + Uri = navigationUri; + ConfirmMessage = confirmMessage; + } + public string Uri { get; set; } + public string ConfirmMessage { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Dto/UserLoginOut.cs b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Dto/UserLoginOut.cs new file mode 100644 index 000000000..bda55ccfd --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Dto/UserLoginOut.cs @@ -0,0 +1,20 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public class UserLoginOut +{ + public UserLoginOut(string message) + { + Message = message; + } + public string Message { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Email/EmailService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Email/EmailService.cs new file mode 100644 index 000000000..ac7e59b30 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Email/EmailService.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + + + +using MailKit.Net.Smtp; + +using Microsoft.Extensions.Options; + +using MimeKit; + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +using ThingsGateway.Razor; + +namespace ThingsGateway.Admin.Application; + +/// +internal sealed class EmailService : IEmailService +{ + private readonly EmailOptions _emailOptions; + private readonly WebsiteOptions _websiteOptions; + + /// + public EmailService(IOptions emailOptions, IOptions websiteOptions) + { + _emailOptions = emailOptions.Value; + _websiteOptions = websiteOptions.Value; + } + + + /// + [DisplayName("发送邮件")] + public async Task SendEmail([Required] string content, string title = "") + { + var webTitle = _websiteOptions.Title; + title = string.IsNullOrWhiteSpace(title) ? $"{webTitle} 系统邮件" : title; + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(_emailOptions.DefaultFromEmail, _emailOptions.DefaultFromEmail)); + message.To.Add(new MailboxAddress(_emailOptions.DefaultToEmail, _emailOptions.DefaultToEmail)); + message.Subject = title; + message.Body = new TextPart("html") + { + Text = content + }; + + using var client = new SmtpClient(); + await client.ConnectAsync(_emailOptions.Host, _emailOptions.Port, _emailOptions.EnableSsl).ConfigureAwait(false); + await client.AuthenticateAsync(_emailOptions.UserName, _emailOptions.Password).ConfigureAwait(false); + await client.SendAsync(message).ConfigureAwait(false); + await client.DisconnectAsync(true).ConfigureAwait(false); + + await Task.CompletedTask.ConfigureAwait(false); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Email/IEmailService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Email/IEmailService.cs new file mode 100644 index 000000000..82c9d05dd --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Email/IEmailService.cs @@ -0,0 +1,25 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + + + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; +/// +/// 系统邮件发送服务 +/// +public interface IEmailService +{ + /// + /// 发送邮件 + /// + Task SendEmail([Required] string content, string title = ""); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Notice/INoticeService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Notice/INoticeService.cs new file mode 100644 index 000000000..5305ee11b --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Notice/INoticeService.cs @@ -0,0 +1,35 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public interface INoticeService +{ + /// + /// 发送新消息通知给指定用户 + /// + Task NavigationMesage(IEnumerable? clientIds, string uri, string message); + + /// + /// 发送新消息通知给指定用户 + /// + /// 客户端ID列表 + /// 消息内容 + /// 异步操作结果 + Task NewMesage(IEnumerable? clientIds, AppMessage message); + + /// + /// 发送用户下线通知给指定用户 + /// + /// 客户端ID列表 + /// 下线消息内容 + /// 异步操作结果 + Task UserLoginOut(IEnumerable? clientIds, string message); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Notice/NoticeService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Notice/NoticeService.cs new file mode 100644 index 000000000..4a1945b23 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/MessageService/Notice/NoticeService.cs @@ -0,0 +1,68 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; +internal sealed class NoticeService : INoticeService +{ + private IEventService? MessageDispatchService { get; set; } + private IEventService? UserLoginOutDispatchService { get; set; } + private IEventService? NavigationMesageDispatchService { get; set; } + public NoticeService(IEventService signalRMessageDispatchService, + IEventService userLoginOutDispatchService, + IEventService navigationMesageDispatchService + ) + { + MessageDispatchService = signalRMessageDispatchService; + UserLoginOutDispatchService = userLoginOutDispatchService; + NavigationMesageDispatchService = navigationMesageDispatchService; + } + + + /// + public async Task NewMesage(IEnumerable? clientIds, AppMessage message) + { + //发送消息给用户 + if (clientIds != null) + { + foreach (var clientId in clientIds) + { + await MessageDispatchService.Publish(clientId.ToString(), message).ConfigureAwait(false); + } + } + } + + /// + public async Task UserLoginOut(IEnumerable? clientIds, string message) + { + //发送消息给用户 + if (clientIds != null) + { + foreach (var clientId in clientIds) + { + await UserLoginOutDispatchService.Publish(clientId.ToString(), new(message)).ConfigureAwait(false); + } + } + } + + + + /// + public async Task NavigationMesage(IEnumerable? clientIds, string uri, string message) + { + //发送消息给用户 + if (clientIds != null) + { + foreach (var clientId in clientIds) + { + await NavigationMesageDispatchService.Publish(clientId.ToString(), new(uri, message)).ConfigureAwait(false); + } + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/Dto/OperateLogInput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/Dto/OperateLogInput.cs new file mode 100644 index 000000000..8d8c02bf0 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/Dto/OperateLogInput.cs @@ -0,0 +1,55 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using ThingsGateway.Extension.Generic; + +namespace ThingsGateway.Admin.Application; + +/// +/// 操作日志分页输入 +/// +public class OperateLogPageInput : ITableSearchModel +{ + /// + /// 账号 + /// + public string? Account { get; set; } + + /// + /// 分类 + /// + public virtual LogCateGoryEnum? Category { get; set; } + + /// + /// 时间区间 + /// + public DateTimeRangeValue? SearchDate { get; set; } + + /// + public IEnumerable GetSearches() + { + var ret = new List(); + ret.AddIF(!string.IsNullOrEmpty(Account), () => new SearchFilterAction(nameof(SysOperateLog.OpAccount), Account)); + ret.AddIF(Category != null, () => new SearchFilterAction(nameof(SysOperateLog.Category), Category!.Value, FilterAction.Equal)); + ret.AddIF(SearchDate != null, () => new SearchFilterAction(nameof(SysOperateLog.OpTime), SearchDate!.Start, FilterAction.GreaterThanOrEqual)); + ret.AddIF(SearchDate != null, () => new SearchFilterAction(nameof(SysOperateLog.OpTime), SearchDate!.End, FilterAction.LessThanOrEqual)); + return ret; + } + + /// + public void Reset() + { + SearchDate = null; + Category = null; + Account = null; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/Dto/OperateLogOutput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/Dto/OperateLogOutput.cs new file mode 100644 index 000000000..2b72e7a1a --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/Dto/OperateLogOutput.cs @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public class OperateLogIndexOutput +{ + public string Name { get; set; } + public string? OpAccount { get; set; } + public string OpBrowser { get; set; } + public string? OpIp { get; set; } + public DateTime OpTime { get; set; } +} + +public class OperateLogDayStatisticsOutput +{ + /// + /// 日期 + /// + public string Date { get; set; } + + /// + /// 异常次数 + /// + public int ExceptionCount { get; set; } + + /// + /// 登录次数 + /// + public int LoginCount { get; set; } + + /// + /// 登出次数 + /// + public int LogoutCount { get; set; } + + /// + /// 操作次数 + /// + public int OperateCount { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/ISysOperateLogService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/ISysOperateLogService.cs new file mode 100644 index 000000000..a26f259be --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/ISysOperateLogService.cs @@ -0,0 +1,44 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +/// 操作日志服务接口 +/// +public interface ISysOperateLogService +{ + /// + /// 删除指定分类的操作日志 + /// + /// 日志分类 + Task DeleteAsync(LogCateGoryEnum category); + + /// + /// 获取最新的十条日志 + /// + /// 操作人账号 + Task> GetNewLog(string account); + + /// + /// 表格查询 + /// + /// 查询条件 + Task> PageAsync(QueryPageOptions option); + + /// + /// 根据天数统计操作日志信息 + /// + /// 天数 + /// 操作日志统计信息列表 + Task> StatisticsByDayAsync(int day); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/SysOperateLogService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/SysOperateLogService.cs new file mode 100644 index 000000000..cc7c5102c --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/OperateLog/SysOperateLogService.cs @@ -0,0 +1,95 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using System.Data; + +namespace ThingsGateway.Admin.Application; + +internal sealed class SysOperateLogService : BaseService, ISysOperateLogService +{ + + #region 查询 + + /// + /// 最新十条 + /// + /// 操作人 + public async Task> GetNewLog(string account) + { + using var db = GetDB(); + var data = await db.Queryable().Select(a => new OperateLogIndexOutput { OpTime = a.OpTime, Name = a.Name, OpAccount = a.OpAccount, OpBrowser = a.OpBrowser, OpIp = a.OpIp }).Where(a => a.OpAccount == account).OrderByDescending(a => a.OpTime).Take(10).ToListAsync().ConfigureAwait(false); + return data; + } + + /// + /// 表格查询 + /// + /// 查询条件 + public Task> PageAsync(QueryPageOptions option) + { + return QueryAsync(option, query => query.WhereIF(!option.SearchText.IsNullOrWhiteSpace(), a => a.Name.Contains(option.SearchText))); + } + + /// + /// 获取n天的统计信息 + /// + /// 天 + /// 统计信息 + public async Task> StatisticsByDayAsync(int day) + { + using var db = GetDB(); + //取最近七天 + var dayArray = Enumerable.Range(0, day).Select(it => DateTime.Now.Date.AddDays(it * -1)).ToList(); + //生成时间表 + var queryableLeft = db.Reportable(dayArray).ToQueryable(); + //ReportableDateType.MonthsInLast1yea 表式近一年月份 并且queryable之后还能在where过滤 + var queryableRight = db.Queryable(); //声名表 + //报表查询 + var list = await db.Queryable(queryableLeft, queryableRight, JoinType.Left, (x1, x2) + => x2.OpTime.ToString("yyyy-MM-dd") == x1.ColumnName.ToString("yyyy-MM-dd")) + .GroupBy((x1, x2) => x1.ColumnName)//根据时间分组 + .OrderBy((x1, x2) => x1.ColumnName)//根据时间升序排序 + .Select((x1, x2) => new OperateLogDayStatisticsOutput + { + OperateCount = SqlFunc.AggregateSum(SqlFunc.IIF(x2.Category == LogCateGoryEnum.Operate, 1, 0)), //null的数据要为0所以不能用count + ExceptionCount = SqlFunc.AggregateSum(SqlFunc.IIF(x2.Category == LogCateGoryEnum.Exception, 1, 0)), //null的数据要为0所以不能用count + LoginCount = SqlFunc.AggregateSum(SqlFunc.IIF(x2.Category == LogCateGoryEnum.Login, 1, 0)), //null的数据要为0所以不能用count + LogoutCount = SqlFunc.AggregateSum(SqlFunc.IIF(x2.Category == LogCateGoryEnum.Logout, 1, 0)), //null的数据要为0所以不能用count + Date = x1.ColumnName.ToString("yyyy-MM-dd") + } + ).ToListAsync().ConfigureAwait(false); + + return list; + } + + #endregion 查询 + + #region 删除 + + /// + /// 删除日志 + /// + /// 分类 + [OperDesc("DeleteOperLog", isRecordPar: false)] + public async Task DeleteAsync(LogCateGoryEnum category) + { + using var db = GetDB(); + await db.Deleteable(it => it.Category == category).ExecuteCommandAsync().ConfigureAwait(false); + } + + #endregion 删除 + + + +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/IBusinessDeviceHostedService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Org/Dto/OrgInput.cs similarity index 57% rename from src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/IBusinessDeviceHostedService.cs rename to src/Admin/ThingsGateway.Admin.Application/Services/Org/Dto/OrgInput.cs index e670ff717..a527ccba0 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/IBusinessDeviceHostedService.cs +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Org/Dto/OrgInput.cs @@ -8,28 +8,27 @@ // QQ群:605534569 // ------------------------------------------------------------------------------ -using Microsoft.Extensions.Hosting; +namespace ThingsGateway.Admin.Application; -namespace ThingsGateway.Gateway.Application; - -public interface IBusinessDeviceHostedService : IDeviceHostedService, IHostedService +public class SysOrgCopyInput { /// - /// 重启 + /// 目标ID /// - /// 是否重新获取设备 - /// - Task RestartAsync(bool removeDevice = true); + public long TargetId { get; set; } /// - /// 启用业务 + /// 组织Id列表 /// - bool StartBusinessDeviceEnable { get; set; } + public List Ids { get; set; } = new(); /// - /// 停止 + /// 是否包含下级 /// - /// 是否移除设备 - /// - Task StopAsync(bool removeDevice); + public bool ContainsChild { get; set; } = false; + + /// + /// 是否包含职位 + /// + public bool ContainsPosition { get; set; } = false; } diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Org/ISysOrgService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Org/ISysOrgService.cs new file mode 100644 index 000000000..48d7d1f50 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Org/ISysOrgService.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + +/// +/// 机构服务 +/// +public interface ISysOrgService +{ + /// + /// 复制组织 + /// + /// 机构复制参数 + /// + Task CopyAsync(SysOrgCopyInput input); + /// + /// 保存机构 + /// + /// 机构 + /// 保存类型 + Task SaveOrgAsync(SysOrg input, ItemChangedType type); + + /// + /// 删除机构 + /// + /// id列表 + Task DeleteOrgAsync(IEnumerable ids); + + /// + /// 报表查询 + /// + /// 查询条件 + /// 额外条件 + Task> PageAsync(QueryPageOptions option, Func, ISugarQueryable>? queryFunc = null); + + /// + /// 获取全部机构 + /// + /// + Task> GetAllAsync(bool showDisabled = true); + /// + /// 获取机构及下级ID列表 + /// + /// + /// + /// 组织列表 + /// + Task> GetOrgChildIdsAsync(long orgId, bool isContainOneself = true, List sysOrgList = null); + + + /// + /// 根据组织ID获取租户ID + /// + /// 组织id + /// 租户id + /// + Task GetTenantIdByOrgIdAsync(long orgId, List sysOrgList = null); + Task> GetChildListByIdAsync(long orgId, bool isContainOneself = true, List sysOrgList = null); + + /// + /// 获取组织信息 + /// + /// 组织id + /// 组织信息 + Task GetSysOrgByIdAsync(long id); + /// + /// 获取租户列表 + /// + /// + Task> GetTenantListAsync(); + /// + /// 获取机构选择器 + /// + /// + Task> SelectorAsync(); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Org/SysOrgService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Org/SysOrgService.cs new file mode 100644 index 000000000..fb9bc5794 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Org/SysOrgService.cs @@ -0,0 +1,532 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using ThingsGateway.Extension.Generic; +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.Admin.Application; + +internal sealed class SysOrgService : BaseService, ISysOrgService +{ + private ISysUserService _sysUserService; + private ISysUserService SysUserService + { + get + { + if (_sysUserService == null) + { + _sysUserService = App.GetService(); + } + return _sysUserService; + } + } + private IDispatchService _dispatchService; + public SysOrgService(IDispatchService dispatchService) + { + _dispatchService = dispatchService; + } + + [OperDesc("CopyOrg")] + public async Task CopyAsync(SysOrgCopyInput input) + { + var orgList = await GetAllAsync().ConfigureAwait(false);//获取所有 + var positionList = await App.GetService().GetAllAsync().ConfigureAwait(false);//获取所有职位 + var ids = new HashSet();//定义不重复Id集合 + var addOrgList = new List();//添加机构列表 + var addPositionList = new List();//添加职位列表 + var alreadyIds = new HashSet();//定义已经复制过得组织Id + ids.AddRange(input.Ids);//加到集合 + if (ids.Contains(input.TargetId)) + throw Oops.Bah(Localizer["CanotContainsSelf"]); + //获取目标组织 + var target = orgList.Where(it => it.Id == input.TargetId).FirstOrDefault(); + if (target != null || input.TargetId == 0) + { + //需要复制的组织名称列表 + var orgNames = orgList.Where(it => ids.Contains(it.Id)).Select(it => it.Name).ToList(); + //目标组织的一级子组织名称列表 + var targetChildNames = orgList.Where(it => it.ParentId == input.TargetId).Select(it => it.Name).ToList(); + orgNames.ForEach(it => + { + if (targetChildNames.Contains(it)) throw Oops.Bah(Localizer["TargetNameDup", it]); + }); + foreach (var id in input.Ids) + { + var org = orgList.Where(o => o.Id == id).FirstOrDefault();//获取组织 + if (org != null && !alreadyIds.Contains(id)) + { + alreadyIds.Add(id);//添加到已复制列表 + SysOrgService.RedirectOrg(org);//生成新的实体 + org.ParentId = input.TargetId;//父id为目标Id + addOrgList.Add(org); + //是否包含职位 + if (input.ContainsPosition) + { + var positions = positionList.Where(p => p.OrgId == id).ToList();//获取机构下的职位 + positions.ForEach(p => + { + p.OrgId = org.Id;//赋值新的机构ID + p.Id = CommonUtils.GetSingleId();//生成新的ID + p.Code = RandomHelper.CreateRandomString(10);//生成新的Code + addPositionList.Add(p);//添加到职位列表 + }); + } + //是否包含下级 + if (input.ContainsChild) + { + var childIds = await GetOrgChildIdsAsync(id, false).ConfigureAwait(false);//获取下级id列表 + alreadyIds.AddRange(childIds);//添加到已复制id + var childList = orgList.Where(c => childIds.Contains(c.Id)).ToList();//获取下级 + var sysOrgChildren = CopySysOrgChildren(childList, id, org.Id, input.ContainsPosition, + positionList);//赋值下级组织 + addOrgList.AddRange(sysOrgChildren.Item1);//添加到组织列表 + addPositionList.AddRange(sysOrgChildren.Item2);//添加到职位列表 + } + } + } + orgList.AddRange(addOrgList);//要添加的组织添加到组织列表 + //遍历机构重新赋值全称和父Id列表 + addOrgList.ForEach(it => + { + it.Names = it.Name; + if (it.ParentId != 0) + { + var parentIdList = GetNames(orgList, it.ParentId, it.Name, out var names); + it.Names = names; + it.ParentIdList = parentIdList; + } + }); + + using var db = GetDB(); + //事务 + var result = await db.UseTranAsync(async () => + { + await db.Insertable(addOrgList).ExecuteCommandAsync().ConfigureAwait(false);//插入组织 + if (addPositionList.Count > 0) + { + await db.Insertable(addPositionList).ExecuteCommandAsync().ConfigureAwait(false); + } + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + RefreshCache();//刷新缓存 + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + + + } + } + + [OperDesc("DeleteOrg")] + public async Task DeleteOrgAsync(IEnumerable ids) + { + //获取所有ID + if (ids.Any()) + { + using var db = GetDB(); + var sysOrgList = await GetAllAsync().ConfigureAwait(false);//获取所有组织 + var sysDeleteOrgList = new List();//需要删除的组织ID集合 + foreach (var it in ids) + { + var children = SysOrgService.GetSysOrgChildren(sysOrgList, it);//查找下级组织 + sysDeleteOrgList.AddRange(children.Select(it => it.Id).ToList()); + sysDeleteOrgList.Add(it); + } + //如果组织下有用户则不能删除 + if (await db.Queryable().AnyAsync(it => sysDeleteOrgList.Contains(it.OrgId)).ConfigureAwait(false)) + { + throw Oops.Bah(Localizer["DeleteUserFirst"]); + } + //判断组织下是否有角色 + var hasRole = await db.Queryable().Where(it => sysDeleteOrgList.Contains(it.OrgId)).CountAsync().ConfigureAwait(false) > 0; + if (hasRole) + throw Oops.Bah(Localizer["DeleteRoleFirst"]); + // 判断组织下是否有职位 + var hasPosition = await db.Queryable().Where(it => sysDeleteOrgList.Contains(it.OrgId)).CountAsync().ConfigureAwait(false) > 0; + if (hasPosition) + throw Oops.Bah(Localizer["DeletePositionFirst"]); + //删除组织 + var result = await base.DeleteAsync(ids).ConfigureAwait(false); + if (result) + RefreshCache(); + return result; + } + else + { + return false; + } + + } + + + /// + /// 从缓存/数据库获取系统配置列表 + /// + public async Task> GetAllAsync(bool showDisabled = true) + { + var key = $"{CacheConst.Cache_SysOrg}";//系统配置key + var sysOrgs = App.CacheService.Get>(key); + if (sysOrgs == null) + { + using var db = GetDB(); + sysOrgs = (await db.Queryable().ToListAsync().ConfigureAwait(false)); + App.CacheService.Set(key, sysOrgs); + } + if (!showDisabled) + { + sysOrgs = sysOrgs.Where(it => it.Status).ToList(); + } + return sysOrgs; + } + + /// + public async Task GetSysOrgByIdAsync(long id) + { + var sysOrg = await GetAllAsync().ConfigureAwait(false); + var result = sysOrg.FirstOrDefault(it => it.Id == id); + return result; + } + + /// + /// 表格查询 + /// + public async Task> PageAsync(QueryPageOptions option, Func, ISugarQueryable>? queryFunc = null) + { + var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + queryFunc += a => + a.WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.Id))//在指定机构列表查询 + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId); + return await QueryAsync(option, queryFunc).ConfigureAwait(false); + } + + /// + public async Task> SelectorAsync() + { + var sysOrgList = await GetAllAsync().ConfigureAwait(false);//获取所有组织 + var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + sysOrgList = sysOrgList + .WhereIf(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.Id)) + .WhereIf(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ToList();//在指定组织列表查询 + + return sysOrgList; + } + [OperDesc("SaveOrg")] + public async Task SaveOrgAsync(SysOrg input, ItemChangedType type) + { + await CheckInput(input).ConfigureAwait(false);//检查参数 + var reuslt = await base.SaveAsync(input, type).ConfigureAwait(false); + + if (reuslt) + { + if (type == ItemChangedType.Update) + { + if (!input.Status) + { + var orgIds = await GetOrgChildIdsAsync(input.Id, true).ConfigureAwait(false);//获取所有下级 + await ClearTokenUtil.DeleteUserTokenByOrgIds(orgIds).ConfigureAwait(false);//清除用户token + } + } + RefreshCache(); + } + + return reuslt; + } + + + /// + public async Task> GetOrgChildIdsAsync(long orgId, bool isContainOneself = true, List sysOrgList = null) + { + var orgIds = new HashSet();//组织列表 + if (orgId > 0)//如果orgId有值 + { + //获取所有子集 + var childList = await GetChildListByIdAsync(orgId, isContainOneself, sysOrgList).ConfigureAwait(false); + orgIds = childList.Select(x => x.Id).ToHashSet();//提取ID列表 + } + return orgIds; + } + + /// + public async Task> GetChildListByIdAsync(long orgId, bool isContainOneself = true, List sysOrgList = null) + { + //获取所有组织 + sysOrgList ??= await GetAllAsync().ConfigureAwait(false); + //查找下级 + var childList = SysOrgService.GetSysOrgChildren(sysOrgList, orgId); + if (isContainOneself)//如果包含自己 + { + //获取自己的组织信息 + var self = sysOrgList.Where(it => it.Id == orgId).FirstOrDefault(); + if (self != null) childList.Insert(0, self);//如果组织不为空就插到第一个 + } + return childList; + } + + + + /// + public async Task> GetTenantListAsync() + { + var key = $"{CacheConst.Cache_SysTenant}"; + + var tenantList = App.CacheService.Get>(key); + if (tenantList == null) + { + var orgList = await GetAllAsync(false).ConfigureAwait(false); + tenantList = orgList.Where(it => it.Category == OrgEnum.COMPANY).OrderBy(x => x.SortCode).ToList(); + if (tenantList.Count > 0) + { + //插入Redis + App.CacheService.Set(key, tenantList); + } + } + return tenantList; + } + + /// + public async Task GetTenantIdByOrgIdAsync(long orgId, List sysOrgList = null) + { + var key = $"{CacheConst.Cache_SysOrgTenant}"; + //先从缓存拿租户Id + var tenantId = App.CacheService.HashGetOne(key, orgId.ToString()); + if (tenantId == null) + { + //获取所有组织 + sysOrgList ??= await GetAllAsync().ConfigureAwait(false); + var userOrg = sysOrgList.FirstOrDefault(it => it.Id == orgId); + if (userOrg != null) + { + //如果是公司直接返回 + if (userOrg.Category == OrgEnum.COMPANY) + { + tenantId = userOrg.Id; + } + else + { + var parentIds = userOrg.ParentIdList;//获取父级ID列表 + //从最后一个往前遍历,取第一个公司ID为租户ID + for (var i = parentIds.Count - 1; i >= 0; i--) + { + var parentId = parentIds[i]; + var org = sysOrgList.FirstOrDefault(it => it.Id == parentId); + if (org.Category == OrgEnum.COMPANY) + { + tenantId = org.Id;//租户ID + break; + } + } + } + if (tenantId != null) + App.CacheService.HashAdd(key, orgId.ToString(), tenantId);//插入缓存 + } + } + return tenantId; + } + + #region 方法 + /// + /// 赋值组织的所有下级 + /// + /// 组织列表 + /// 父Id + /// 新父Id + /// + /// + /// + private static Tuple, List> CopySysOrgChildren(List orgList, long parentId, long newParentId, bool isCopyPosition, List positions) + { + //找下级组织列表 + var orgInfos = orgList.Where(it => it.ParentId == parentId).ToList(); + if (orgInfos.Count > 0)//如果数量大于0 + { + var result = new Tuple, List>( + new List(), new List() + ); + foreach (var item in orgInfos)//遍历组织 + { + var oldId = item.Id;//获取旧Id + SysOrgService.RedirectOrg(item);//实体重新赋值 + var children = CopySysOrgChildren(orgList, oldId, item.Id, isCopyPosition, + positions);//获取子节点 + item.ParentId = newParentId;//赋值新的父Id + result.Item1.AddRange(children.Item1);//添加下级组织; + if (isCopyPosition)//如果包含职位 + { + var positionList = positions.Where(it => it.OrgId == oldId).ToList();//获取职位列表 + positionList.ForEach(it => + { + it.OrgId = item.Id;//赋值新的机构ID + it.Id = CommonUtils.GetSingleId();//生成新的ID + it.Code = RandomHelper.CreateRandomString(10);//生成新的Code + }); + result.Item2.AddRange(positionList);//添加职位列表 + } + } + return result;//返回结果 + } + return new Tuple, List>( + new List(), new List() + ); + } + + + /// + /// 获取组织所有下级 + /// + /// + /// + /// + private static List GetSysOrgChildren(List orgList, long parentId) + { + //找下级组织ID列表 + var orgInfos = orgList.Where(it => it.ParentId == parentId).ToList(); + if (orgInfos.Count > 0)//如果数量大于0 + { + var data = new List(); + foreach (var item in orgInfos)//遍历组织 + { + var children = SysOrgService.GetSysOrgChildren(orgList, item.Id);//获取子节点 + data.AddRange(children);//添加子节点); + data.Add(item);//添加到列表 + } + return data;//返回结果 + } + return new List(); + } + + + /// + /// 重新生成组织实体 + /// + /// + private static void RedirectOrg(SysOrg org) + { + //重新生成ID并赋值 + var newId = CommonUtils.GetSingleId(); + org.Id = newId; + org.Code = RandomHelper.CreateRandomString(10); + org.CreateTime = DateTime.Now; + org.CreateUser = UserManager.UserAccount; + org.CreateUserId = UserManager.UserId; + } + /// + /// 检查输入参数 + /// + private async Task CheckInput(SysOrg input) + { + + if (!(await SysUserService.GetUserByIdAsync(UserManager.UserId).ConfigureAwait(false)).IsGlobal) + { + if (input.ParentId == 0) + { + throw Oops.Bah(Localizer["RootOrg"]); + } + } + var sysOrgList = await GetAllAsync().ConfigureAwait(false);//获取全部 + if (sysOrgList.Any(it => it.ParentId == input.ParentId && it.Name == input.Name && it.Id != input.Id))//判断同级是否有名称重复的 + throw Oops.Bah(Localizer["NameDup", input.Name]); + input.Names = input.Name;//全称默认自己 + if (input.ParentId != 0) + { + //获取父级,判断父级ID正不正确 + var parent = sysOrgList.Where(it => it.Id == input.ParentId).FirstOrDefault(); + if (parent != null) + { + if (parent.Id == input.Id) + throw Oops.Bah(Localizer["ParentChoiceSelf"]); + } + else + { + throw Oops.Bah(Localizer["ParentNull", input.Id]); + } + var parentIdList = GetNames(sysOrgList, input.ParentId, input.Name, out var names); + input.Names = names; + input.ParentIdList = parentIdList; + } + //如果code没填 + if (string.IsNullOrEmpty(input.Code)) + { + input.Code = RandomHelper.CreateRandomString(10); + } + else + { + //判断是否有相同的Code + if (sysOrgList.Any(it => it.Code == input.Code && it.Id != input.Id)) + throw Oops.Bah(Localizer["CodeDup", input.Code]); + } + } + + + + /// + /// 刷新缓存 + /// + /// + private void RefreshCache() + { + App.CacheService.Remove($"{CacheConst.Cache_SysOrg}"); + App.CacheService.Remove($"{CacheConst.Cache_SysUser}"); + App.CacheService.Remove($"{CacheConst.Cache_SysTenant}"); + App.CacheService.Remove($"{CacheConst.Cache_SysOrgTenant}"); + + _dispatchService.Dispatch(null); + } + + + /// + /// 获取全称 + /// + /// 组织列表 + /// 父Id + /// 组织名称 + /// 组织全称 + /// 组织父Id列表 + private static List GetNames(List sysOrgList, long parentId, string orgName, out string names) + { + names = string.Empty; + //获取父级菜单 + var parents = SysOrgService.GetOrgParents(sysOrgList, parentId); + foreach (var item in parents) + { + names += $"{item.Name}/"; + } + names += orgName;//赋值全称 + var parentIdList = parents.Select(it => it.Id).ToList();//赋值父Id列表 + return parentIdList; + } + + + /// + private static List GetOrgParents(List allOrgList, long orgId, bool includeSelf = true) + { + //找到组织 + var sysOrgList = allOrgList.Where(it => it.Id == orgId).FirstOrDefault(); + if (sysOrgList != null)//如果组织不为空 + { + var data = new List(); + var parents = SysOrgService.GetOrgParents(allOrgList, sysOrgList.ParentId, includeSelf);//递归获取父节点 + data.AddRange(parents);//添加父节点; + if (includeSelf) + data.Add(sysOrgList);//添加到列表 + return data;//返回结果 + } + return new List(); + } + + #endregion 方法 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Pos/Dto/PositionInput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Pos/Dto/PositionInput.cs new file mode 100644 index 000000000..9d1205d04 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Pos/Dto/PositionInput.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + + + + +public class PositionSelectorInput : UserSelectorInput +{ +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/ICollectDeviceHostedService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Pos/Dto/PositionOutput.cs similarity index 50% rename from src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/ICollectDeviceHostedService.cs rename to src/Admin/ThingsGateway.Admin.Application/Services/Pos/Dto/PositionOutput.cs index 7bc17647c..20b4c5149 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/ICollectDeviceHostedService.cs +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Pos/Dto/PositionOutput.cs @@ -8,52 +8,47 @@ // QQ群:605534569 // ------------------------------------------------------------------------------ -using Microsoft.Extensions.Hosting; +namespace ThingsGateway.Admin.Application; -namespace ThingsGateway.Gateway.Application; -public interface ICollectDeviceHostedService : IDeviceHostedService, IHostedService +public class PositionTreeOutput { + /// + /// Id + /// + public long Id { get; set; } /// - /// 启动完成 + /// 名称 /// - event RestartEventHandler Started; - /// - /// 初始化完成 - /// - event RestartEventHandler Starting; - /// - /// 停止完成 - /// - event RestartEventHandler Stoped; - /// - /// 停止前 - /// - event RestartEventHandler Stoping; + public string Name { get; set; } /// - /// 重启 + /// 是否是职位 /// - /// 是否重新获取设备 - /// - Task RestartAsync(bool removeDevice = true); + public bool IsPosition { get; set; } /// - /// 启用采集 + /// 子项 /// - bool StartCollectDeviceEnable { get; set; } - - /// - /// 停止 - /// - /// 是否移除设备 - /// - Task StopAsync(bool removeDevice); - - /// - /// 启动 - /// - /// - Task StartAsync(); + public List Children { get; set; } = new List(); +} + + +public class PositionSelectorOutput +{ + /// + /// 组织Id或者职位Id + /// + public long Id { get; set; } + + /// + /// 名称 + /// + public string Name { get; set; } + + /// + /// 子项 + /// + public List Children { get; set; } = new List(); } diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Pos/ISysPositionService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Pos/ISysPositionService.cs new file mode 100644 index 000000000..fcaa9c075 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Pos/ISysPositionService.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + +/// +/// 职位服务 +/// +public interface ISysPositionService +{ + #region 查询 + + /// + /// 获取职位列表 + /// + /// 职位列表 + Task> GetAllAsync(bool showDisabled = true); + /// + /// 保存岗位 + /// + /// 机构 + /// 保存类型 + Task SavePositionAsync(SysPosition input, ItemChangedType type); + + /// + /// 删除岗位 + /// + /// id列表 + Task DeletePositionAsync(IEnumerable ids); + + /// + /// 报表查询 + /// + /// 查询条件 + /// 额外条件 + Task> PageAsync(QueryPageOptions option, Func, ISugarQueryable>? queryFunc = null); + + /// + /// 获取职位信息 + /// + /// 职位ID + /// 职位信息 + Task GetSysPositionById(long id); + + /// + /// 职位树形结构 + /// + /// + Task> TreeAsync(); + + /// + /// 职位选择器 + /// + /// 查询参数 + /// + Task> SelectorAsync(PositionSelectorInput input); + + + + #endregion + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Pos/SysPositionService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Pos/SysPositionService.cs new file mode 100644 index 000000000..770e1ca61 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Pos/SysPositionService.cs @@ -0,0 +1,289 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.Admin.Application; + +/// +/// 职位服务 +/// +public class SysPositionService : BaseService, ISysPositionService +{ + private ISysUserService _sysUserService; + private ISysUserService SysUserService + { + get + { + if (_sysUserService == null) + { + _sysUserService = App.GetService(); + } + return _sysUserService; + } + } + private readonly ISysOrgService _sysOrgService; + private IDispatchService _dispatchService; + + public SysPositionService(ISysOrgService sysOrgService, IDispatchService dispatchService) + { + _sysOrgService = sysOrgService; + _dispatchService = dispatchService; + } + + public async Task> GetAllAsync(bool showDisabled = true) + { + var key = $"{CacheConst.Cache_SysPosition}";//系统配置key + var sysPositions = App.CacheService.Get>(key); + if (sysPositions == null) + { + using var db = GetDB(); + sysPositions = (await db.Queryable().ToListAsync().ConfigureAwait(false)); + App.CacheService.Set(key, sysPositions); + } + if (!showDisabled) + { + sysPositions = sysPositions.Where(it => it.Status).ToList(); + } + return sysPositions; + } + + public async Task GetSysPositionById(long id) + { + var list = await GetAllAsync().ConfigureAwait(false); + return list.FirstOrDefault(x => x.Id == id); + } + + [OperDesc("DeletePosition")] + public async Task DeletePositionAsync(IEnumerable ids) + { + //获取所有ID + if (ids.Any()) + { + using var db = GetDB(); + //如果组织下有用户则不能删除 + if (await db.Queryable().AnyAsync(it => ids.Contains(it.PositionId.Value)).ConfigureAwait(false)) + { + throw Oops.Bah(Localizer["DeleteUserFirst"]); + } + + var dels = (await GetAllAsync().ConfigureAwait(false)).Where(a => ids.Contains(a.Id)); + await SysUserService.CheckApiDataScopeAsync(dels.Select(a => a.OrgId).ToList(), dels.Select(a => a.CreateUserId).ToList()).ConfigureAwait(false); + //删除职位 + var result = await base.DeleteAsync(ids).ConfigureAwait(false); + if (result) + RefreshCache(); + return result; + } + else + { + return false; + } + + } + + /// + /// 表格查询 + /// + public async Task> PageAsync(QueryPageOptions option, Func, ISugarQueryable>? queryFunc = null) + { + var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); //获取机构ID范围 + queryFunc += a => a + .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.OrgId)) + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId); + + return await QueryAsync(option, queryFunc).ConfigureAwait(false); + } + + [OperDesc("SavePosition")] + public async Task SavePositionAsync(SysPosition input, ItemChangedType type) + { + await CheckInput(input).ConfigureAwait(false);//检查参数 + if (type == ItemChangedType.Update) + await SysUserService.CheckApiDataScopeAsync(input.OrgId, input.CreateUserId).ConfigureAwait(false); + + var reuslt = await base.SaveAsync(input, type).ConfigureAwait(false); + if (reuslt) + RefreshCache(); + + return reuslt; + } + + /// + /// 刷新缓存 + /// + /// + private void RefreshCache() + { + App.CacheService.Remove($"{CacheConst.Cache_SysPosition}"); + _dispatchService.Dispatch(null); + } + + /// + public async Task> TreeAsync() + { + var result = new List();//返回结果 + var sysOrgList = await _sysOrgService.GetAllAsync(false).ConfigureAwait(false);//获取所有组织 + var sysPositions = await GetAllAsync().ConfigureAwait(false);//获取所有职位 + var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + sysOrgList = sysOrgList + .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.Id)) + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ToList();//在指定组织列表查询 + sysPositions = sysPositions + .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.OrgId)) + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ToList();//在指定职位列表查询 + + var posCategory = typeof(PositionCategoryEnum).GetEnumNames();//获取职位分类 + var topOrgList = sysOrgList.Where(it => it.ParentId == 0).ToList();//获取顶级组织 + //遍历顶级组织 + foreach (var org in topOrgList) + { + var childIds = await _sysOrgService.GetOrgChildIdsAsync(org.Id, true, sysOrgList).ConfigureAwait(false);//获取组织下的所有子级ID + var orgPositions = sysPositions.Where(it => childIds.Contains(it.OrgId)).ToList();//获取组织下的职位 + if (orgPositions.Count == 0) continue; + var positionTreeOutput = new PositionTreeOutput + { + Id = org.Id, + Name = org.Name, + IsPosition = false + };//实例化组织树 + //获取组织下的职位职位分类 + foreach (var category in posCategory) + { + var id = CommonUtils.GetSingleId();//生成唯一ID临时用,因为前端需要ID + var categoryTreeOutput = new PositionTreeOutput + { + Id = id, + Name = category, + IsPosition = false + };//实例化职位分类树 + var positions = orgPositions.Where(it => it.Category.ToString() == category).ToList();//获取职位分类下的职位 + //遍历职位,实例化职位树 + positions.ForEach(it => + { + categoryTreeOutput.Children.Add(new PositionTreeOutput() + { + Id = it.Id, + Name = it.Name, + IsPosition = true + });//添加职位 + }); + positionTreeOutput.Children.Add(categoryTreeOutput); + } + result.Add(positionTreeOutput); + } + + return result; + } + + /// + public async Task> SelectorAsync(PositionSelectorInput input) + { + var sysOrgList = await _sysOrgService.GetAllAsync(false).ConfigureAwait(false);//获取所有组织 + var sysPositions = await GetAllAsync().ConfigureAwait(false);//获取所有职位 + var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + sysOrgList = sysOrgList + .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.Id)) + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ToList();//在指定组织列表查询 + sysPositions = sysPositions + .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.OrgId)) + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ToList();//在指定职位列表查询 + + var result = await ConstructPositionSelector(sysOrgList, sysPositions).ConfigureAwait(false);//构造树 + return result; + } + + /// + /// 构建职位选择器 + /// + /// 组织列表 + /// 职位列表 + /// 父Id + /// + public async Task> ConstructPositionSelector(List orgList, List sysPositions, + long parentId = 0) + { + //找下级组织列表 + var orgInfos = orgList.Where(it => it.ParentId == parentId).OrderBy(it => it.SortCode).ToList(); + var data = new List(); + if (orgInfos.Count > 0)//如果数量大于0 + { + foreach (var item in orgInfos)//遍历组织 + { + var childIds = await _sysOrgService.GetOrgChildIdsAsync(item.Id, true, orgList).ConfigureAwait(false);//获取组织下的所有子级ID + var orgPositions = sysPositions.Where(it => childIds.Contains(it.OrgId)).ToList();//获取组织下的职位 + if (orgPositions.Count > 0)//如果组织和组织下级有职位 + { + var positionSelectorOutput = new PositionSelectorOutput + { + Id = item.Id, + Name = item.Name, + Children = await ConstructPositionSelector(orgList, sysPositions, item.Id).ConfigureAwait(false)//递归 + };//实例化职位树 + var positions = orgPositions.Where(it => it.OrgId == item.Id).ToList();//获取组织下的职位 + if (positions.Count > 0)//如果数量大于0 + { + foreach (var position in positions) + { + positionSelectorOutput.Children.Add(new PositionSelectorOutput + { + Id = position.Id, + Name = position.Name + });//添加职位 + } + } + data.Add(positionSelectorOutput);//添加到列表 + } + } + return data;//返回结果 + } + return new List(); + } + + /// + /// 检查输入参数 + /// + /// + private async Task CheckInput(SysPosition input) + { + var sysPositions = await GetAllAsync().ConfigureAwait(false);//获取全部 + if (sysPositions.Any(it => it.OrgId == input.OrgId && it.Name == input.Name && it.Id != input.Id))//判断同级是否有名称重复的 + throw Oops.Bah(Localizer["NameDup", input.Name]); + if (input.Id > 0)//如果ID大于0表示编辑 + { + var position = sysPositions.Where(it => it.Id == input.Id).FirstOrDefault();//获取当前职位 + if (position == null) + throw Oops.Bah(Localizer["SysPositionNull", input.Name]); + } + //如果code没填 + if (string.IsNullOrEmpty(input.Code)) + { + input.Code = RandomHelper.CreateRandomString(10);//赋值Code + } + else + { + //判断是否有相同的Code + if (sysPositions.Any(it => it.Code == input.Code && it.Id != input.Id)) + throw Oops.Bah(Localizer["CodeDup", input.Code]); + } + } + + + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Relation/IRelationService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Relation/IRelationService.cs new file mode 100644 index 000000000..bd2c54104 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Relation/IRelationService.cs @@ -0,0 +1,87 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public interface IRelationService +{ + /// + /// 根据分类获取关系表信息 + /// + /// 分类名称 + /// 关系表 + Task> GetRelationByCategoryAsync(RelationCategoryEnum category); + + /// + /// 通过对象ID和分类获取关系列表 + /// + /// 对象ID + /// 分类 + /// 关系表 + Task> GetRelationListByObjectIdAndCategoryAsync(long objectId, RelationCategoryEnum category); + + /// + /// 通过对象ID列表和分类获取关系列表 + /// + /// 对象ID + /// 分类 + /// 关系表 + Task> GetRelationListByObjectIdListAndCategoryAsync(IEnumerable objectIds, RelationCategoryEnum category); + + /// + /// 通过目标ID和分类获取关系列表 + /// + /// 目标ID + /// 分类 + /// 关系表 + Task> GetRelationListByTargetIdAndCategoryAsync(string targetId, RelationCategoryEnum category); + + /// + /// 通过目标ID列表和分类获取关系列表 + /// + /// + /// + /// 关系表 + Task> GetRelationListByTargetIdListAndCategoryAsync(IEnumerable targetIds, RelationCategoryEnum category); + + /// + /// 获取用户模块ID + /// + /// 角色id列表 + /// 用户id + /// + Task> GetUserModuleId(IEnumerable roleIdList, long userId); + + /// + /// 更新缓存 + /// + /// 分类 + void RefreshCache(RelationCategoryEnum category); + + /// + /// 保存关系 + /// + /// 分类 + /// 对象ID + /// 目标ID + /// 拓展信息 + /// 是否清除老的数据 + /// 是否刷新缓存 + Task SaveRelationAsync(RelationCategoryEnum category, long objectId, string? targetId, string extJson, bool clear, bool refreshCache = true); + + /// + /// 保存关系 + /// + /// 分类 + /// 对象ID + /// 目标ID和拓展信息 + /// 是否清除老的数据 + Task SaveRelationBatchAsync(RelationCategoryEnum category, long objectId, IEnumerable<(string targetId, string extJson)> targetIdAndExtJsons, bool clear); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Relation/RelationService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Relation/RelationService.cs new file mode 100644 index 000000000..e0971a492 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Relation/RelationService.cs @@ -0,0 +1,203 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +internal sealed class RelationService : BaseService, IRelationService +{ + #region 查询 + + /// + /// 根据分类获取关系表信息 + /// + /// 分类名称 + /// 关系表 + public async Task> GetRelationByCategoryAsync(RelationCategoryEnum category) + { + var key = $"{CacheConst.Cache_SysRelation}{category}"; + var sysRelations = App.CacheService.Get>(key); + if (sysRelations == null) + { + using var db = GetDB(); + sysRelations = await db.Queryable().Where(it => it.Category == category).ToListAsync().ConfigureAwait(false); + App.CacheService.Set(key, sysRelations ?? new());//赋值空集合 + } + + return sysRelations; + } + + /// + /// 通过对象ID和分类获取关系列表 + /// + /// 对象ID + /// 分类 + /// 关系表 + public async Task> GetRelationListByObjectIdAndCategoryAsync(long objectId, RelationCategoryEnum category) + { + var sysRelations = await GetRelationByCategoryAsync(category).ConfigureAwait(false); + var result = sysRelations.Where(it => it.ObjectId == objectId);//获取关系集合 + return result; + } + + /// + /// 通过对象ID列表和分类获取关系列表 + /// + /// 对象ID + /// 分类 + /// 关系表 + public async Task> GetRelationListByObjectIdListAndCategoryAsync(IEnumerable objectIds, RelationCategoryEnum category) + { + var sysRelations = await GetRelationByCategoryAsync(category).ConfigureAwait(false); + var result = sysRelations.Where(it => objectIds.Contains(it.ObjectId));//获取关系集合 + return result; + } + + /// + /// 通过目标ID和分类获取关系列表 + /// + /// 目标ID + /// 分类 + /// 关系表 + public async Task> GetRelationListByTargetIdAndCategoryAsync(string targetId, RelationCategoryEnum category) + { + var sysRelations = await GetRelationByCategoryAsync(category).ConfigureAwait(false); + var result = sysRelations.Where(it => it.TargetId == targetId);//获取关系集合 + return result; + } + + /// + /// 通过目标ID列表和分类获取关系列表 + /// + /// + /// + /// 关系表 + public async Task> GetRelationListByTargetIdListAndCategoryAsync(IEnumerable targetIds, RelationCategoryEnum category) + { + var sysRelations = await GetRelationByCategoryAsync(category).ConfigureAwait(false); + var result = sysRelations.Where(it => targetIds.Contains(it.TargetId));//获取关系集合 + return result; + } + + /// + /// 获取用户模块ID + /// + /// 角色id列表 + /// 用户id + /// + public async Task> GetUserModuleId(IEnumerable roleIdList, long userId) + { + IEnumerable? moduleIds = Enumerable.Empty(); + var roleRelation = await GetRelationByCategoryAsync(RelationCategoryEnum.RoleHasModule).ConfigureAwait(false);//获取角色模块关系集合 + if (roleRelation?.Count > 0) + { + moduleIds = roleRelation.Where(it => roleIdList.Contains(it.ObjectId)).Select(it => it.TargetId.ToLong()); + } + var userRelation = await GetRelationByCategoryAsync(RelationCategoryEnum.UserHasModule).ConfigureAwait(false);//获取用户模块关系集合 + var userModuleIds = userRelation.Where(it => it.ObjectId == userId).Select(it => it.TargetId.ToLong()); + if (userModuleIds.Any()) + { + moduleIds = (userModuleIds); + } + return moduleIds; + } + + #endregion 查询 + + #region 保存 + + /// + /// 保存关系 + /// + /// 分类 + /// 对象ID + /// 目标ID + /// 拓展信息 + /// 是否清除老的数据 + /// 是否刷新缓存 + public async Task SaveRelationAsync(RelationCategoryEnum category, long objectId, string? targetId, + string extJson, bool clear, bool refreshCache = true) + { + var sysRelation = new SysRelation + { + ObjectId = objectId, + TargetId = targetId, + Category = category, + ExtJson = extJson + }; + using var db = GetDB(); + //事务 + var result = await db.UseTranAsync(async () => + { + if (clear) + await db.Deleteable().Where(it => it.ObjectId == objectId && it.Category == category).ExecuteCommandAsync().ConfigureAwait(false);//删除老的 + await db.Insertable(sysRelation).ExecuteCommandAsync().ConfigureAwait(false);//添加新的 + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + if (refreshCache) + RefreshCache(category); + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + + /// + /// 保存关系 + /// + /// 分类 + /// 对象ID + /// 目标ID和拓展信息 + /// 是否清除老的数据 + public async Task SaveRelationBatchAsync(RelationCategoryEnum category, long objectId, IEnumerable<(string targetId, string extJson)> targetIdAndExtJsons, bool clear) + { + var sysRelations = targetIdAndExtJsons.Select(a => new SysRelation + { + ObjectId = objectId, + TargetId = a.targetId, + Category = category, + ExtJson = a.extJson + }); + + using var db = GetDB(); + //事务 + var result = await db.UseTranAsync(async () => + { + if (clear) + await db.Deleteable().Where(it => it.ObjectId == objectId && it.Category == category).ExecuteCommandAsync().ConfigureAwait(false);//删除老的 + await db.Insertable(sysRelations.ToList()).ExecuteCommandAsync().ConfigureAwait(false);//添加新的 + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + RefreshCache(category); + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + + #endregion 保存 + + #region 缓存 + + /// + /// 更新缓存 + /// + /// 分类 + public void RefreshCache(RelationCategoryEnum category) + { + var key = $"{CacheConst.Cache_SysRelation}{category}"; + App.CacheService.Remove(key); + } + + #endregion 缓存 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Resource/Dto/ResourceInput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Resource/Dto/ResourceInput.cs new file mode 100644 index 000000000..d054195b5 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Resource/Dto/ResourceInput.cs @@ -0,0 +1,70 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using System.ComponentModel.DataAnnotations; + +using ThingsGateway.Extension.Generic; + +namespace ThingsGateway.Admin.Application; + +public class ResourceTableSearchModel : ITableSearchModel +{ + public string? Href { get; set; } + + /// + /// 模块ID,单独搜索 + /// + [Required] + public long Module { get; set; } + + public virtual string? Title { get; set; } + + /// + public IEnumerable GetSearches() + { + var ret = new List(); + ret.AddIF(!string.IsNullOrEmpty(Href), () => new SearchFilterAction(nameof(SysResource.Href), Href)); + ret.AddIF(!string.IsNullOrEmpty(Title), () => new SearchFilterAction(nameof(SysResource.Title), Title)); + return ret; + } + + /// + public void Reset() + { + Module = ResourceConst.SystemId;//系统管理ModuleID + Href = null; + Title = null; + } +} + +public class GrantResourceInput +{ + public long Id { get; set; } + public string? Href { get; set; } + public int SortCode { get; set; } + + /// + /// 分类 + /// + public ResourceCategoryEnum Category { get; set; } = ResourceCategoryEnum.Menu; + + /// + /// 模块ID,单独搜索 + /// + [Required] + public long Module { get; set; } + + public virtual string? Title { get; set; } + + public DataScopeEnum ScopeCategory { get; set; } + public List ScopeDefineOrgIdList { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Resource/Dto/ResourceOutput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Resource/Dto/ResourceOutput.cs new file mode 100644 index 000000000..8b05eac71 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Resource/Dto/ResourceOutput.cs @@ -0,0 +1,45 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public class PermissionTreeSelector +{ + /// + /// 接口描述 + /// + public string ApiName { get; set; } + + /// + /// 路由名称 + /// + public string ApiRoute { get; set; } +} + +/// +/// Api授权资源树 +/// +public class OpenApiPermissionTreeSelector +{ + /// + /// 接口描述 + /// + public string ApiName { get; set; } + + /// + /// 路由名称 + /// + public string ApiRoute { get; set; } + + /// + /// 子节点 + /// + public List Children { get; set; } = new(); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Resource/ISysResourceService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Resource/ISysResourceService.cs new file mode 100644 index 000000000..b5bb445b7 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Resource/ISysResourceService.cs @@ -0,0 +1,115 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +/// 资源服务接口,定义了资源相关操作的接口方法 +/// +public interface ISysResourceService +{ + /// + /// 更改父级 + /// + /// + /// + /// + Task ChangeParentAsync(long id, long parentMenuId); + + /// + /// 构造树形 + /// + /// 资源列表 + /// 父ID + /// + IEnumerable ConstructMenuTrees(IEnumerable resourceList, long parentId = 0); + + + /// + /// 复制资源到其他模块 + /// + /// + /// + /// + Task CopyAsync(IEnumerable ids, long moduleId); + + /// + /// 删除资源 + /// + /// id列表 + /// + Task DeleteResourceAsync(IEnumerable ids); + + /// + /// 从缓存/数据库读取全部资源列表 + /// + /// 全部资源列表 + Task> GetAllAsync(); + + /// + /// 根据菜单Id获取菜单列表 + /// + /// 菜单id列表 + /// 菜单列表 + Task> GetMenuByMenuIdsAsync(IEnumerable menuIds); + + /// + /// 根据模块Id获取模块列表 + /// + /// 模块id列表 + /// 菜单列表 + Task> GetMuduleByMuduleIdsAsync(IEnumerable moduleIds); + + /// + /// 获取父菜单集合 + /// + /// 所有菜单列表 + /// 我的菜单列表 + /// + IEnumerable GetMyParentResources(IEnumerable allMenuList, IEnumerable myMenus); + + /// + /// 获取资源所有下级,结果不会转为树形 + /// + /// 资源列表 + /// 父Id + /// + IEnumerable GetResourceChilden(IEnumerable resourceList, long parentId); + + /// + /// 获取资源所有父级,结果不会转为树形 + /// + /// 资源列表 + /// Id + /// + IEnumerable GetResourceParent(IEnumerable resourceList, long resourceId); + + /// + /// 表格查询 + /// + /// 查询条件 + /// 查询条件 + /// + Task> PageAsync(QueryPageOptions options, ResourceTableSearchModel searchModel); + + /// + /// 刷新缓存 + /// + void RefreshCache(); + + /// + /// 保存资源 + /// + /// 资源 + /// 保存类型 + Task SaveResourceAsync(SysResource input, ItemChangedType type); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Resource/SysResourceService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Resource/SysResourceService.cs new file mode 100644 index 000000000..ddfcce2c1 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Resource/SysResourceService.cs @@ -0,0 +1,403 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.Extensions.DependencyInjection; + +using SqlSugar; + +using System.Globalization; + +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.Admin.Application; + +internal sealed class SysResourceService : BaseService, ISysResourceService +{ + private readonly IRelationService _relationService; + + private string CacheKey = $"{CacheConst.Cache_SysResource}-{CultureInfo.CurrentUICulture.Name}"; + + public SysResourceService(IRelationService relationService) + { + _relationService = relationService; + } + + #region 增删改查 + + + [OperDesc("CopyResource")] + public async Task CopyAsync(IEnumerable ids, long moduleId) + { + var resourceList = await GetAllAsync().ConfigureAwait(false); + var myResourceList = resourceList.Where(a => ids.Contains(a.Id)).ToList(); + + var parent = GetMyParentResources(resourceList, myResourceList); + myResourceList = myResourceList.Concat(parent).Where(a => a.Category != ResourceCategoryEnum.Module).DistinctBy(a => a.Id).ToList(); + var tree = ConstructMenuTrees(myResourceList).ToList(); + SysResourceService.SetTreeValue(tree, moduleId, 0); + var data = MenuTreesToSaveLevel(tree); + using var db = GetDB(); + var result = await db.Insertable(data).ExecuteCommandAsync().ConfigureAwait(false); + RefreshCache();//刷新缓存 + } + + private static void SetTreeValue(List tree, long moduleId, long parentId) + { + if (tree == null) return; + foreach (var item in tree) + { + item.Id = CommonUtils.GetSingleId(); + item.ParentId = parentId; + item.Code = RandomHelper.CreateRandomString(10); + item.Module = moduleId; + SysResourceService.SetTreeValue(item.Children, moduleId, item.Id); + } + } + + [OperDesc("ChangeParentResource")] + public async Task ChangeParentAsync(long id, long parentMenuId) + { + var resourceList = await GetAllAsync().ConfigureAwait(false); + var resource = resourceList.First(a => a.Id == id); + resource.ParentId = parentMenuId; + using var db = GetDB(); + var result = await db.Updateable(resource).ExecuteCommandAsync().ConfigureAwait(false); + RefreshCache();//刷新缓存 + _relationService.RefreshCache(RelationCategoryEnum.RoleHasResource);//关系表刷新缓存 + _relationService.RefreshCache(RelationCategoryEnum.UserHasResource);//关系表刷新缓存 + } + + /// + /// 删除资源 + /// + /// id列表 + /// + [OperDesc("DeleteResource")] + public async Task DeleteResourceAsync(IEnumerable ids) + { + //删除 + if (ids.Any()) + { + //获取所有菜单和按钮 + var resourceList = await GetAllAsync().ConfigureAwait(false); + //找到要删除的菜单 + var delSysResources = resourceList.Where(it => ids.Contains(it.Id)); + //找到要删除的模块 + var delModules = resourceList.Where(a => a.Category == ResourceCategoryEnum.Module).Where(it => ids.Contains(it.Id)); + if (delModules.Any()) + { + //获取模块下的所有列表 + var delModuleResources = resourceList.Where(it => delModules.Select(a => a.Id).Contains(it.Module)); + delSysResources = delSysResources.Concat(delModuleResources).ToHashSet(); + } + //查找内置菜单 + var system = delSysResources.FirstOrDefault(it => it.Code == ResourceConst.System); + if (system != null) + throw Oops.Bah(Localizer["CanotDeleteSystemResource", system.Title]); + + //需要删除的资源ID列表 + var resourceIds = delSysResources.SelectMany(it => + { + var child = GetResourceChilden(resourceList, it.Id); + return child.Select(c => c.Id).Concat(new List() { it.Id }); + }); + var deleteIds = ids.Concat(resourceIds).ToHashSet();//添加到删除ID列表 + + using var db = GetDB(); + //事务 + var result = await db.UseTranAsync(async () => + { + await db.Deleteable().In(deleteIds.ToList()).ExecuteCommandAsync().ConfigureAwait(false);//删除菜单和按钮 + await db.Deleteable()//关系表删除对应RoleHasResource + .Where(it => it.Category == RelationCategoryEnum.RoleHasResource && resourceIds.Contains(SqlFunc.ToInt64(it.TargetId))).ExecuteCommandAsync().ConfigureAwait(false); + await db.Deleteable()//关系表删除对应UserHasResource + .Where(it => it.Category == RelationCategoryEnum.UserHasResource && resourceIds.Contains(SqlFunc.ToInt64(it.TargetId))).ExecuteCommandAsync().ConfigureAwait(false); + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + RefreshCache();//资源表菜单刷新缓存 + _relationService.RefreshCache(RelationCategoryEnum.RoleHasResource);//关系表刷新缓存 + _relationService.RefreshCache(RelationCategoryEnum.UserHasResource);//关系表刷新缓存 + return true; + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + return false; + } + + /// + /// 从缓存/数据库读取全部资源列表 + /// + /// 全部资源列表 + public async Task> GetAllAsync() + { + var sysResources = App.CacheService.Get>(CacheKey); + if (sysResources == null) + { + using var db = GetDB(); + sysResources = await db.Queryable().ToListAsync().ConfigureAwait(false); + App.CacheService.Set(CacheKey, sysResources); + } + return sysResources; + } + + /// + /// 根据菜单Id获取菜单列表 + /// + /// 菜单id列表 + /// 菜单列表 + public async Task> GetMenuByMenuIdsAsync(IEnumerable menuIds) + { + var menuList = await GetAllAsync().ConfigureAwait(false); + var menus = menuList.Where(it => it.Category == ResourceCategoryEnum.Menu && menuIds.Contains(it.Id)); + return menus; + } + + /// + /// 根据模块Id获取模块列表 + /// + /// 模块id列表 + /// 菜单列表 + public async Task> GetMuduleByMuduleIdsAsync(IEnumerable moduleIds) + { + var moduleList = await GetAllAsync().ConfigureAwait(false); + var modules = moduleList.Where(it => it.Category == ResourceCategoryEnum.Module && moduleIds.Contains(it.Id)); + return modules; + } + + /// + /// 表格查询 + /// + /// 查询条件 + /// 查询条件 + /// + public Task> PageAsync(QueryPageOptions options, ResourceTableSearchModel searchModel) + { + return QueryAsync(options, b => b.Where(a => (a.Category == ResourceCategoryEnum.Module && a.Id == searchModel.Module) || (a.Category != ResourceCategoryEnum.Module && a.Module == searchModel.Module))); + } + + /// + /// 保存资源 + /// + /// 资源 + /// 保存类型 + [OperDesc("SaveResource")] + public async Task SaveResourceAsync(SysResource input, ItemChangedType type) + { + var resource = await CheckInput(input).ConfigureAwait(false);//检查参数 + using var db = GetDB(); + + if (type == ItemChangedType.Add) + { + var result = await db.Insertable(input).ExecuteCommandAsync().ConfigureAwait(false); + RefreshCache();//刷新缓存 + return result > 0; + } + else + { + var permissions = new List(); + if (resource.Href != input.Href) + { + //获取所有角色和用户的权限关系 + var rolePermissions = await _relationService.GetRelationByCategoryAsync(RelationCategoryEnum.RoleHasPermission).ConfigureAwait(false); + var userPermissions = await _relationService.GetRelationByCategoryAsync(RelationCategoryEnum.UserHasPermission).ConfigureAwait(false); + //找到所有匹配的权限 + rolePermissions = rolePermissions.Where(it => it.TargetId!.Contains(resource.Href)).ToList(); + userPermissions = userPermissions.Where(it => it.TargetId!.Contains(resource.Href)).ToList(); + //更新路径 + rolePermissions.ForEach(it => it.TargetId = it.TargetId!.Replace(resource.Href, input.Href)); + userPermissions.ForEach(it => it.TargetId = it.TargetId!.Replace(resource.Href, input.Href)); + //添加到权限列表 + permissions.AddRange(rolePermissions); + permissions.AddRange(userPermissions); + } + //事务 + var result = await db.UseTranAsync(async () => + { + await db.Updateable(input).ExecuteCommandAsync().ConfigureAwait(false);//更新数据 + if (permissions.Count > 0)//如果权限列表大于0就更新 + { + await db.Updateable(permissions).ExecuteCommandAsync().ConfigureAwait(false);//更新关系表 + } + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + RefreshCache();//刷新菜单缓存 + if (resource.Href != input.Href) + { + _relationService.RefreshCache(RelationCategoryEnum.RoleHasPermission); + _relationService.RefreshCache(RelationCategoryEnum.UserHasPermission); + } + return true; + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + } + + #endregion 增删改查 + + #region 缓存 + + /// + /// 刷新缓存 + /// + public void RefreshCache() + { + App.CacheService.Remove(CacheKey); + //删除超级管理员的缓存 + App.RootServices.GetRequiredService().DeleteUserFromCache(RoleConst.SuperAdminId); + } + + #endregion 缓存 + + #region 方法 + + /// + /// 检查输入参数 + /// + /// 资源 + private async Task CheckInput(SysResource sysResource) + { + if (sysResource.Code.IsNullOrWhiteSpace()) //默认编码 + { + sysResource.Code = RandomHelper.CreateRandomString(10); + } + + //如果菜单类型是菜单 + //if (sysResource.Category == ResourceCategoryEnum.Menu) + //{ + // if (string.IsNullOrEmpty(sysResource.Href)) + // throw Oops.Bah("ResourceMenuHrefNotNull"); + //} + + //获取所有列表 + var menList = await GetAllAsync().ConfigureAwait(false); + //判断是否有同级且同名 + if (menList.Any(it => it.ParentId == sysResource.ParentId && it.Title == sysResource.Title && it.Id != sysResource.Id && it.Module == sysResource.Module)) + throw Oops.Bah(Localizer["ResourceDup", sysResource.Title]); + if (sysResource.ParentId != 0) + { + //获取父级,判断父级ID正不正确 + var parent = menList.Where(it => it.Id == sysResource.ParentId).FirstOrDefault(); + if (parent != null) + { + if (parent.Module != sysResource.Module)//如果父级的模块和当前模块不一样 + throw Oops.Bah(Localizer["ModuleIdDiff"]); + if (parent.Id == sysResource.Id) + throw Oops.Bah(Localizer["ResourceChoiceSelf"]); + } + else + { + throw Oops.Bah(Localizer["ResourceParentNull", sysResource.ParentId]); + } + } + + //如果ID大于0表示编辑 + if (sysResource.Id > 0) + { + var resource = menList.FirstOrDefault(it => it.Id == sysResource.Id); + if (resource == null) + throw Oops.Bah(Localizer["NotFoundResource"]); + return resource; + } + + return null; + } + + #endregion 方法 + + + /// + private static List MenuTreesToSaveLevel(IEnumerable resourceList) + { + var flatList = new List(); + + void TraverseTree(SysResource node) + { + // 添加当前节点到平级列表 + flatList.Add(node); + + // 如果当前节点有子节点,则递归处理每个子节点 + if (node.Children != null && node.Children.Count > 0) + { + foreach (var child in node.Children) + { + TraverseTree(child); + } + } + } + + // 遍历资源列表中的每个顶级节点 + foreach (var resource in resourceList) + { + TraverseTree(resource); + } + + return flatList; + } + + + /// + public IEnumerable ConstructMenuTrees(IEnumerable resourceList, long parentId = 0) + { + //找下级资源ID列表 + var resources = resourceList.Where(it => it.ParentId == parentId).OrderBy(it => it.SortCode); + if (resources.Any())//如果数量大于0 + { + foreach (var item in resources)//遍历资源 + { + var children = ConstructMenuTrees(resourceList, item.Id).ToList();//添加子节点 + item.Children = children.Count > 0 ? children : null; + } + } + return resources; + } + + + /// + public IEnumerable GetMyParentResources(IEnumerable allMenuList, IEnumerable myMenus) + { + var parentList = myMenus + .SelectMany(it => GetResourceParent(allMenuList, it.ParentId)) + .Where(parent => parent != null + && !myMenus.Contains(parent) + && !myMenus.Any(m => m.Id == parent.Id)) + .Distinct(); + return parentList; + } + + + /// + public IEnumerable GetResourceChilden(IEnumerable resourceList, long parentId) + { + //找下级资源ID列表 + return resourceList.Where(it => it.ParentId == parentId) + .SelectMany(item => new List { item }.Concat(GetResourceChilden(resourceList, item.Id))); + } + + /// + public IEnumerable GetResourceParent(IEnumerable resourceList, long resourceId) + { + //找上级资源ID列表 + return resourceList.Where(it => it.Id == resourceId) + .SelectMany(item => new List { item }.Concat(GetResourceParent(resourceList, item.ParentId))); + } + + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Role/Dto/RoleInput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Role/Dto/RoleInput.cs new file mode 100644 index 000000000..106d21f7f --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Role/Dto/RoleInput.cs @@ -0,0 +1,72 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public class RelationPermission +{ + /// + /// 接口Url/页面路由 + /// + public string ApiUrl { get; set; } +} + +/// +/// 角色授权API权限 +/// +public class GrantPermissionData +{ + /// + /// 已授权权限信息 + /// + public virtual IEnumerable GrantInfoList { get; set; } = Enumerable.Empty(); + + /// + /// 角色Id/用户Id + /// + public virtual long Id { get; set; } +} + +public class RelationResourcePermission +{ + public long MenuId { get; set; } + public HashSet ButtonIds { get; set; } = new(); +} + +/// +/// 角色授权资源参数 +/// +public class GrantResourceData +{ + /// + /// 授权资源信息 + /// + public IEnumerable GrantInfoList { get; set; } = Enumerable.Empty(); + /// + /// 角色Id + /// + public long Id { get; set; } +} + +/// +/// 角色授权用户参数 +/// +public class GrantUserOrRoleInput +{ + /// + /// 授权权限信息 + /// + public HashSet GrantInfoList { get; set; } = new(); + + /// + /// Id + /// + public long Id { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Role/Dto/RoleOutput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Role/Dto/RoleOutput.cs new file mode 100644 index 000000000..43f51abd4 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Role/Dto/RoleOutput.cs @@ -0,0 +1,38 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + + +/// +/// 角色树输出参数 +/// +public class RoleTreeOutput +{ + /// + /// Id + /// + public long Id { get; set; } + + /// + /// 名称 + /// + public string Name { get; set; } + + /// + /// 角色/机构 + /// + public bool IsRole { get; set; } + + /// + /// 子项 + /// + public List Children { get; set; } = new List(); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Role/ISysRoleService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Role/ISysRoleService.cs new file mode 100644 index 000000000..2e4bd7eb1 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Role/ISysRoleService.cs @@ -0,0 +1,109 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + +/// +/// 角色服务接口 +/// +public interface ISysRoleService +{ + /// + /// 获取角色拥有的OpenApi权限 + /// + /// 角色id + Task ApiOwnPermissionAsync(long id); + + /// + /// 获取角色树 + /// + Task> TreeAsync(); + + /// + /// 删除角色 + /// + /// id列表 + Task DeleteRoleAsync(IEnumerable ids); + + /// + /// 从缓存/数据库获取全部角色信息 + /// + /// 角色列表 + Task> GetAllAsync(); + + /// + /// 根据角色id获取角色列表 + /// + /// 角色id列表 + /// 角色列表 + Task> GetRoleListByIdListAsync(IEnumerable input); + + /// + /// 根据用户id获取角色列表 + /// + /// 用户id + /// 角色列表 + Task> GetRoleListByUserIdAsync(long userId); + + /// + /// 授权OpenApi权限 + /// + /// 授权信息 + Task GrantApiPermissionAsync(GrantPermissionData input); + + /// + /// 授权资源 + /// + /// 授权信息 + Task GrantResourceAsync(GrantResourceData input); + + /// + /// 授权用户 + /// + /// 授权参数 + Task GrantUserAsync(GrantUserOrRoleInput input); + + /// + /// 获取拥有的资源 + /// + /// id + /// 类型 + Task OwnResourceAsync(long id, RelationCategoryEnum category = RelationCategoryEnum.RoleHasResource); + + /// + /// 获取角色的用户id列表 + /// + /// 角色id + /// + Task> OwnUserAsync(long id); + + /// + /// 报表查询 + /// + /// 查询条件 + /// 查询条件 + Task> PageAsync(QueryPageOptions option, Func, ISugarQueryable>? queryFunc = null); + + /// + /// 刷新缓存 + /// + void RefreshCache(); + + /// + /// 保存角色 + /// + /// 角色 + /// 保存类型 + Task SaveRoleAsync(SysRole input, ItemChangedType type); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Role/SysRoleService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Role/SysRoleService.cs new file mode 100644 index 000000000..f19f0346b --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Role/SysRoleService.cs @@ -0,0 +1,530 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using SqlSugar; + +using ThingsGateway.FriendlyException; +using ThingsGateway.NewLife.Json.Extension; + +namespace ThingsGateway.Admin.Application; + +internal sealed class SysRoleService : BaseService, ISysRoleService +{ + private readonly IRelationService _relationService; + private readonly ISysResourceService _sysResourceService; + private readonly ISysOrgService _sysOrgService; + private ISysUserService _sysUserService; + private ISysUserService SysUserService + { + get + { + if (_sysUserService == null) + { + _sysUserService = App.GetService(); + } + return _sysUserService; + } + } + private IDispatchService _dispatchService; + + public SysRoleService(IRelationService relationService, ISysResourceService sysResourceService, ISysOrgService sysOrgService, IDispatchService dispatchService) + { + _relationService = relationService; + _sysResourceService = sysResourceService; + _sysOrgService = sysOrgService; + _dispatchService = dispatchService; + } + + #region 查询 + + /// + public async Task> TreeAsync() + { + var result = new List();//返回结果 + var sysOrgList = await _sysOrgService.GetAllAsync(false).ConfigureAwait(false);//获取所有机构 + var sysRoles = await GetAllAsync().ConfigureAwait(false);//获取所有角色 + var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + sysOrgList = sysOrgList + .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.Id)) + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ToList();//在指定组织列表查询 + sysRoles = sysRoles + .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.OrgId)) + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + + .ToList();//在指定职位列表查询 + + var topOrgList = sysOrgList.Where(it => it.ParentId == 0);//获取顶级机构 + var globalRole = sysRoles.Where(it => it.Category == RoleCategoryEnum.Global);//获取全局角色 + if (globalRole.Any()) + { + result.Add(new RoleTreeOutput() + { + Id = CommonUtils.GetSingleId(), + Name = Localizer["Global"], + Children = globalRole.Select(it => new RoleTreeOutput + { + Id = it.Id, + Name = it.Name, + IsRole = true + }).ToList() + });//添加全局角色 + } + //遍历顶级机构 + foreach (var org in topOrgList) + { + var childIds = await _sysOrgService.GetOrgChildIdsAsync(org.Id, true, sysOrgList).ConfigureAwait(false);//获取机构下的所有子级ID + var childRoles = sysRoles.Where(it => it.OrgId != 0 && childIds.Contains(it.OrgId));//获取机构下的所有角色 + if (childRoles.Any()) + { + var roleTreeOutput = new RoleTreeOutput + { + Id = org.Id, + Name = org.Name, + IsRole = false + };//实例化角色树 + foreach (var it in childRoles) + { + roleTreeOutput.Children.Add(new RoleTreeOutput() + { + Id = it.Id, + Name = it.Name, + IsRole = true + }); + } + result.Add(roleTreeOutput); + } + } + return result; + } + + /// + /// 从缓存/数据库获取全部角色信息 + /// + /// 角色列表 + public async Task> GetAllAsync() + { + var key = CacheConst.Cache_SysRole; + var sysRoles = App.CacheService.Get>(key); + if (sysRoles == null) + { + using var db = GetDB(); + sysRoles = await db.Queryable().ToListAsync().ConfigureAwait(false); + App.CacheService.Set(key, sysRoles); + } + return sysRoles; + } + + /// + /// 根据用户id获取角色列表 + /// + /// 用户id + /// 角色列表 + public async Task> GetRoleListByUserIdAsync(long userId) + { + var roleList = await _relationService.GetRelationListByObjectIdAndCategoryAsync(userId, RelationCategoryEnum.UserHasRole).ConfigureAwait(false);//根据用户ID获取角色ID + var roleIdList = roleList.Select(x => x.TargetId.ToLong());//角色ID列表 + return (await GetAllAsync().ConfigureAwait(false)).Where(it => roleIdList.Contains(it.Id)); + } + + /// + public async Task> PageAsync(QueryPageOptions option, Func, ISugarQueryable>? queryFunc = null) + { + var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); //获取机构ID范围 + queryFunc += a => a + .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.OrgId)) + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId); + + return await QueryAsync(option, queryFunc).ConfigureAwait(false); + } + /// + /// 根据角色id获取角色列表 + /// + /// 角色id列表 + /// 角色列表 + public async Task> GetRoleListByIdListAsync(IEnumerable input) + { + var roles = await GetAllAsync().ConfigureAwait(false); + var roleList = roles.Where(it => input.Contains(it.Id)); + return roleList; + } + #endregion 查询 + + #region 修改 + + /// + /// 删除角色 + /// + /// id列表 + [OperDesc("DeleteRole")] + public async Task DeleteRoleAsync(IEnumerable ids) + { + var sysRoles = await GetAllAsync().ConfigureAwait(false);//获取所有角色 + var hasSuperAdmin = sysRoles.Any(it => it.Id == RoleConst.SuperAdminRoleId && ids.Contains(it.Id));//判断是否有超级管理员 + if (hasSuperAdmin) + throw Oops.Bah(Localizer["CanotDeleteAdmin"]); + + + var dels = (await GetAllAsync().ConfigureAwait(false)).Where(a => ids.Contains(a.Id)); + await SysUserService.CheckApiDataScopeAsync(dels.Select(a => a.OrgId).ToList(), dels.Select(a => a.CreateUserId).ToList()).ConfigureAwait(false); + + //数据库是string所以这里转下 + var targetIds = ids.Select(it => it.ToString()); + //定义删除的关系 + var delRelations = new List { + RelationCategoryEnum.RoleHasResource, + RelationCategoryEnum.RoleHasPermission, + RelationCategoryEnum.RoleHasModule, + RelationCategoryEnum.RoleHasOpenApiPermission }; + using var db = GetDB(); + //事务 + var result = await db.UseTranAsync(async () => + { + await db.Deleteable().In(ids.ToList()).ExecuteCommandHasChangeAsync().ConfigureAwait(false);//删除 + //删除关系表角色与资源关系,角色与权限关系 + await db.Deleteable(it => ids.Contains(it.ObjectId) && delRelations.Contains(it.Category)).ExecuteCommandAsync().ConfigureAwait(false); + //删除关系表角色与用户关系 + await db.Deleteable(it => targetIds.Contains(it.TargetId) && it.Category == RelationCategoryEnum.UserHasRole).ExecuteCommandAsync().ConfigureAwait(false); + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + RefreshCache();//刷新缓存 + _relationService.RefreshCache(RelationCategoryEnum.UserHasRole);//关系表刷新UserHasRole缓存 + _relationService.RefreshCache(RelationCategoryEnum.RoleHasResource);//关系表刷新RoleHasResource缓存 + _relationService.RefreshCache(RelationCategoryEnum.RoleHasPermission);//关系表刷新RoleHasPermission缓存 + _relationService.RefreshCache(RelationCategoryEnum.RoleHasModule);//关系表刷新RoleHasModule缓存 + _relationService.RefreshCache(RelationCategoryEnum.RoleHasOpenApiPermission);//关系表刷新RoleHasOpenApiPermission缓存 + await ClearTokenUtil.DeleteUserCacheByRoleIds(ids).ConfigureAwait(false);//清除角色下用户缓存 + return true; + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + + /// + /// 保存角色 + /// + /// 角色 + /// 保存类型 + [OperDesc("SaveRole")] + public async Task SaveRoleAsync(SysRole input, ItemChangedType type) + { + + await CheckInput(input).ConfigureAwait(false);//检查参数 + + if (type == ItemChangedType.Add) + { + if (!((await SysUserService.GetUserByIdAsync(UserManager.UserId).ConfigureAwait(false)).IsGlobal)) + { + input.Category = RoleCategoryEnum.Org; + } + } + else + { + await SysUserService.CheckApiDataScopeAsync(input.OrgId, input.CreateUserId).ConfigureAwait(false); + } + + if (await base.SaveAsync(input, type).ConfigureAwait(false)) + { + RefreshCache(); + await ClearTokenUtil.DeleteUserCacheByRoleIds(new List { input.Id }).ConfigureAwait(false);//清除角色下用户缓存 + return true; + } + return false; + } + + #endregion 修改 + + #region 授权 + + + + #region 资源 + + /// + /// 获取拥有的资源 + /// + /// id + /// 类型 + public async Task OwnResourceAsync(long id, RelationCategoryEnum category = RelationCategoryEnum.RoleHasResource) + { + var roleOwnResource = new GrantResourceData() { Id = id };//定义结果集 + + //获取关系列表 + var relations = await _relationService.GetRelationListByObjectIdAndCategoryAsync(id, category).ConfigureAwait(false); + roleOwnResource.GrantInfoList = relations.Select(it => (it.ExtJson?.FromJsonNetString())).Where(a => a != null); + return roleOwnResource; + } + /// + /// 授权资源 + /// + /// 授权信息 + [OperDesc("RoleGrantResource")] + public async Task GrantResourceAsync(GrantResourceData input) + { + var isSuperAdmin = input.Id == RoleConst.SuperAdminRoleId;//判断是否有超管 + if (isSuperAdmin) + throw Oops.Bah(Localizer["CanotGrantAdmin"]); + var menuIds = input.GrantInfoList.Select(it => it.MenuId).ToList();//菜单ID + var extJsons = input.GrantInfoList.Select(it => it.ToJsonNetString()).ToList();//拓展信息 + var relationRoles = new List();//要添加的角色资源和授权关系表 + var sysRole = (await GetAllAsync().ConfigureAwait(false)).FirstOrDefault(it => it.Id == input.Id);//获取角色 + + await SysUserService.CheckApiDataScopeAsync(sysRole.OrgId, sysRole.CreateUserId).ConfigureAwait(false); + + if (sysRole != null) + { + var resources = await _sysResourceService.GetAllAsync().ConfigureAwait(false); + var menusList = resources.Where(a => a.Category == ResourceCategoryEnum.Menu).Where(a => menuIds.Contains(a.Id)); + + #region 角色模块处理 + + //获取我的模块信息Id列表 + var moduleIds = menusList.Select(it => it.Module).Distinct(); + foreach (var item in moduleIds) + { + //将角色资源添加到列表 + relationRoles.Add(new SysRelation + { + ObjectId = sysRole.Id, + TargetId = item.ToString(), + Category = RelationCategoryEnum.RoleHasModule + }); + } + + #endregion 角色模块处理 + + #region 角色资源处理 + + //遍历菜单列表 + for (var i = 0; i < menuIds.Count; i++) + { + //将角色资源添加到列表 + relationRoles.Add(new SysRelation + { + ObjectId = sysRole.Id, + TargetId = menuIds[i].ToString(), + Category = RelationCategoryEnum.RoleHasResource, + ExtJson = extJsons?[i] + }); + } + + #endregion 角色资源处理 + + #region 角色权限处理. + var defaultDataScope = sysRole.DefaultDataScope;//获取默认数据范围 + + if (menusList.Any()) + { + //获取权限授权树 + var permissions = App.GetService().PermissionTreeSelector(menusList.Select(it => it.Href)); + //要添加的角色有哪些权限列表 + var relationRolePer = permissions.Select(it => new SysRelation + { + ObjectId = sysRole.Id, + TargetId = it.ApiRoute, + Category = RelationCategoryEnum.RoleHasPermission, + ExtJson = new RelationPermission + { + ApiUrl = it.ApiRoute, + }.ToJsonNetString() + }); + relationRoles.AddRange(relationRolePer);//合并列表 + } + + #endregion 角色权限处理. + + #region 保存数据库 + + using var db = GetDB(); + + //事务 + var result = await db.UseTranAsync(async () => + { + await db.Deleteable(it => + it.ObjectId == sysRole.Id && (it.Category == RelationCategoryEnum.RoleHasPermission || it.Category == RelationCategoryEnum.RoleHasResource + || it.Category == RelationCategoryEnum.RoleHasModule + + )).ExecuteCommandAsync().ConfigureAwait(false); + await db.Insertable(relationRoles).ExecuteCommandAsync().ConfigureAwait(false);//添加新的 + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + _relationService.RefreshCache(RelationCategoryEnum.RoleHasResource);//刷新关系缓存 + _relationService.RefreshCache(RelationCategoryEnum.RoleHasPermission);//刷新关系缓存 + _relationService.RefreshCache(RelationCategoryEnum.RoleHasModule);//关系表刷新 + await ClearTokenUtil.DeleteUserCacheByRoleIds(new List { input.Id }).ConfigureAwait(false);//清除角色下用户缓存 + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + + #endregion 保存数据库 + } + } + #endregion + + #region OPENAPI + + /// + /// 获取角色拥有的OpenApi权限 + /// + /// 角色id + public async Task ApiOwnPermissionAsync(long id) + { + var roleOwnPermission = new GrantPermissionData { Id = id };//定义结果集 + //获取关系列表 + var relations = await _relationService.GetRelationListByObjectIdAndCategoryAsync(id, RelationCategoryEnum.RoleHasOpenApiPermission).ConfigureAwait(false); + + roleOwnPermission.GrantInfoList = relations.Select(it => it.ExtJson?.FromJsonNetString()!).Where(a => a != null); + return roleOwnPermission; + } + + /// + /// 授权OpenApi权限 + /// + /// 授权信息 + [OperDesc("RoleGrantApiPermission")] + public async Task GrantApiPermissionAsync(GrantPermissionData input) + { + var isSuperAdmin = input.Id == RoleConst.SuperAdminRoleId;//判断是否有超管 + if (isSuperAdmin) + throw Oops.Bah(Localizer["CanotGrantAdmin"]); + + var sysRole = (await GetAllAsync().ConfigureAwait(false)).FirstOrDefault(it => it.Id == input.Id);//获取角色 + + await SysUserService.CheckApiDataScopeAsync(sysRole.OrgId, sysRole.CreateUserId).ConfigureAwait(false); + + if (sysRole != null) + { + await _relationService.SaveRelationBatchAsync(RelationCategoryEnum.RoleHasOpenApiPermission, input.Id, + input.GrantInfoList.Select(a => (a.ApiUrl, a.ToJsonNetString())) + , true).ConfigureAwait(false);//添加到数据库 + await ClearTokenUtil.DeleteUserCacheByRoleIds(new List { input.Id }).ConfigureAwait(false);//清除角色下用户缓存 + } + } + + #endregion OPENAPI + + #region 用户 + + /// + public async Task> OwnUserAsync(long id) + { + //获取关系列表 + var relations = await _relationService.GetRelationListByTargetIdAndCategoryAsync(id.ToString(), RelationCategoryEnum.UserHasRole).ConfigureAwait(false); + return relations.Select(it => it.ObjectId); + } + + + /// + /// 授权用户 + /// + /// 授权参数 + [OperDesc("RoleGrantUser")] + public async Task GrantUserAsync(GrantUserOrRoleInput input) + { + var isSuperAdmin = input.Id == RoleConst.SuperAdminRoleId;//判断是否有超管 + if (isSuperAdmin) + throw Oops.Bah(Localizer["CanotGrantAdmin"]); + + var sysRole = (await GetAllAsync().ConfigureAwait(false)).FirstOrDefault(a => a.Id == input.Id); + await SysUserService.CheckApiDataScopeAsync(sysRole.OrgId, sysRole.CreateUserId).ConfigureAwait(false); + + var sysRelations = input.GrantInfoList.Select(it => + new SysRelation() + { + ObjectId = it, + TargetId = input.Id.ToString(), + Category = RelationCategoryEnum.UserHasRole + } + ); + using var db = GetDB(); + + //事务 + var result = await db.UseTranAsync(async () => + { + var targetId = input.Id.ToString(); + await db.Deleteable(it => it.TargetId == targetId && it.Category == RelationCategoryEnum.UserHasRole).ExecuteCommandAsync().ConfigureAwait(false);//删除老的 + await db.Insertable(sysRelations.ToList()).ExecuteCommandAsync().ConfigureAwait(false);//添加新的 + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + _relationService.RefreshCache(RelationCategoryEnum.UserHasRole);//刷新关系表UserHasRole缓存 + await ClearTokenUtil.DeleteUserCacheByRoleIds(new List { input.Id }).ConfigureAwait(false);//清除角色下用户缓存 + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + + + #endregion + + /// + public void RefreshCache() + { + App.CacheService.Remove(CacheConst.Cache_SysRole);//删除KEY + + _dispatchService.Dispatch(null); + + } + + #endregion 授权 + + #region 方法 + + /// + /// 检查输入参数 + /// + /// + private async Task CheckInput(SysRole sysRole) + { + if (sysRole.Id == RoleConst.SuperAdminRoleId) + throw Oops.Bah(Localizer["CanotEditAdmin"]); + if (sysRole.Category == RoleCategoryEnum.Org && sysRole.OrgId == 0) + throw Oops.Bah(Localizer["OrgNotNull"]); + + if (sysRole.Category == RoleCategoryEnum.Global)//如果是全局 + sysRole.OrgId = 0;//机构id设0 + + var sysRoles = await GetAllAsync().ConfigureAwait(false);//获取所有 + var repeatName = sysRoles.Any(it => it.OrgId == sysRole.OrgId && it.Name == sysRole.Name && it.Id != sysRole.Id);//是否有重复角色名称 + if (repeatName)//如果有 + { + if (sysRole.OrgId == 0) + throw Oops.Bah(Localizer["SameOrgNameDup", sysRole.Name]); + throw Oops.Bah(Localizer["NameDup", sysRole.Name]); + } + + if (!((await GetRoleListByUserIdAsync(UserManager.UserId).ConfigureAwait(false)).Any(a => a.Category == RoleCategoryEnum.Global)) && sysRole.DefaultDataScope.ScopeCategory == DataScopeEnum.SCOPE_ALL) + throw Oops.Bah(Localizer["CannotRoleScopeAll"]); + + //如果code没填 + if (string.IsNullOrEmpty(sysRole.Code)) + { + sysRole.Code = RandomHelper.CreateRandomString(10);//赋值Code + } + //判断是否有相同的Code + if (sysRoles.Any(it => it.Code == sysRole.Code && it.Id != sysRole.Id)) + throw Oops.Bah(Localizer["CodeDup", sysRole.Code]); + + + } + + + #endregion 方法 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Session/Dto/SessionInput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Session/Dto/SessionInput.cs new file mode 100644 index 000000000..f1e6a69bf --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Session/Dto/SessionInput.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// Token退出参数 +/// +public class ExitVerificatInput +{ + /// + /// 用户id + /// + public long Id { get; set; } + + /// + /// verificat + /// + public IEnumerable VerificatIds { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Session/Dto/SessionOutput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Session/Dto/SessionOutput.cs new file mode 100644 index 000000000..b57400a81 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Session/Dto/SessionOutput.cs @@ -0,0 +1,58 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +/// 会话输出 +/// +public class SessionOutput : PrimaryIdEntity +{ + /// + /// 主键Id + /// + public override long Id { get; set; } + + /// + /// 账号 + /// + [AutoGenerateColumn(Filterable = true, Sortable = true)] + public virtual string Account { get; set; } + + /// + /// 在线状态 + /// + public bool Online { get; set; } + + /// + /// 最新登录ip + /// + [AutoGenerateColumn(Filterable = true, Sortable = true)] + public string LatestLoginIp { get; set; } + + /// + /// 最新登录时间 + /// + [AutoGenerateColumn(Filterable = true, Sortable = true)] + public DateTime? LatestLoginTime { get; set; } + + /// + /// 令牌数量 + /// + public int VerificatCount { get; set; } + + /// + /// 令牌信息集合 + /// + [AutoGenerateColumn(Ignore = true)] + public List VerificatSignList { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Session/ISessionService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Session/ISessionService.cs new file mode 100644 index 000000000..4694d9238 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Session/ISessionService.cs @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +public interface ISessionService +{ + /// + /// 强制退出用户会话 + /// + /// 用户ID + /// 异步操作结果 + Task ExitSession(long userId); + + /// + /// 强制退出用户令牌 + /// + /// 参数 + /// 异步操作结果 + Task ExitVerificat(ExitVerificatInput input); + + /// + /// 异步分页查询会话信息 + /// + /// 查询条件 + /// 查询结果 + Task> PageAsync(QueryPageOptions option); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/Session/SessionService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/Session/SessionService.cs new file mode 100644 index 000000000..160345d30 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/Session/SessionService.cs @@ -0,0 +1,209 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Mapster; + +using SqlSugar; + +using ThingsGateway.Extension; + +namespace ThingsGateway.Admin.Application; + +internal sealed class SessionService : BaseService, ISessionService +{ + private readonly IVerificatInfoService _verificatInfoService; + private ISysUserService _sysUserService; + private ISysUserService SysUserService + { + get + { + if (_sysUserService == null) + { + _sysUserService = App.GetService(); + } + return _sysUserService; + } + } + public SessionService(IVerificatInfoService verificatInfoService) + { + _verificatInfoService = verificatInfoService; + } + + #region 查询 + + /// + /// 表格查询 + /// + /// 查询条件 + public async Task> PageAsync(QueryPageOptions option) + { + var ret = new QueryData() + { + IsSorted = option.SortOrder != SortOrder.Unset, + IsFiltered = option.Filters.Count > 0, + IsAdvanceSearch = option.AdvanceSearches.Count > 0 || option.CustomerSearches.Count > 0, + IsSearch = option.Searches.Count > 0 + }; + var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + + using var db = GetDB(); + var query = db.GetQuery(option) + .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.OrgId))//在指定机构列表查询 + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .WhereIF(!option.SearchText.IsNullOrWhiteSpace(), a => a.Account.Contains(option.SearchText!)); + + if (option.IsPage) + { + RefAsync totalCount = 0; + + var items = await query.ToPageListAsync(option.PageIndex, option.PageItems, totalCount).ConfigureAwait(false); + + var verificatInfoDicts = _verificatInfoService.GetListByUserIds(items.Select(a => a.Id).ToList()).GroupBy(a => a.UserId).ToDictionary(a => a.Key, a => a.ToList()); + + var r = items.Select((it) => + { + var reuslt = it.Adapt(); + if (verificatInfoDicts.TryGetValue(it.Id, out var verificatInfos)) + { + SessionService.GetTokenInfos(verificatInfos);//获取剩余时间 + reuslt.VerificatCount = verificatInfos.Count;//令牌数量 + reuslt.VerificatSignList = verificatInfos;//令牌列表 + + //如果有mqtt客户端ID就是在线 + reuslt.Online = verificatInfos.Any(it => it.ClientIds.Count > 0); + } + + return reuslt; + }).ToList(); + + ret.TotalCount = totalCount; + ret.Items = r; + } + else if (option.IsVirtualScroll) + { + RefAsync totalCount = 0; + + var items = await query.ToPageListAsync(option.StartIndex, option.PageItems, totalCount).ConfigureAwait(false); + var verificatInfoDicts = _verificatInfoService.GetListByUserIds(items.Select(a => a.Id).ToList()).GroupBy(a => a.UserId).ToDictionary(a => a.Key, a => a.ToList()); + + var r = items.Select((it) => + { + var reuslt = it.Adapt(); + if (verificatInfoDicts.TryGetValue(it.Id, out var verificatInfos)) + { + SessionService.GetTokenInfos(verificatInfos);//获取剩余时间 + reuslt.VerificatCount = verificatInfos.Count;//令牌数量 + reuslt.VerificatSignList = verificatInfos;//令牌列表 + + //如果有mqtt客户端ID就是在线 + reuslt.Online = verificatInfos.Any(it => it.ClientIds.Count > 0); + } + + return reuslt; + }).ToList(); + ret.TotalCount = totalCount; + ret.Items = r; + } + else + { + var items = await query.ToListAsync().ConfigureAwait(false); + + var verificatInfoDicts = _verificatInfoService.GetListByUserIds(items.Select(a => a.Id).ToList()).GroupBy(a => a.UserId).ToDictionary(a => a.Key, a => a.ToList()); + + var r = items.Select((it) => + { + var reuslt = it.Adapt(); + if (verificatInfoDicts.TryGetValue(it.Id, out var verificatInfos)) + { + SessionService.GetTokenInfos(verificatInfos);//获取剩余时间 + reuslt.VerificatCount = verificatInfos.Count;//令牌数量 + reuslt.VerificatSignList = verificatInfos;//令牌列表 + + //如果有mqtt客户端ID就是在线 + reuslt.Online = verificatInfos.Any(it => it.ClientIds.Count > 0); + } + + return reuslt; + }).ToList(); + ret.TotalCount = items.Count; + ret.Items = r; + } + return ret; + } + + #endregion 查询 + + #region 修改 + + /// + /// 强退会话 + /// + /// 用户id + [OperDesc("ExitSession")] + public async Task ExitSession(long userId) + { + var verificatInfoIds = _verificatInfoService.GetListByUserId(userId); + //verificat列表 + _verificatInfoService.Delete(verificatInfoIds.Select(a => a.Id).ToList()); + await NoticeUserLoginOut(userId, verificatInfoIds.SelectMany(a => a.ClientIds).ToList()).ConfigureAwait(false); + } + + /// + /// 强退令牌 + /// + /// 参数 + /// + [OperDesc("ExitVerificat")] + public async Task ExitVerificat(ExitVerificatInput input) + { + var userId = input.Id; + var data = input.VerificatIds.ToList(); + if (data.Count > 0) + { + var data1 = _verificatInfoService.GetListByIds(data).SelectMany(a => a.ClientIds).ToList(); + _verificatInfoService.Delete(data);//如果还有verificat则更新verificat + await NoticeUserLoginOut(userId, data1).ConfigureAwait(false); + } + } + + #endregion 修改 + + #region 方法 + + /// + /// 获取verificat剩余时间信息 + /// + /// verificat列表 + private static void GetTokenInfos(List verificatInfos) + { + verificatInfos.ForEach(it => + { + var now = DateTime.Now; + it.VerificatRemain = now.GetDiffTime(it.VerificatTimeout);//获取时间差 + }); + } + + /// + /// 通知用户下线 + /// + /// + private async Task NoticeUserLoginOut(long userId, List clientIds) + { + await NoticeUtil.UserLoginOut(new UserLoginOutEvent + { + Message = Localizer["ExitVerificat"], + ClientIds = clientIds, + }).ConfigureAwait(false);//通知用户下线 + } + + #endregion 方法 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/SugarAopService/ISugarAopService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/SugarAopService/ISugarAopService.cs new file mode 100644 index 000000000..be89d2532 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/SugarAopService/ISugarAopService.cs @@ -0,0 +1,34 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + + +/// +/// Aop设置 +/// +public interface ISugarAopService +{ + /// + /// Aop设置 + /// + public void AopSetting(ISqlSugarClient db, bool isShowSql = false); + +} +/// +/// Aop设置,可自定义加解密等 +/// +public interface ISugarConfigAopService +{ + public SqlSugarOptions Config(SqlSugarOptions sqlSugarOptions); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/SugarAopService/SugarAopService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/SugarAopService/SugarAopService.cs new file mode 100644 index 000000000..879889061 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/SugarAopService/SugarAopService.cs @@ -0,0 +1,130 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + +public class SugarAopService : ISugarAopService +{ + private IAppService _appService; + public SugarAopService(IAppService appService) + { + _appService = appService; + } + /// + /// Aop设置 + /// + public void AopSetting(ISqlSugarClient db, bool isShowSql = false) + { + var config = db.CurrentConnectionConfig; + + if (isShowSql) + { + // 打印SQL语句 + db.Aop.OnLogExecuting = (sql, pars) => + { + if (sql.StartsWith("SELECT")) + { + Console.ForegroundColor = ConsoleColor.Green; + DbContext.WriteLog($"查询{config.ConfigId}库操作"); + } + if (sql.StartsWith("UPDATE")) + { + Console.ForegroundColor = ConsoleColor.Blue; + DbContext.WriteLog($"修改{config.ConfigId}库操作"); + } + if (sql.StartsWith("INSERT")) + { + Console.ForegroundColor = ConsoleColor.Yellow; + DbContext.WriteLog($"添加{config.ConfigId}库操作"); + } + if (sql.StartsWith("DELETE")) + { + Console.ForegroundColor = ConsoleColor.Red; + DbContext.WriteLog($"删除{config.ConfigId}库操作"); + } + DbContext.WriteLogWithSql(UtilMethods.GetNativeSql(sql, pars)); + DbContext.WriteLog($"{config.ConfigId}库操作结束"); + Console.ForegroundColor = ConsoleColor.White; + }; + } + //异常 + db.Aop.OnError = (ex) => + { + if (ex.Parametres == null) return; + Console.ForegroundColor = ConsoleColor.Red; + DbContext.WriteLog($"{config.ConfigId}库操作异常"); + DbContext.WriteErrorLogWithSql(UtilMethods.GetNativeSql(ex.Sql, (SugarParameter[])ex.Parametres)); + Console.WriteLine(ex.ToString()); + NewLife.Log.XTrace.WriteException(ex); + Console.ForegroundColor = ConsoleColor.White; + }; + //插入和更新过滤器 + db.Aop.DataExecuting = (oldValue, entityInfo) => + { + // 新增操作 + if (entityInfo.OperationType == DataFilterType.InsertByObject) + { + // 主键(long类型)且没有值的---赋值雪花Id + if (entityInfo.EntityColumnInfo.IsPrimarykey && entityInfo.EntityColumnInfo.PropertyInfo.PropertyType == typeof(long)) + { + var id = entityInfo.EntityColumnInfo.PropertyInfo.GetValue(entityInfo.EntityValue); + if (id == null || (long)id == 0) + entityInfo.SetValue(CommonUtils.GetSingleId()); + } + if (entityInfo.PropertyName == nameof(BaseEntity.CreateTime)) + entityInfo.SetValue(DateTime.Now); + + if (_appService.User != null) + { + //创建人 + if (entityInfo.PropertyName == nameof(BaseEntity.CreateUserId)) + entityInfo.SetValue(UserManager.UserId); + if (entityInfo.PropertyName == nameof(BaseEntity.CreateUser)) + entityInfo.SetValue(UserManager.UserAccount); + if (entityInfo.PropertyName == nameof(BaseDataEntity.CreateOrgId)) + entityInfo.SetValue(UserManager.OrgId); + } + } + // 更新操作 + if (entityInfo.OperationType == DataFilterType.UpdateByObject) + { + //更新时间 + if (entityInfo.PropertyName == nameof(BaseEntity.UpdateTime)) + entityInfo.SetValue(DateTime.Now); + //更新人 + if (_appService.User != null) + { + if (entityInfo.PropertyName == nameof(BaseEntity.UpdateUserId)) + entityInfo.SetValue(UserManager.UserId); + if (entityInfo.PropertyName == nameof(BaseEntity.UpdateUser)) + entityInfo.SetValue(UserManager.UserAccount); + } + } + }; + + //查询数据转换 + db.Aop.DataExecuted = (value, entity) => + { + }; + } + +} + + +public class SugarConfigAopService : ISugarConfigAopService +{ + public SqlSugarOptions Config(SqlSugarOptions sqlSugarOptions) + { + return sqlSugarOptions; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/SugarService/BaseService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/SugarService/BaseService.cs new file mode 100644 index 000000000..79027d3a9 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/SugarService/BaseService.cs @@ -0,0 +1,155 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.Extensions.Localization; + +using SqlSugar; + +namespace ThingsGateway.Admin.Application; + +/// +/// 通用服务 +/// +/// +public class BaseService : IDataService, IDisposable where T : class, new() +{ + /// + /// 通用服务 + /// + public BaseService() + { + Localizer = App.CreateLocalizerByType(typeof(T))!; + } + + /// + /// 是否已释放资源 + /// + public bool IsDisposed { get; private set; } + + /// + /// 语言本地化资源 + /// + protected IStringLocalizer Localizer { get; } + + /// + public Task AddAsync(T model) + { + return SaveAsync(model, ItemChangedType.Add); + } + + /// + public Task DeleteAsync(IEnumerable models) + { + if (models.FirstOrDefault() is IPrimaryIdEntity) + return DeleteAsync(models.Select(a => ((IPrimaryIdEntity)a).Id)); + else + return Task.FromResult(false); + } + + /// + public virtual async Task DeleteAsync(IEnumerable models) + { + using var db = GetDB(); + return await db.Deleteable().In(models.ToList()).ExecuteCommandHasChangeAsync().ConfigureAwait(false); + } + + /// + /// 释放资源 + /// + public void Dispose() + { + Dispose(true); + IsDisposed = true; + GC.SuppressFinalize(this); + } + + /// + public Task> QueryAsync(QueryPageOptions option) + { + return QueryAsync(option, null, null); + } + + /// + public virtual async Task> QueryAsync(QueryPageOptions option, Func, ISugarQueryable>? queryFunc = null, FilterKeyValueAction where = null) + { + var ret = new QueryData() + { + IsSorted = option.SortOrder != SortOrder.Unset, + IsFiltered = option.Filters.Count > 0, + IsAdvanceSearch = option.AdvanceSearches.Count > 0 || option.CustomerSearches.Count > 0, + IsSearch = option.Searches.Count > 0 + }; + + using var db = GetDB(); + var query = db.Queryable(); + if (queryFunc != null) + query = queryFunc(query); + query = db.GetQuery(option, query, where); + + if (option.IsPage) + { + RefAsync totalCount = 0; + + var items = await query.ToPageListAsync(option.PageIndex, option.PageItems, totalCount).ConfigureAwait(false); + + ret.TotalCount = totalCount; + ret.Items = items; + } + else if (option.IsVirtualScroll) + { + RefAsync totalCount = 0; + + var items = await query.ToPageListAsync(option.StartIndex, option.PageItems, totalCount).ConfigureAwait(false); + + ret.TotalCount = totalCount; + ret.Items = items; + } + else + { + var items = await query.ToListAsync().ConfigureAwait(false); + ret.TotalCount = items.Count; + ret.Items = items; + } + return ret; + } + + /// + public virtual async Task SaveAsync(T model, ItemChangedType changedType) + { + using var db = GetDB(); + if (changedType == ItemChangedType.Add) + { + return (await db.Insertable(model).ExecuteCommandAsync().ConfigureAwait(false)) > 0; + } + else + { + return (await db.Updateable(model).ExecuteCommandAsync().ConfigureAwait(false)) > 0; + } + } + + /// + /// 释放资源 + /// + /// + protected virtual void Dispose(bool disposing) + { + } + + /// + /// 获取数据库连接 + /// + /// + protected SqlSugarClient GetDB() + { + return DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/User/Dto/UserInput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/User/Dto/UserInput.cs new file mode 100644 index 000000000..3e39a6130 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/User/Dto/UserInput.cs @@ -0,0 +1,32 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; +/// +/// 用户选择器参数 +/// +public class UserSelectorInput +{ + /// + /// 组织ID + /// + public long OrgId { get; set; } + + /// + /// 机构ID + /// + public long PositionId { get; set; } + + /// + /// 角色ID + /// + public long RoleId { get; set; } + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/User/Dto/UserOutput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/User/Dto/UserOutput.cs new file mode 100644 index 000000000..6cbea9a54 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/User/Dto/UserOutput.cs @@ -0,0 +1,46 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +/// 选择用户输出参数 +/// +public class UserSelectorOutput : PrimaryIdEntity +{ + [AutoGenerateColumn(Visible = true, Sortable = true, Filterable = true)] + public string Account { get; set; } + + /// + /// 组织ID + /// + [AutoGenerateColumn(Visible = false, IsVisibleWhenEdit = false, IsVisibleWhenAdd = false)] + public long OrgId { get; set; } + + [AutoGenerateColumn(Ignore = true)] + public long CreateUserId { get; set; } + + public override bool Equals(object? obj) + { + if (obj == null || !(obj is UserSelectorOutput)) + { + return false; + } + + return Id == ((UserSelectorOutput)obj).Id; + } + + public override int GetHashCode() + { + return Id.GetHashCode(); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/User/ISysUserService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/User/ISysUserService.cs new file mode 100644 index 000000000..e96dcee58 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/User/ISysUserService.cs @@ -0,0 +1,178 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +/// 用户服务接口,提供用户相关操作方法。 +/// +public interface ISysUserService +{ + #region 数据范围相关 + + /// + /// 获取当前API用户的数据范围 + /// null:代表拥有全部数据权限 + /// [xx,xx]:代表拥有部分机构的权限 + /// []:代表仅自己权限 + /// + /// 机构列表 + Task?> GetCurrentUserDataScopeAsync(); + + /// + /// 检查用户是否有机构的数据权限 + /// + /// 机构id + /// 创建者id + /// + /// 是否有权限 + Task CheckApiDataScopeAsync(long? orgId, long createUerId, bool throwEnable = true); + + /// + /// 检查用户是否有机构的数据权限 + /// + /// 机构id列表 + /// 创建者id列表 + /// + /// + Task CheckApiDataScopeAsync(IEnumerable orgIds, IEnumerable createUerIds, bool throwEnable = true); + + #endregion + + /// + /// 获取用户拥有的OpenAPI权限。 + /// + /// 用户ID。 + /// 用户拥有的OpenAPI权限。 + Task ApiOwnPermissionAsync(long id); + + /// + /// 删除用户。 + /// + /// 用户ID列表。 + /// 是否删除成功。 + Task DeleteUserAsync(IEnumerable ids); + + /// + /// 从缓存中删除用户信息。 + /// + /// 用户ID。 + void DeleteUserFromCache(long userId); + + /// + /// 从缓存中删除多个用户信息。 + /// + /// 用户ID列表。 + void DeleteUserFromCache(IEnumerable ids); + + /// + /// 获取用户拥有的按钮编码。 + /// + /// 用户ID。 + /// 以菜单链接为键,按钮编码列表为值的字典。 + Task>> GetButtonCodeListAsync(long userId); + + /// + /// 根据账号获取用户ID。 + /// + /// 账号。 + /// 租户id。 + /// 用户ID,如果用户不存在则返回0。 + Task GetIdByAccountAsync(string account, long? tenantId); + + /// + /// 获取用户拥有的权限。 + /// + /// 用户ID。 + /// 权限列表。 + Task> GetPermissionListByUserIdAsync(long userId); + + /// + /// 根据账号获取用户信息。 + /// + /// 账号。 + /// 租户id。 + /// 用户信息,如果用户不存在则返回null。 + Task GetUserByAccountAsync(string account, long? tenantId); + + /// + /// 根据用户ID获取用户信息。 + /// + /// 用户ID。 + /// 用户信息,如果用户不存在则返回null。 + Task GetUserByIdAsync(long userId); + + /// + /// 根据用户ID列表获取用户列表。 + /// + /// 用户ID列表。 + /// 用户列表。 + Task> GetUserListByIdListAsync(IEnumerable input); + + /// + /// 授予用户OpenAPI权限。 + /// + /// 授权信息。 + /// 异步任务。 + Task GrantApiPermissionAsync(GrantPermissionData input); + + /// + /// 授予用户资源。 + /// + /// 授权信息。 + /// 异步任务。 + Task GrantResourceAsync(GrantResourceData input); + + /// + /// 授予用户角色。 + /// + /// 授权信息。 + /// 异步任务。 + Task GrantRoleAsync(GrantUserOrRoleInput input); + + /// + /// 获取用户拥有的资源。 + /// + /// 用户ID。 + /// 用户拥有的资源数据。 + Task OwnResourceAsync(long id); + + /// + /// 获取用户拥有的角色ID列表。 + /// + /// 用户ID。 + /// 角色ID列表。 + Task> OwnRoleAsync(long id); + + /// + /// 表格查询用户信息。 + /// + /// 查询选项。 + /// 查询选项。 + /// 用户信息列表。 + Task> PageAsync(QueryPageOptions option, UserSelectorInput input); + + /// + /// 重置用户密码。 + /// + /// 用户ID。 + /// 异步任务。 + Task ResetPasswordAsync(long id); + + /// + /// 保存用户信息。 + /// + /// 用户信息。 + /// 变更类型。 + /// 是否保存成功。 + Task SaveUserAsync(SysUser input, ItemChangedType changedType); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/User/SysUserService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/User/SysUserService.cs new file mode 100644 index 000000000..15532a716 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/User/SysUserService.cs @@ -0,0 +1,924 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Mapster; + +using SqlSugar; + +using ThingsGateway.DataEncryption; +using ThingsGateway.Extension; +using ThingsGateway.Extension.Generic; +using ThingsGateway.FriendlyException; +using ThingsGateway.NewLife.Json.Extension; + +namespace ThingsGateway.Admin.Application; + +internal sealed class SysUserService : BaseService, ISysUserService +{ + private readonly IRelationService _relationService; + private readonly ISysResourceService _sysResourceService; + private readonly ISysRoleService _roleService; + private readonly ISysDictService _configService; + private readonly ISysPositionService _sysPositionService; + private readonly ISysOrgService _sysOrgService; + private readonly IVerificatInfoService _verificatInfoService; + + public SysUserService( + IVerificatInfoService verificatInfoService, + IRelationService relationService, + ISysPositionService sysPositionService, + ISysOrgService sysOrgService, + ISysResourceService sysResourceService, + ISysRoleService roleService, + ISysDictService configService) + { + _sysOrgService = sysOrgService; + _sysPositionService = sysPositionService; + _relationService = relationService; + _sysResourceService = sysResourceService; + _roleService = roleService; + _configService = configService; + _verificatInfoService = verificatInfoService; + } + + + #region 数据范围相关 + + /// + public async Task?> GetCurrentUserDataScopeAsync() + { + if (UserManager.SuperAdmin || UserManager.UserId == 0) + return null; + var userInfo = await GetUserByIdAsync(UserManager.UserId).ConfigureAwait(false);//获取用户信息 + var roles = await _roleService.GetRoleListByUserIdAsync(UserManager.UserId).ConfigureAwait(false); + if (roles.Any(a => a.DefaultDataScope.ScopeCategory == DataScopeEnum.SCOPE_ALL)) + { + return null; + } + else + { + var scopeDefineOrgIdList = roles.Where(a => a.DefaultDataScope.ScopeCategory == DataScopeEnum.SCOPE_ORG_DEFINE).SelectMany(a => a.DefaultDataScope.ScopeDefineOrgIdList); + + HashSet orgChilds = new(); + HashSet orgs = new(); + if (roles.Any(a => a.DefaultDataScope.ScopeCategory == DataScopeEnum.SCOPE_ORG_CHILD)) + { + orgChilds = userInfo.ScopeOrgChildList; + } + if (roles.Any(a => a.DefaultDataScope.ScopeCategory == DataScopeEnum.SCOPE_ORG_CHILD)) + { + orgs = new HashSet() { userInfo.OrgId }; + } + return scopeDefineOrgIdList.Concat(orgChilds).Concat(orgs).ToHashSet(); + } + } + + /// + public async Task CheckApiDataScopeAsync(long? orgId, long createUerId, bool throwEnable = true) + { + var hasPermission = true; + //判断数据范围 + var dataScope = await GetCurrentUserDataScopeAsync().ConfigureAwait(false); + if (dataScope is { Count: > 0 })//如果有机构 + { + if (orgId == null || !dataScope.Contains(orgId.Value))//判断机构id是否在数据范围 + hasPermission = false; + } + else if (dataScope is { Count: 0 })// 表示仅自己 + { + if (createUerId != 0 && createUerId != UserManager.UserId) + hasPermission = false;//机构的创建人不是自己则报错 + } + if (!hasPermission && throwEnable) + { + throw Oops.Bah(App.CreateLocalizerByType(typeof(ThingsGateway.Admin.Application.OperDescAttribute))["NoPermission"]); + } + return hasPermission; + } + + + public async Task CheckApiDataScopeAsync(IEnumerable orgIds, IEnumerable createUerIds, bool throwEnable = true) + { + var hasPermission = true; + //判断数据范围 + var dataScope = await GetCurrentUserDataScopeAsync().ConfigureAwait(false); + if (dataScope is { Count: > 0 })//如果有机构 + { + if (orgIds == null || !dataScope.ContainsAll(orgIds))//判断机构id列表是否全在数据范围 + hasPermission = false; + } + else if (dataScope is { Count: 0 })// 表示仅自己 + { + if (createUerIds.Any(it => it != 0 && it != UserManager.UserId))//如果创建者id里有任何不是自己创建的机构 + hasPermission = false; + } + if (!hasPermission && throwEnable) + { + throw Oops.Bah(App.CreateLocalizerByType(typeof(ThingsGateway.Admin.Application.OperDescAttribute))["NoPermission"]); + } + return hasPermission; + } + + #endregion + + #region 查询 + + + /// + public async Task GetUserByAccountAsync(string account, long? tenantId) + { + var userId = await GetIdByAccountAsync(account, tenantId).ConfigureAwait(false);//获取用户ID + if (userId > 0) + { + var sysUser = await GetUserByIdAsync(userId).ConfigureAwait(false);//获取用户信息 + if (sysUser?.Account == account) + return sysUser; + else + return null; + } + else + { + return null; + } + } + + /// + /// 根据用户id获取用户,不存在返回null + /// + /// 用户id + /// 用户 + public async Task GetUserByIdAsync(long userId) + { + //先从Cache拿 + var sysUser = App.CacheService.HashGetOne(CacheConst.Cache_SysUser, userId.ToString()); + sysUser ??= await GetUserFromDbAsync(userId).ConfigureAwait(false);//从数据库拿用户信息 + return sysUser; + } + + /// + /// 根据账号获取用户id + /// + /// 账号 + /// 租户id + /// 用户id + public async Task GetIdByAccountAsync(string account, long? tenantId) + { + var key = CacheConst.Cache_SysUserAccount; + var orgIds = new HashSet(); + if (tenantId > 0) + { + key += $":{tenantId}"; + orgIds = await _sysOrgService.GetOrgChildIdsAsync(tenantId.Value).ConfigureAwait(false);//获取下级机构 + } + //先从Cache拿 + var userId = App.CacheService.HashGetOne(key, account); + if (userId == 0) + { + + //单查获取用户账号对应ID + using var db = GetDB(); + userId = await db.Queryable() + .Where(it => it.Account == account) + .WhereIF(orgIds.Count > 0, it => orgIds.Contains(it.OrgId)) + .Select(it => it.Id).FirstAsync().ConfigureAwait(false); + if (userId != 0) + { + //插入Cache + App.CacheService.HashAdd(key, account, userId); + } + } + return userId; + } + + /// + /// 获取用户拥有的按钮编码 + /// + /// 用户id + /// 按钮编码 + public async Task>> GetButtonCodeListAsync(long userId) + { + //获取用户资源集合 + var resourceList = await _relationService.GetRelationListByObjectIdAndCategoryAsync(userId, RelationCategoryEnum.UserHasResource).ConfigureAwait(false); + if (!resourceList.Any())//如果有表示用户单独授权了不走用户角色 + { + //获取用户角色关系集合 + var roleList = await _relationService.GetRelationListByObjectIdAndCategoryAsync(userId, RelationCategoryEnum.UserHasRole).ConfigureAwait(false); + var roleIdList = roleList.Select(x => x.TargetId.ToLong());//角色ID列表 + if (roleIdList.Any())//如果该用户有角色 + { + resourceList = await _relationService.GetRelationListByObjectIdListAndCategoryAsync(roleIdList, + RelationCategoryEnum.RoleHasResource).ConfigureAwait(false);//获取资源集合 + } + } + var relationResourcePermissions = resourceList.Select(it => it.ExtJson?.FromJsonNetString()); + var allResources = await _sysResourceService.GetAllAsync().ConfigureAwait(false); + + + var menus = allResources.Where(it => it.Category == ResourceCategoryEnum.Menu && relationResourcePermissions.Select(a => a.MenuId).Contains(it.Id)).ToDictionary(a => a, a => relationResourcePermissions.FirstOrDefault(b => b.MenuId == a.Id)); + + Dictionary> buttonCodeList = new(); + foreach (var item in menus) + { + if (buttonCodeList.TryGetValue(item.Key.Href, out var buttonCode)) + { + var buttonS = allResources.Where(a => item.Value.ButtonIds.Contains(a.Id)); + buttonCode.AddRange(buttonS.Select(a => a.Title)); + } + else + { + var buttonS = allResources.Where(a => item.Value.ButtonIds.Contains(a.Id)); + buttonCodeList.Add(item.Key.Href, buttonS.Select(a => a.Title).ToList()); + } + } + + var firstbuttons = allResources.Where(it => it.Category == ResourceCategoryEnum.Button && relationResourcePermissions.FirstOrDefault(a => a.MenuId == 0)?.ButtonIds?.Contains(it.Id) == true); + buttonCodeList.Add(string.Empty, firstbuttons?.Select(a => a.Title).ToList()); + + return buttonCodeList!; + } + + /// + public async Task> GetPermissionListByUserIdAsync(long userId) + { + List? permissions = new(); + + #region Razor页面权限 + + { + var sysRelations = + await _relationService.GetRelationListByObjectIdAndCategoryAsync(userId, RelationCategoryEnum.UserHasPermission).ConfigureAwait(false);//根据用户ID获取用户权限 + if (!sysRelations.Any())//如果有表示用户单独授权了不走用户角色 + { + var roleIdList = + await _relationService.GetRelationListByObjectIdAndCategoryAsync(userId, RelationCategoryEnum.UserHasRole).ConfigureAwait(false);//根据用户ID获取角色ID + if (roleIdList.Any())//如果角色ID不为空 + { + //获取角色权限信息 + sysRelations = await _relationService.GetRelationListByObjectIdListAndCategoryAsync(roleIdList.Select(it => it.TargetId.ToLong()), + RelationCategoryEnum.RoleHasPermission).ConfigureAwait(false); + } + } + var relationGroup = sysRelations.GroupBy(it => it.TargetId);//根据目标ID,也就是接口名分组,因为存在一个用户多个角色 + + //遍历分组 + foreach (var it in relationGroup) + { + permissions.Add(new DataScope + { + ApiUrl = it.Key, + }); + } + + } + + #endregion Razor页面权限 + + #region API权限 + + { + var apiRelations = + await _relationService.GetRelationListByObjectIdAndCategoryAsync(userId, RelationCategoryEnum.UserHasOpenApiPermission).ConfigureAwait(false);//根据用户ID获取用户权限 + if (!apiRelations.Any())//如果有表示用户单独授权了不走用户角色 + { + var roleIdList = + await _relationService.GetRelationListByObjectIdAndCategoryAsync(userId, RelationCategoryEnum.UserHasRole).ConfigureAwait(false);//根据用户ID获取角色ID + if (roleIdList.Any())//如果角色ID不为空 + { + //获取角色权限信息 + apiRelations = await _relationService.GetRelationListByObjectIdListAndCategoryAsync(roleIdList.Select(it => it.TargetId.ToLong()), + RelationCategoryEnum.RoleHasOpenApiPermission).ConfigureAwait(false); + } + } + var relationGroup = apiRelations.GroupBy(it => it.TargetId);//根据目标ID,也就是接口名分组,因为存在一个用户多个角色 + + //遍历分组 + foreach (var it in relationGroup) + { + permissions.Add(new DataScope + { + ApiUrl = it.Key, + }); + } + + } + + #endregion API权限 + + return permissions; + } + + /// + /// 表格查询 + /// + /// 查询条件 + /// 查询条件 + public async Task> PageAsync(QueryPageOptions option, UserSelectorInput input) + { + if (input != null) + { + option.SortName = "u." + option.SortName; + var orgIds = await _sysOrgService.GetOrgChildIdsAsync(input.OrgId).ConfigureAwait(false);//获取下级机构 + var dataScope = await GetCurrentUserDataScopeAsync().ConfigureAwait(false); + + return await QueryAsync(option, query => + query.WhereIF(!option.SearchText.IsNullOrWhiteSpace(), a => a.Account.Contains(option.SearchText)) + .WhereIF(input.OrgId > 0, u => orgIds.Contains(u.OrgId))//指定机构 + .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.OrgId))//在指定机构列表查询 + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .WhereIF(input.PositionId > 0, u => u.PositionId == input.PositionId)//指定职位 + + .WhereIF(input.RoleId > 0, + u => SqlFunc.Subqueryable() + .Where(r => r.TargetId == input.RoleId.ToString() && r.ObjectId == u.Id && r.Category == RelationCategoryEnum.UserHasRole) + .Any())//指定角色 + + .LeftJoin((u, o) => u.OrgId == o.Id).LeftJoin((u, o, p) => u.PositionId == p.Id) + .Select((u, o, p) => new SysUser + { + Id = u.Id.SelectAll(), + OrgName = o.Name, + PositionName = p.Name, + OrgNames = o.Names + } + ) + .Mapper(u => + { +#pragma warning disable CS8625 // 无法将 null 字面量转换为非 null 的引用类型。 + u.Password = null;//密码清空 + u.Phone = DESEncryption.Decrypt(u.Phone);//解密手机号 +#pragma warning restore CS8625 // 无法将 null 字面量转换为非 null 的引用类型。 + })).ConfigureAwait(false); + + } + else + { + return await QueryAsync(option, query => + query.WhereIF(!option.SearchText.IsNullOrWhiteSpace(), a => a.Account.Contains(option.SearchText)).Mapper(u => + { +#pragma warning disable CS8625 // 无法将 null 字面量转换为非 null 的引用类型。 + u.Password = null;//密码清空 + u.Phone = DESEncryption.Decrypt(u.Phone);//解密手机号 +#pragma warning restore CS8625 // 无法将 null 字面量转换为非 null 的引用类型。 + })).ConfigureAwait(false); + } + + + + + } + + /// + /// 获取用户拥有的角色 + /// + /// 用户id + /// 角色id列表 + public async Task> OwnRoleAsync(long id) + { + var relations = await _relationService.GetRelationListByObjectIdAndCategoryAsync(id, RelationCategoryEnum.UserHasRole).ConfigureAwait(false); + return relations.Select(it => it.TargetId.ToLong()); + } + + /// + /// 获取用户拥有的资源 + /// + /// 用户id + public async Task OwnResourceAsync(long id) + { + return await _roleService.OwnResourceAsync(id, RelationCategoryEnum.UserHasResource).ConfigureAwait(false); + } + + /// + /// 根据用户id获取用户列表 + /// + /// 用户id列表 + /// 用户列表 + public async Task> GetUserListByIdListAsync(IEnumerable input) + { + using var db = GetDB(); + var userList = await db.Queryable().Where(it => input.Contains(it.Id)).Select().ToListAsync().ConfigureAwait(false); + return userList; + } + + #endregion 查询 + + #region OPENAPI + + /// + /// 获取用户拥有的OpenApi权限 + /// + /// 用户id + public async Task ApiOwnPermissionAsync(long id) + { + var roleOwnPermission = new GrantPermissionData { Id = id };//定义结果集 + //获取关系列表 + var relations = await _relationService.GetRelationListByObjectIdAndCategoryAsync(id, RelationCategoryEnum.UserHasOpenApiPermission).ConfigureAwait(false); + roleOwnPermission.GrantInfoList = relations.Select(it => it.ExtJson?.FromJsonNetString()!).Where(a => a != null); + return roleOwnPermission; + } + + /// + [OperDesc("UserGrantApiPermission")] + public async Task GrantApiPermissionAsync(GrantPermissionData input) + { + var sysUser = await GetUserByIdAsync(input.Id).ConfigureAwait(false);//获取用户 + + await CheckApiDataScopeAsync(sysUser.OrgId, sysUser.CreateUserId).ConfigureAwait(false); + if (sysUser != null) + { + await _relationService.SaveRelationBatchAsync(RelationCategoryEnum.UserHasOpenApiPermission, input.Id, + input.GrantInfoList.Select(a => (a.ApiUrl, a.ToJsonNetString())), + true).ConfigureAwait(false);//添加到数据库 + DeleteUserFromCache(input.Id); + } + } + + #endregion OPENAPI + + #region 新增 + + /// + [OperDesc("SaveUser", isRecordPar: false)] + public async Task SaveUserAsync(SysUser input, ItemChangedType changedType) + { + await CheckInput(input).ConfigureAwait(false);//检查参数 + + if (changedType == ItemChangedType.Add) + { + var sysUser = input.Adapt(); + //获取默认密码 + sysUser.Avatar = input.Avatar; + sysUser.Password = await GetDefaultPassWord(true).ConfigureAwait(false);//设置密码 + sysUser.Status = true;//默认状态 + return await SaveAsync(sysUser, changedType).ConfigureAwait(false);//添加数据 + } + else + { + await CheckApiDataScopeAsync(input.OrgId, input.CreateUserId).ConfigureAwait(false); + var exist = await GetUserByIdAsync(input.Id).ConfigureAwait(false);//获取用户信息 + if (exist != null) + { + var isSuperAdmin = exist.Account == RoleConst.SuperAdmin;//判断是否有超管 + if (isSuperAdmin && !UserManager.SuperAdmin) + throw Oops.Bah(Localizer["CanotEditAdminUser"]); + + if (input.Status != exist.Status) + CheckSelf(input.Id, input.Status ? Localizer["Enable"] : Localizer["Disable"]);//判断是不是自己 + + var sysUser = input;//实体转换 + using var db = GetDB(); + var result = await db.Updateable(sysUser).IgnoreColumns(it => + new + { + //忽略更新字段 + it.Password, + it.LastLoginDevice, + it.LastLoginIp, + it.LastLoginTime, + it.LatestLoginDevice, + it.LatestLoginIp, + it.LatestLoginTime + }).ExecuteCommandAsync().ConfigureAwait(false) > 0; + if (result)//修改数据 + { + DeleteUserFromCache(sysUser.Id);//删除用户缓存 + + var verificatInfoIds = _verificatInfoService.GetListByUserId(sysUser.Id); + + //从列表中删除 + //删除用户verificat缓存 + _verificatInfoService.Delete(verificatInfoIds.Select(a => a.Id).ToList()); + await NoticeUtil.UserLoginOut(new UserLoginOutEvent() { ClientIds = verificatInfoIds.SelectMany(a => a.ClientIds).ToList(), Message = Localizer["ExitVerificat"] }).ConfigureAwait(false); + } + return result; + } + } + return false; + } + + #endregion 新增 + + #region 编辑 + + /// + [OperDesc("ResetPassword")] + public async Task ResetPasswordAsync(long id) + { + var sysUser = await GetUserByIdAsync(id).ConfigureAwait(false); + + await CheckApiDataScopeAsync(sysUser.OrgId, sysUser.CreateUserId).ConfigureAwait(false); + + var password = await GetDefaultPassWord(true).ConfigureAwait(false);//获取默认密码,这里不走Aop所以需要加密一下 + using var db = GetDB(); + //重置密码 + if (await db.UpdateSetColumnsTrueAsync(it => new SysUser + { + Password = password + }, it => it.Id == id).ConfigureAwait(false)) + { + DeleteUserFromCache(id);//从cache删除用户信息 + var verificatInfoIds = _verificatInfoService.GetListByUserId(id); + //删除用户verificat缓存 + _verificatInfoService.Delete(verificatInfoIds.Select(a => a.Id).ToList()); + await NoticeUtil.UserLoginOut(new UserLoginOutEvent() { ClientIds = verificatInfoIds.SelectMany(a => a.ClientIds).ToList(), Message = Localizer["ExitVerificat"] }).ConfigureAwait(false); + } + } + + /// + [OperDesc("UserGrantRole")] + public async Task GrantRoleAsync(GrantUserOrRoleInput input) + { + var sysUser = await GetUserByIdAsync(input.Id).ConfigureAwait(false);//获取用户信息 + await CheckApiDataScopeAsync(sysUser.OrgId, sysUser.CreateUserId).ConfigureAwait(false); + if (sysUser != null) + { + var isSuperAdmin = (sysUser.Account == RoleConst.SuperAdmin || input.GrantInfoList.Any(a => a == RoleConst.SuperAdminRoleId)) && !UserManager.SuperAdmin;//判断是否有超管 + if (isSuperAdmin) + throw Oops.Bah(Localizer["CanotGrantAdmin"]); + + CheckSelf(input.Id, Localizer["GrantRole"]);//判断是不是自己 + + //给用户赋角色 + await _relationService.SaveRelationBatchAsync(RelationCategoryEnum.UserHasRole, input.Id, input.GrantInfoList.Select(it => (it.ToString(), string.Empty)), true).ConfigureAwait(false); + DeleteUserFromCache(input.Id);//从cache删除用户信息 + } + } + + /// + [OperDesc("UserGrantResource")] + public async Task GrantResourceAsync(GrantResourceData input) + { + var menuIds = input.GrantInfoList.Select(it => it.MenuId).ToList();//菜单ID + var extJsons = input.GrantInfoList.Select(it => it.ToJsonNetString()).ToList();//拓展信息 + var relationUsers = new List();//要添加的用户资源和授权关系表 + var sysUser = await GetUserByIdAsync(input.Id).ConfigureAwait(false);//获取用户 + await CheckApiDataScopeAsync(sysUser.OrgId, sysUser.CreateUserId).ConfigureAwait(false); + if (sysUser != null) + { + var resources = await _sysResourceService.GetAllAsync().ConfigureAwait(false); + var menusList = resources.Where(a => a.Category == ResourceCategoryEnum.Menu).Where(a => menuIds.Contains(a.Id)); + + + #region 用户模块处理 + + //获取我的模块信息Id列表 + var moduleIds = menusList.Select(it => it.Module).Distinct(); + foreach (var item in moduleIds) + { + //将角色资源添加到列表 + relationUsers.Add(new SysRelation + { + ObjectId = sysUser.Id, + TargetId = item.ToString(), + Category = RelationCategoryEnum.UserHasModule + }); + } + + #endregion 用户模块处理 + + #region 用户资源处理 + + for (var i = 0; i < menuIds.Count; i++) + { + //将角色资源添加到列表 + relationUsers.Add(new SysRelation + { + ObjectId = sysUser.Id, + TargetId = menuIds[i].ToString(), + Category = RelationCategoryEnum.UserHasResource, + ExtJson = extJsons?[i] + }); + } + #endregion 用户资源处理 + + #region 用户权限处理. + + //获取菜单信息 + if (menusList.Any()) + { + //获取权限授权树 + var permissions = App.GetService().PermissionTreeSelector(menusList.Select(it => it.Href)); + //要添加的角色有哪些权限列表 + var relationUserPer = permissions.Select(it => new SysRelation + { + ObjectId = sysUser.Id, + TargetId = it.ApiRoute, + Category = RelationCategoryEnum.UserHasPermission, + ExtJson = new RelationPermission { ApiUrl = it.ApiRoute } + .ToJsonNetString() + }); + relationUsers.AddRange(relationUserPer);//合并列表 + } + + #endregion 用户权限处理. + + #region 保存数据库 + + using var db = GetDB(); + + //事务 + var result = await db.UseTranAsync(async () => + { + await db.Deleteable(it => + it.ObjectId == sysUser.Id && (it.Category == RelationCategoryEnum.UserHasPermission + || it.Category == RelationCategoryEnum.UserHasResource + || it.Category == RelationCategoryEnum.UserHasModule + + )).ExecuteCommandAsync().ConfigureAwait(false); + await db.Insertable(relationUsers).ExecuteCommandAsync().ConfigureAwait(false);//添加新的 + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + _relationService.RefreshCache(RelationCategoryEnum.UserHasPermission);//刷新关系缓存 + _relationService.RefreshCache(RelationCategoryEnum.UserHasResource);//刷新关系缓存 + _relationService.RefreshCache(RelationCategoryEnum.UserHasModule);//刷新关系缓存 + DeleteUserFromCache(input.Id);//删除该用户缓存 + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + + #endregion 保存数据库 + } + } + + #endregion 编辑 + + #region 删除 + + /// + [OperDesc("DeleteUser")] + public async Task DeleteUserAsync(IEnumerable ids) + { + using var db = GetDB(); + var containsSuperAdmin = await db.Queryable().Where(it => it.Account == RoleConst.SuperAdmin && ids.Contains(it.Id)).AnyAsync().ConfigureAwait(false);//判断是否有超管 + if (containsSuperAdmin) + throw Oops.Bah(Localizer["CanotDeleteAdminUser"]); + if (ids.Contains(UserManager.UserId)) + throw Oops.Bah(Localizer["CanotDeleteSelf"]); + + var sysUsers = await GetUserListByIdListAsync(ids).ConfigureAwait(false);//获取用户信息 + await CheckApiDataScopeAsync(sysUsers.Select(a => a.OrgId).ToList(), sysUsers.Select(a => a.CreateUserId).ToList()).ConfigureAwait(false); + + //定义删除的关系 + var delRelations = new List + { + RelationCategoryEnum.UserHasResource, RelationCategoryEnum.UserHasPermission, RelationCategoryEnum.UserHasRole, RelationCategoryEnum.UserHasOpenApiPermission + , RelationCategoryEnum.UserHasModule + }; + //事务 + var result = await db.UseTranAsync(async () => + { + //清除该用户作为主管信息 + await db.Updateable().SetColumns(it => new SysUser + { + DirectorId = null + }) + .Where(it => ids.Contains(it.DirectorId.Value)) + .ExecuteCommandAsync().ConfigureAwait(false); + + //删除用户 + await db.Deleteable().In(ids.ToList()).ExecuteCommandHasChangeAsync().ConfigureAwait(false);//删除 + + //删除关系表用户与资源关系,用户与权限关系,用户与角色关系 + await db.Deleteable(it => ids.Contains(it.ObjectId) && delRelations.Contains(it.Category)).ExecuteCommandAsync().ConfigureAwait(false); + + //删除组织表主管信息 + await db.Deleteable(it => ids.Contains(it.DirectorId.Value)).ExecuteCommandAsync().ConfigureAwait(false); + + }).ConfigureAwait(false); + + if (result.IsSuccess)//如果成功了 + { + DeleteUserFromCache(ids);//cache删除用户 + _relationService.RefreshCache(RelationCategoryEnum.UserHasRole);//关系表刷新UserHasRole缓存 + _relationService.RefreshCache(RelationCategoryEnum.UserHasResource);//关系表刷新UserHasRole缓存 + _relationService.RefreshCache(RelationCategoryEnum.UserHasModule);//关系表刷新UserHasModule缓存 + _relationService.RefreshCache(RelationCategoryEnum.UserHasPermission);//关系表刷新UserHasRole缓存 + _relationService.RefreshCache(RelationCategoryEnum.UserHasOpenApiPermission);//关系表刷新Relation_SYS_USER_HAS_OPENAPIPERMISSION缓存 + //将这些用户踢下线,并永久注销这些用户 + foreach (var id in ids) + { + var verificatInfoIds = _verificatInfoService.GetListByUserId(id); + _verificatInfoService.Delete(verificatInfoIds.Select(a => a.Id).ToList()); + await UserLoginOut(id, verificatInfoIds.SelectMany(a => a.ClientIds).ToList()).ConfigureAwait(false); + } + + return true; + } + else + { + throw new(result.ErrorMessage, result.ErrorException); + } + } + + /// + public void DeleteUserFromCache(long userId) + { + DeleteUserFromCache(new List + { + userId + }); + } + + /// + public void DeleteUserFromCache(IEnumerable ids) + { + var userIds = ids.Select(it => it.ToString()).ToArray();//id转string列表 + var sysUsers = App.CacheService.HashGet(CacheConst.Cache_SysUser, userIds).Where(it => it != null);//获取用户列表 + if (sysUsers.Any() == true) + { + var accounts = sysUsers.Where(it => it != null).Select(it => it.Account).ToArray();//账号集合 + var phones = sysUsers.Select(it => it.Phone);//手机号集合 + + if (sysUsers.Any(it => it.TenantId != null))//如果有租户id不是空的表示是多租户模式 + { + var userAccountKey = CacheConst.Cache_SysUserAccount; + var tenantIds = sysUsers.Where(it => it.TenantId != null).Select(it => it.TenantId.Value).Distinct().ToArray();//租户id列表 + foreach (var tenantId in tenantIds) + { + userAccountKey = $"{userAccountKey}:{tenantId}"; + //删除账号 + App.CacheService.HashDel(userAccountKey, accounts); + } + } + //删除用户信息 + App.CacheService.HashDel(CacheConst.Cache_SysUser, userIds); + //删除账号 + App.CacheService.HashDel(CacheConst.Cache_SysUserAccount, accounts); + + App.CacheService.HashDel(CacheConst.Cache_Token, userIds.Select(it => it.ToString()).ToArray()); + + + } + } + + #endregion 删除 + + #region 方法 + + /// + /// 通知用户下线 + /// + /// 用户ID + /// Token列表 + private async Task UserLoginOut(long userId, List? verificatInfoIds) + { + await NoticeUtil.UserLoginOut(new UserLoginOutEvent + { + Message = Localizer["ExitVerificat"], + ClientIds = verificatInfoIds, + }).ConfigureAwait(false);//通知用户下线 + } + + /// + /// 获取默认密码 + /// + /// + private async Task GetDefaultPassWord(bool isEncrypt = false) + { + //获取默认密码 + var appConfig = await _configService.GetAppConfigAsync().ConfigureAwait(false); + return isEncrypt ? DESEncryption.Encrypt(appConfig.PasswordPolicy.DefaultPassword) : appConfig.PasswordPolicy.DefaultPassword;//判断是否需要加密 + } + + /// + /// 检查输入参数 + /// + /// + private async Task CheckInput(SysUser sysUser) + { + + var sysOrgList = await _sysOrgService.GetAllAsync().ConfigureAwait(false);//获取组织列表 + var userOrg = sysOrgList.FirstOrDefault(it => it.Id == sysUser.OrgId); + if (userOrg == null) + throw Oops.Bah(Localizer[$"NoOrg"]); + var tenantId = await _sysOrgService.GetTenantIdByOrgIdAsync(sysUser.OrgId, sysOrgList).ConfigureAwait(false); + + //判断账号重复,直接从cache拿 + var accountId = await GetIdByAccountAsync(sysUser.Account, tenantId).ConfigureAwait(false); + if (accountId > 0 && accountId != sysUser.Id) + throw Oops.Bah(Localizer["AccountDup", sysUser.Account]); + //如果邮箱不是空 + if (!string.IsNullOrEmpty(sysUser.Email)) + { + var isMatch = sysUser.Email.MatchEmail();//验证邮箱格式 + if (!isMatch) + throw Oops.Bah(Localizer["EmailError", sysUser.Email]); + + using var db = GetDB(); + if (await db.Queryable().Where(it => it.Email == sysUser.Email && it.Id != sysUser.Id).AnyAsync().ConfigureAwait(false)) + throw Oops.Bah(Localizer["EmailDup", sysUser.Email]); + } + //如果手机号不是空 + if (!string.IsNullOrEmpty(sysUser.Phone)) + { + if (!sysUser.Phone.MatchPhoneNumber())//验证手机格式 + throw Oops.Bah(Localizer["PhoneError", sysUser.Phone]); + sysUser.Phone = DESEncryption.Encrypt(sysUser.Phone); + } + + if (sysUser.DirectorId == UserManager.UserId) + throw Oops.Bah(Localizer["DirectorSelf"]); + } + + /// + /// 检查是否为自己 + /// + /// + /// 操作名称 + private void CheckSelf(long id, string operate) + { + if (id == UserManager.UserId)//如果是自己 + { + throw Oops.Bah(Localizer["CheckSelf", operate]); + } + } + + /// + /// 数据库获取用户信息 + /// + /// 用户ID + /// + private async Task GetUserFromDbAsync(long userId) + { + using var db = GetDB(); + var sysUser = await db.Queryable() + .LeftJoin((u, o) => u.OrgId == o.Id)//连表 + .LeftJoin((u, o, p) => u.PositionId == p.Id)//连表 + .Where(u => u.Id == userId) + .Select((u, o, p) => new SysUser + { + Id = u.Id.SelectAll(), + OrgName = o.Name, + OrgNames = o.Names, + PositionName = p.Name, + OrgAndPosIdList = o.ParentIdList + }).FirstAsync() + .ConfigureAwait(false); + if (sysUser != null) + { + sysUser.Password = DESEncryption.Decrypt(sysUser.Password);//解密密码 + sysUser.Phone = DESEncryption.Decrypt(sysUser.Phone);//解密手机号 + + sysUser.OrgAndPosIdList.AddRange(sysUser.OrgId, sysUser.PositionId ?? 0);//添加组织和职位Id + if (sysUser.DirectorId != null) + { + sysUser.DirectorInfo = (await GetUserByIdAsync(sysUser.DirectorId.Value).ConfigureAwait(false)).Adapt();//获取主管信息 + } + + //获取按钮码 + var buttonCodeList = await GetButtonCodeListAsync(sysUser.Id).ConfigureAwait(false); + //获取数据权限 + var dataScopeList = await GetPermissionListByUserIdAsync(sysUser.Id).ConfigureAwait(false); + //获取权限码 + var permissionCodeList = dataScopeList.Select(it => it.ApiUrl).ToHashSet(); + //获取角色码 + var roleCodeList = await _roleService.GetRoleListByUserIdAsync(sysUser.Id).ConfigureAwait(false); + //权限码赋值 + sysUser.ButtonCodeList = buttonCodeList; + sysUser.RoleIdList = roleCodeList.Select(it => it.Id).ToHashSet(); + sysUser.PermissionCodeList = permissionCodeList; + sysUser.IsGlobal = roleCodeList.Any(a => a.Category == RoleCategoryEnum.Global); + + + + var sysOrgList = await _sysOrgService.GetAllAsync().ConfigureAwait(false); + var scopeOrgChildList = + (await _sysOrgService.GetChildListByIdAsync(sysUser.OrgId, true, sysOrgList).ConfigureAwait(false)).Select(it => it.Id).ToHashSet();//获取所属机构的下级机构Id列表 + sysUser.ScopeOrgChildList = scopeOrgChildList; + var tenantId = await _sysOrgService.GetTenantIdByOrgIdAsync(sysUser.OrgId, sysOrgList).ConfigureAwait(false); + sysUser.TenantId = tenantId; + + if (sysUser.Account == RoleConst.SuperAdmin) + { + var modules = (await _sysResourceService.GetAllAsync().ConfigureAwait(false)).Where(a => a.Category == ResourceCategoryEnum.Module); + sysUser.ModuleList = modules.ToList();//模块列表赋值给用户 + } + else + { + var moduleIds = await _relationService.GetUserModuleId(sysUser.RoleIdList, sysUser.Id).ConfigureAwait(false);//获取模块ID列表 + var modules = await _sysResourceService.GetMuduleByMuduleIdsAsync(moduleIds).ConfigureAwait(false);//获取模块列表 + sysUser.ModuleList = modules.ToList();//模块列表赋值给用户 + } + + //插入Cache + App.CacheService.HashAdd(CacheConst.Cache_SysUserAccount, sysUser.Account, sysUser.Id); + App.CacheService.HashAdd(CacheConst.Cache_SysUser, sysUser.Id.ToString(), sysUser); + + return sysUser; + } + return null; + } + + #endregion 方法 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/Dto/UserCenterInput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/Dto/UserCenterInput.cs new file mode 100644 index 000000000..223034caf --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/Dto/UserCenterInput.cs @@ -0,0 +1,46 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Admin.Application; + +/// +/// 更新个人工作台 +/// +public class WorkbenchInfo : PagePolicy +{ + public long Id { get; set; } +} + +/// +/// 修改密码 +/// +public class UpdatePasswordInput +{ + /// + /// 旧密码 + /// + [Required] + public string Password { get; set; } + + /// + /// 新密码 + /// + [Required] + public string NewPassword { get; set; } + + /// + /// 确认密码 + /// + [Required] + public string ConfirmPassword { get; set; } + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/Dto/UserCenterOutput.cs b/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/Dto/UserCenterOutput.cs new file mode 100644 index 000000000..dddc5fc35 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/Dto/UserCenterOutput.cs @@ -0,0 +1,11 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/IUserCenterService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/IUserCenterService.cs new file mode 100644 index 000000000..a4acdbde7 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/IUserCenterService.cs @@ -0,0 +1,50 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public interface IUserCenterService +{ + /// + /// 获取个人工作台 + /// + /// 用户id + /// 个人工作台信息 + Task GetLoginWorkbenchAsync(long userId); + + /// + /// 获取菜单列表,不会转成树形数据 + /// + /// 用户id + /// 模块id + /// 菜单列表 + Task> GetOwnMenuAsync(long userId, long moduleId); + + /// + /// 更新密码 + /// + /// 密码更新输入 + /// + Task UpdatePasswordAsync(UpdatePasswordInput input); + + /// + /// 更新用户信息 + /// + /// 用户信息 + /// + Task UpdateUserInfoAsync(SysUser input); + + /// + /// 更新个人工作台信息 + /// + /// 个人工作台信息 + /// + Task UpdateWorkbenchInfoAsync(WorkbenchInfo input); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/UserCenterService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/UserCenterService.cs new file mode 100644 index 000000000..b6e6a4670 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/UserCenter/UserCenterService.cs @@ -0,0 +1,229 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using System.Text.RegularExpressions; + +using ThingsGateway.DataEncryption; +using ThingsGateway.Extension; +using ThingsGateway.Extension.Generic; +using ThingsGateway.FriendlyException; +using ThingsGateway.NewLife.Json.Extension; +using ThingsGateway.Razor; + +namespace ThingsGateway.Admin.Application; + +internal sealed class UserCenterService : BaseService, IUserCenterService +{ + private readonly ISysDictService _configService; + private readonly IRelationService _relationService; + private readonly ISysResourceService _sysResourceService; + private readonly ISysUserService _userService; + private readonly IVerificatInfoService _verificatInfoService; + + public UserCenterService(ISysUserService userService, + IRelationService relationService, + IVerificatInfoService verificatInfoService, + + ISysResourceService sysResourceService, + ISysDictService configService) + { + _userService = userService; + _relationService = relationService; + _sysResourceService = sysResourceService; + _configService = configService; + _verificatInfoService = verificatInfoService; + } + + #region 查询 + + /// + /// 获取个人工作台 + /// + /// 用户id + public async Task GetLoginWorkbenchAsync(long userId) + { + AppConfig? appConfig = null; + WorkbenchInfo relationUserWorkBench = new(); + relationUserWorkBench.Id = userId; + { + //获取个人工作台信息 + var sysRelations = await _relationService.GetRelationByCategoryAsync(RelationCategoryEnum.UserWorkbenchData).ConfigureAwait(false); + var sysRelation = sysRelations.FirstOrDefault(it => it.ObjectId == userId);//获取个人工作台 + if (sysRelation != null) + { + //如果有数据直接返回个人工作台 + relationUserWorkBench.Shortcuts = sysRelation.ExtJson!.ToLower().FromJsonNetString>(); + } + else + { + //如果没数据去系统配置里取默认的工作台 + appConfig = await _configService.GetAppConfigAsync().ConfigureAwait(false); + relationUserWorkBench.Shortcuts = appConfig.PagePolicy.Shortcuts;//返回工作台信息 + } + } + return relationUserWorkBench; + } + + /// + /// 获取菜单列表,不会转成树形数据 + /// + /// 用户id + /// 模块id + /// 菜单列表 + public async Task> GetOwnMenuAsync(long userId, long moduleId) + { + var result = new List(); + //获取用户信息 + var userInfo = await _userService.GetUserByIdAsync(userId).ConfigureAwait(false); + if (userInfo != null) + { + //获取用户所拥有的资源集合 + var resourceList = await _relationService.GetRelationListByObjectIdAndCategoryAsync(userInfo.Id, RelationCategoryEnum.UserHasResource).ConfigureAwait(false); + if (!resourceList.Any())//如果没有就获取角色的 + //获取角色所拥有的资源集合 + resourceList = await _relationService.GetRelationListByObjectIdListAndCategoryAsync(userInfo.RoleIdList!, + RelationCategoryEnum.RoleHasResource).ConfigureAwait(false); + + var all = await _sysResourceService.GetAllAsync().ConfigureAwait(false); + + //定义菜单ID列表 + var menuIdList = resourceList.Select(r => r.TargetId.ToLong()); + + //获取所有的菜单 ,并按分类和排序码排序 //首页例外 + var allMenuList = (all).Where(a => a.Category == ResourceCategoryEnum.Menu + ).WhereIf(moduleId > 0, a => a.Module == moduleId).OrderBy(a => a.Module).ThenBy(a => a.SortCode); + + //输出的用户权限菜单 + IEnumerable myMenus; + + //管理员拥有全部权限 + if (UserManager.SuperAdmin) + { + myMenus = allMenuList; + } + else + { + //获取我的菜单列表 + myMenus = allMenuList.Where(it => menuIdList.Contains(it.Id)); + } + + // 对获取到的角色对应的菜单列表进行处理,获取父列表 + var parentList = _sysResourceService.GetMyParentResources(allMenuList, myMenus); + + return myMenus.Concat(parentList); + } + return Enumerable.Empty(); + } + + #endregion 查询 + + #region 编辑 + + /// + [OperDesc("UpdatePassword", isRecordPar: false)] + public async Task UpdatePasswordAsync(UpdatePasswordInput input) + { + var websiteOptions = App.GetOptions()!; + if (websiteOptions.Demo) + { + throw Oops.Bah(Localizer["DemoCanotUpdatePassword"]); + } + if (input.NewPassword != input.ConfirmPassword) + throw Oops.Bah(Localizer["ConfirmPasswordDiff"]); + + //获取用户信息 + var userInfo = await _userService.GetUserByIdAsync(UserManager.UserId).ConfigureAwait(false); + var password = input.Password; + if (userInfo!.Password != password) + throw Oops.Bah(Localizer["OldPasswordError"]); + + var passwordPolicy = (await _configService.GetAppConfigAsync().ConfigureAwait(false)).PasswordPolicy; + if (passwordPolicy.PasswordMinLen > input.NewPassword.Length) + throw Oops.Bah(Localizer["PasswordLengthLess", passwordPolicy.PasswordMinLen]); + if (passwordPolicy.PasswordContainNum && !Regex.IsMatch(input.NewPassword, "[0-9]")) + throw Oops.Bah(Localizer["PasswordMustNum"]); + if (passwordPolicy.PasswordContainLower && !Regex.IsMatch(input.NewPassword, "[a-z]")) + throw Oops.Bah(Localizer["PasswordMustLow"]); + if (passwordPolicy.PasswordContainUpper && !Regex.IsMatch(input.NewPassword, "[A-Z]")) + throw Oops.Bah(Localizer["PasswordMustUpp"]); + if (passwordPolicy.PasswordContainChar && !Regex.IsMatch(input.NewPassword, "[~!@#$%^&*()_+`\\-={}|\\[\\]:\";'<>?,./]")) + throw Oops.Bah(Localizer["PasswordMustSpecial"]); + + var newPassword = DESEncryption.Encrypt(input.NewPassword); + using var db = GetDB(); + await db.UpdateSetColumnsTrueAsync(it => new SysUser() { Password = newPassword }, it => it.Id == userInfo.Id).ConfigureAwait(false); + _userService.DeleteUserFromCache(UserManager.UserId);//cache删除用户数据 + + //将这些用户踢下线,并永久注销这些用户 + var verificatInfoIds = _verificatInfoService.GetListByUserId(UserManager.UserId); + //从列表中删除 + _verificatInfoService.Delete(verificatInfoIds.Select(a => a.Id).ToList()); + await UserLoginOut(UserManager.UserId, verificatInfoIds.SelectMany(a => a.ClientIds).ToList()).ConfigureAwait(false); + } + + /// + [OperDesc("UpdateUserInfo", false)] + public async Task UpdateUserInfoAsync(SysUser input) + { + if (!string.IsNullOrEmpty(input.Phone)) + { + if (!input.Phone.MatchPhoneNumber())//判断是否是手机号格式 + throw Oops.Bah(Localizer["PhoneError", input.Phone]); + } + if (!string.IsNullOrEmpty(input.Email)) + { + var match = input.Email.MatchEmail(); + if (!match) + throw Oops.Bah(Localizer["EmailError", input.Email]); + } + using var db = GetDB(); + + //更新指定字段 + var result = await db.UpdateSetColumnsTrueAsync(it => new SysUser + { + Email = input.Email, + Phone = input.Phone, + Avatar = input.Avatar, + }, it => it.Id == UserManager.UserId).ConfigureAwait(false); + if (result) + _userService.DeleteUserFromCache(UserManager.UserId);//cache删除用户数据 + } + + /// + [OperDesc("WorkbenchInfo")] + public async Task UpdateWorkbenchInfoAsync(WorkbenchInfo input) + { + //关系表保存个人工作台 + await _relationService.SaveRelationAsync(RelationCategoryEnum.UserWorkbenchData, input.Id, null, input.Shortcuts.ToJsonNetString(), + true).ConfigureAwait(false); + } + + #endregion 编辑 + + #region 方法 + + /// + /// 通知用户下线 + /// + /// 用户ID + /// Token列表 + private async Task UserLoginOut(long userId, List clientIds) + { + await NoticeUtil.UserLoginOut(new UserLoginOutEvent + { + Message = Localizer["PasswordEdited"], + ClientIds = clientIds, + }).ConfigureAwait(false);//通知用户下线 + } + + #endregion 方法 +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/VerificatInfo/IVerificatInfoService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/VerificatInfo/IVerificatInfoService.cs new file mode 100644 index 000000000..ea1530278 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/VerificatInfo/IVerificatInfoService.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +public interface IVerificatInfoService +{ + void Add(VerificatInfo verificatInfo); + + void Delete(long Id); + + void Delete(List ids); + + List? GetClientIdListByUserId(long userId); + + List? GetIdListByUserId(long userId); + + List? GetListByIds(List ids); + + List? GetListByUserId(long userId); + + List? GetListByUserIds(List userIds); + + VerificatInfo GetOne(long id); + + void RemoveAllClientId(); + + void Update(VerificatInfo verificatInfo); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Services/VerificatInfo/VerificatInfoService.cs b/src/Admin/ThingsGateway.Admin.Application/Services/VerificatInfo/VerificatInfoService.cs new file mode 100644 index 000000000..e0a8fe090 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Services/VerificatInfo/VerificatInfoService.cs @@ -0,0 +1,185 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +using ThingsGateway.List; +using ThingsGateway.NewLife.Json.Extension; + +namespace ThingsGateway.Admin.Application; + +/// +/// 操作内存,只在程序停止/启动时设置/获取持久化数据 +/// +internal sealed class VerificatInfoService : BaseService, IVerificatInfoService +{ + #region 查询 + + public VerificatInfo GetOne(long id) + { + //先从Cache拿 + var verificatInfo = App.CacheService.HashGetOne(CacheConst.Cache_Token, id.ToString()); + verificatInfo ??= GetFromDb(id); + if (verificatInfo != null) + if (verificatInfo.VerificatTimeout.AddSeconds(30) < DateTime.Now) + { + Delete(verificatInfo.Id); + return null; + } + return verificatInfo; + } + + private VerificatInfo? GetFromDb(long id) + { + using var db = GetDB(); + var verificatInfo = db.Queryable().First(u => u.Id == id); + if (verificatInfo != null) + VerificatInfoService.SetCahce(verificatInfo); + return verificatInfo; + } + + private static void SetCahce(VerificatInfo verificatInfo) + { + App.CacheService.HashAdd(CacheConst.Cache_Token, verificatInfo.Id.ToString(), verificatInfo); + } + + public List? GetListByUserId(long userId) + { + using var db = GetDB(); + var verificatInfo = db.Queryable().Where(u => u.UserId == userId).ToList(); + return verificatInfo; + } + + public List? GetListByIds(List ids) + { + using var db = GetDB(); + var verificatInfos = db.Queryable().Where(u => ids.Contains(u.Id)).ToList(); + var ids1 = new List(); + foreach (var verificatInfo in verificatInfos) + { + if (verificatInfo.VerificatTimeout.AddSeconds(30) < DateTime.Now) + { + ids1.Add(verificatInfo.Id); + } + } + + if (ids1.Count > 0) + { + Delete(ids1); + } + return verificatInfos; + } + + public List? GetListByUserIds(List userIds) + { + using var db = GetDB(); + var verificatInfos = db.Queryable().Where(u => userIds.Contains(u.UserId)).ToList(); + + List ids = new List(); + foreach (var verificatInfo in verificatInfos) + { + if (verificatInfo.VerificatTimeout.AddSeconds(30) < DateTime.Now) + { + ids.Add(verificatInfo.Id); + } + } + if (ids.Count > 0) + { + Delete(ids); + } + return verificatInfos; + } + + public List? GetIdListByUserId(long userId) + { + using var db = GetDB(); + var verificatInfo = db.Queryable().Where(u => u.UserId == userId).Select(a => a.Id).ToList(); + + return verificatInfo; + } + + public List? GetClientIdListByUserId(long userId) + { + using var db = GetDB(); + var verificatInfo = db.Queryable().Where(u => u.UserId == userId).Select(a => a.ClientIds).ToList().SelectMany(a => a).ToList(); + + return verificatInfo; + } + + #endregion 查询 + + #region 添加 + + public void Add(VerificatInfo verificatInfo) + { + using var db = GetDB(); + db.Insertable(verificatInfo).ExecuteCommand(); + VerificatInfoService.RemoveCache(verificatInfo.Id); + if (verificatInfo != null) + VerificatInfoService.SetCahce(verificatInfo); + } + + #endregion 添加 + + #region 更新 + + public void Update(VerificatInfo verificatInfo) + { + using var db = GetDB(); + db.Updateable(verificatInfo).ExecuteCommand(); + VerificatInfoService.RemoveCache(verificatInfo.Id); + if (verificatInfo != null) + VerificatInfoService.SetCahce(verificatInfo); + } + + #endregion 更新 + + #region 删除 + + public void Delete(long id) + { + using var db = GetDB(); + db.Deleteable(id).ExecuteCommand(); + VerificatInfoService.RemoveCache(id); + } + + public void Delete(List ids) + { + using var db = GetDB(); + db.Deleteable().Where(it => ids.Contains(it.Id)).ExecuteCommand(); + foreach (var id in ids) + { + VerificatInfoService.RemoveCache(id); + } + } + + #endregion 删除 + + #region 去除全部在线Id + + public void RemoveAllClientId() + { + using var db = GetDB(); + db.Updateable().SetColumns("ClientIds", new ConcurrentList().ToJsonNetString()).Where(a => a.Id >= 0).ExecuteCommand(); + VerificatInfoService.RemoveCache(); + } + + #endregion 去除全部在线Id + + private static void RemoveCache() + { + App.CacheService.Remove(CacheConst.Cache_Token); + } + + private static void RemoveCache(long id) + { + App.CacheService.HashDel(CacheConst.Cache_Token, id.ToString()); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SqlSugar/CodeFirstUtils.cs b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/CodeFirstUtils.cs new file mode 100644 index 000000000..87cd64ff9 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/CodeFirstUtils.cs @@ -0,0 +1,109 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +using System.Collections; +using System.Data; +using System.Reflection; + +namespace ThingsGateway.Admin.Application; + +/// +/// CodeFirst功能类 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class CodeFirstUtils +{ + /// + /// CodeFirst生成数据库表结构和种子数据 + /// + /// 程序集名称 + public static void CodeFirst(string assemblyName) + { + InitTable(assemblyName); + InitSeedData(assemblyName); + } + + /// + /// 初始化种子数据 + /// + /// 程序集名称 + private static void InitSeedData(string assemblyName) + { + // 获取所有种子配置-初始化数据 + var seedDataTypes = App.EffectiveTypes + .Where(u => !u.IsInterface && !u.IsAbstract && u.IsClass + && u.GetInterfaces().Any(i => i.HasImplementedRawGeneric(typeof(ISqlSugarEntitySeedData<>))) && u.Assembly.FullName == assemblyName); + if (!seedDataTypes.Any()) return; + foreach (var seedType in seedDataTypes)//遍历种子类 + { + //使用与指定参数匹配程度最高的构造函数来创建指定类型的实例。 + var instance = Activator.CreateInstance(seedType); + //获取SeedData方法 + var seedDataMethod = seedType.GetMethod("SeedData"); + //判断是否有种子数据 + var seedData = ((IEnumerable)seedDataMethod?.Invoke(instance, null)!)?.Cast(); + if (seedData == null) continue;//没有种子数据就下一个 + var entityType = seedType.GetInterfaces().First().GetGenericArguments().First();//获取实体类型 + var tenantAtt = entityType.GetCustomAttribute();//获取sqlSugar多库特性 + if (tenantAtt == null) continue;//如果没有多库特性就下一个 + using var db = DbContext.Db.GetConnectionScope(tenantAtt.configId.ToString()).CopyNew();//获取数据库对象 + var config = DbContext.DbConfigs.FirstOrDefault(u => u.ConfigId.ToString() == tenantAtt.configId.ToString());//获取数据库配置 + if (config?.InitSeedData != true) continue; + var entityInfo = db.EntityMaintenance.GetEntityInfo(entityType); + // seedDataTable.TableName = db.EntityMaintenance.GetEntityInfo(entityType).DbTableName;//获取表名 + var ignoreAdd = seedDataMethod!.GetCustomAttribute();//读取忽略插入特性 + var ignoreUpdate = seedDataMethod!.GetCustomAttribute();//读取忽略更新特性 + if (entityInfo.Columns.Any(u => u.IsPrimarykey))//判断种子数据是否有主键 + { + // 按主键进行批量增加和更新 + var storage = db.StorageableByObject(seedData.ToList()).ToStorage(); + if (ignoreAdd == null) storage.AsInsertable.ExecuteCommand();//执行插入 + if (ignoreUpdate == null && config.IsUpdateSeedData) storage.AsUpdateable.ExecuteCommand();//只有没有忽略更新的特性才执行更新 + } + else// 没有主键或者不是预定义的主键(有重复的可能) + { + //全量插入 + // 无主键则只进行插入 + if (!db.Queryable(entityInfo.DbTableName, entityInfo.DbTableName).Any() && ignoreAdd == null) + db.InsertableByObject(seedData.ToList()).ExecuteCommand(); + } + } + } + + /// + /// 初始化数据库表结构 + /// + /// 程序集名称 + private static void InitTable(string assemblyName) + { + + // 获取所有实体表-初始化表结构 + var entityTypes = App.EffectiveTypes.Where(u => + !u.IsInterface && !u.IsAbstract && u.IsClass && u.IsDefined(typeof(SugarTable), false) && u.Assembly.FullName == assemblyName); + if (!entityTypes.Any()) return;//没有就退出 + foreach (var entityType in entityTypes) + { + var tenantAtt = entityType.GetCustomAttribute();//获取Sqlsugar多库特性 + var config = DbContext.DbConfigs?.FirstOrDefault(u => u.ConfigId.ToString() == tenantAtt?.configId.ToString());//获取数据库配置 + if (config?.InitTable != true) continue; + var ignoreInit = entityType.GetCustomAttribute();//获取忽略初始化特性 + if (ignoreInit != null) continue;//如果有忽略初始化特性 + if (tenantAtt == null) continue;//如果没有多库特性就下一个 + using var db = DbContext.Db.GetConnectionScope(tenantAtt.configId.ToString()).CopyNew();//获取数据库对象 + var splitTable = entityType.GetCustomAttribute();//获取自动分表特性 + if (splitTable == null)//如果特性是空 + db.CodeFirst.InitTables(entityType);//普通创建 + else + db.CodeFirst.SplitTables().InitTables(entityType);//自动分表创建 + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SqlSugar/DbContext.cs b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/DbContext.cs new file mode 100644 index 000000000..46f0990c2 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/DbContext.cs @@ -0,0 +1,105 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +using SqlSugar; + +using ThingsGateway.Extension; + +namespace ThingsGateway.Admin.Application; + +/// +/// 数据库上下文对象 +/// +public static class DbContext +{ + /// + /// SqlSugar 数据库实例 + /// + public static readonly SqlSugarScope Db; + + /// + /// 读取配置文件中的 ConnectionStrings:Sqlsugar 配置节点 + /// + public static readonly SqlSugarOptions DbConfigs; + + /// + /// 获取数据库连接 + /// + /// + public static SqlSugarClient GetDB() + { + return Db.GetConnectionScopeWithAttr().CopyNew(); + } + + private static ISugarAopService sugarAopService; + private static ISugarAopService SugarAopService + { + get + { + if (sugarAopService == null) + { + sugarAopService = App.RootServices.GetService(); + } + return sugarAopService; + } + } + + static DbContext() + { + // 配置映射 + DbConfigs = App.GetOptions(); + DbConfigs = App.RootServices.GetService().Config(DbConfigs); + Db = new(DbConfigs.Select(a => (ConnectionConfig)a).ToList(), db => + { + DbConfigs.ForEach(it => + { + var sqlsugarScope = db.GetConnectionScope(it.ConfigId);//获取当前库 + MoreSetting(sqlsugarScope);//更多设置 + SugarAopService.AopSetting(sqlsugarScope, it.IsShowSql);//aop配置 + } + ); + }); + } + + + /// + /// 实体更多配置 + /// + /// + private static void MoreSetting(SqlSugarScopeProvider db) + { + db.CurrentConnectionConfig.MoreSettings = new ConnMoreSettings + { + SqlServerCodeFirstNvarchar = true//设置默认nvarchar + }; + } + + /// + public static void WriteErrorLogWithSql(string msg) + { + Console.WriteLine("【Sql执行错误时间】:" + DateTime.Now.ToDefaultDateTimeFormat()); + Console.WriteLine("【Sql语句】:" + msg + Environment.NewLine); + } + + /// + public static void WriteLog(string msg) + { + Console.WriteLine("【库操作】:" + msg + Environment.NewLine); + } + + /// + public static void WriteLogWithSql(string msg) + { + Console.WriteLine("【Sql执行时间】:" + DateTime.Now.ToDefaultDateTimeFormat()); + Console.WriteLine("【Sql语句】:" + msg + Environment.NewLine); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SqlSugar/ISqlSugarEntitySeedData.cs b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/ISqlSugarEntitySeedData.cs new file mode 100644 index 000000000..9eff32e73 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/ISqlSugarEntitySeedData.cs @@ -0,0 +1,24 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Application; + +/// +/// 实体种子数据接口 +/// +/// +public interface ISqlSugarEntitySeedData where TEntity : class, new() +{ + /// + /// 种子数据 + /// + /// + IEnumerable SeedData(); +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SqlSugar/SeedDataUtil.cs b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/SeedDataUtil.cs new file mode 100644 index 000000000..45bfc1238 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/SeedDataUtil.cs @@ -0,0 +1,131 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using SqlSugar; + +using System.Reflection; +using System.Text.RegularExpressions; + +using ThingsGateway.NewLife; + +namespace ThingsGateway.Admin.Application; +/// +/// 种子数据工具类 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class SeedDataUtil +{ + /// + /// 获取List列表 + /// + /// + /// + /// + public static List GetSeedData(string jsonName) + { + var basePath = AppContext.BaseDirectory;//获取项目目录 + jsonName = basePath.CombinePathWithOs(jsonName);//获取文件路径 + var dataString = FileUtil.ReadFile(jsonName);//读取文件 + return GetSeedDataByJson(dataString); + } + + public static string GetManifestResourceStream(Assembly assembly, string path) + { + var name = $"{assembly.GetName().Name}.{path}"; + using var readStream = assembly.GetManifestResourceStream(name); + return readStream?.ToStr(); + } + + public static List GetSeedDataByJson(string json) + { + var seedData = new List();//种子数据结果 + if (!string.IsNullOrEmpty(json))//如果有内容 + { + //字段没有数据的替换成null + json = Regex.Replace(json, "\\\"[^\"]+?\\\": \\\"\\\"", match => match.Value.Replace("\"\"", "null")); + + + + var jtoken = JToken.Parse(json); + jtoken = jtoken.SelectToken("Records") ?? jtoken.SelectToken("RECORDS"); + var type = typeof(T); + + foreach (var objectType in type.GetRuntimeProperties()) + { + var isjson = objectType.CustomAttributes.Any(a => a.NamedArguments.Any(b => b.MemberName == nameof(SugarColumn.IsJson) && b.TypedValue.Value?.ToBoolean() == true)); + if (isjson) + { + foreach (var item in jtoken) + { + var value = item[objectType.Name]; + item[objectType.Name] = value?.ToString()?.IsNullOrEmpty() != false ? null : JToken.Parse(value?.ToString() ?? string.Empty); + } + } + else if (objectType.PropertyType.IsBoolean()) + { + foreach (var item in jtoken) + { + var value = item[objectType.Name]; + item[objectType.Name] = value?.ToBoolean(); + } + } + } + + var seedDataRecord = (List)Newtonsoft.Json.JsonConvert.DeserializeObject(jtoken.ToString(), typeof(List), new JsonSerializerSettings + { + Formatting = Formatting.Indented,// 使用缩进格式化输出 + NullValueHandling = NullValueHandling.Ignore, // 忽略空值属性 + Converters = new List { new ZeroAsFalseConverter() } + }); + + //var seedDataRecord = jtoken.ToObject>(); + + seedData = seedDataRecord ?? new(); + } + + return seedData; + } +} + +/// +/// 种子数据格式实体类,遵循Navicat导出json格式 +/// +/// +public class SeedDataRecords +{ + /// + /// 数据 + /// + public List Records { get; set; } +} + +internal sealed class ZeroAsFalseConverter : JsonConverter +{ + public override bool CanConvert(Type objectType) + { + return objectType == typeof(bool); + } + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + var value = reader.Value?.ToString()?.ToBoolean(); + return value; + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString()); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/SqlSugar/SqlSugarOptions.cs b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/SqlSugarOptions.cs new file mode 100644 index 000000000..a260b2ce2 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/SqlSugar/SqlSugarOptions.cs @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Admin.Application; + +/// +/// SqlSugar配置 +/// +public sealed class SqlSugarOption : ConnectionConfig +{ + /// + /// 初始化数据 + /// + public bool InitSeedData { get; set; } = false; + + /// + /// 初始化表 + /// + public bool InitTable { get; set; } = false; + + /// + /// 是否控制台显示Sql语句 + /// + public bool IsShowSql { get; set; } + + /// + /// 更新数据 + /// + public bool IsUpdateSeedData { get; set; } = false; +} + +/// +/// SqlSugar配置 +/// +public class SqlSugarOptions : List, IConfigurableOptions +{ +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Startup.cs b/src/Admin/ThingsGateway.Admin.Application/Startup.cs new file mode 100644 index 000000000..093f51585 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Startup.cs @@ -0,0 +1,104 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.Extensions.DependencyInjection; + +using SqlSugar; + +using System.Reflection; + +using ThingsGateway.UnifyResult; + +namespace ThingsGateway.Admin.Application; + +[AppStartup(1000000000)] +public class Startup : AppStartup +{ + public void ConfigureAdminApp(IServiceCollection services) + { + services.AddConfigurableOptions(); + services.AddConfigurableOptions(); + services.AddConfigurableOptions(); + + services.AddSingleton(typeof(IDataService<>), typeof(BaseService<>)); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + + StaticConfig.EnableAllWhereIF = true; + + services.AddConfigurableOptions(); + services.AddConfigurableOptions(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + + services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => serviceProvider.GetService()); + + services.AddSingleton(typeof(IEventService<>), typeof(EventService<>)); + + } + + public void UseAdminCore(IServiceProvider serviceProvider) + { + //检查ConfigId + var configIdGroup = DbContext.DbConfigs.GroupBy(it => it.ConfigId); + foreach (var configId in configIdGroup) + { + if (configId.Count() > 1) throw new($"Sqlsugar connect configId: {configId.Key} Duplicate!"); + } + + //遍历配置 + DbContext.DbConfigs?.ForEach(it => + { + var connection = DbContext.Db.GetConnection(it.ConfigId);//获取数据库连接对象 + if (it.InitTable == true) + connection.DbMaintenance.CreateDatabase();//创建数据库,如果存在则不创建 + }); + + var fullName = Assembly.GetExecutingAssembly().FullName;//获取程序集全名 + CodeFirstUtils.CodeFirst(fullName!);//CodeFirst + + + //删除在线用户统计 + var verificatInfoService = App.RootServices.GetService(); + verificatInfoService.RemoveAllClientId(); + + + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Static/UserManager.cs b/src/Admin/ThingsGateway.Admin.Application/Static/UserManager.cs new file mode 100644 index 000000000..6dfb9e33d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Static/UserManager.cs @@ -0,0 +1,49 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +namespace ThingsGateway.Admin.Application; + +/// +/// 当前登录用户信息 +/// +public static class UserManager +{ + private static readonly IAppService _appService; + static UserManager() + { + _appService = App.RootServices.GetService(); + } + /// + /// 是否超级管理员 + /// + public static bool SuperAdmin => (_appService.User?.FindFirst(ClaimConst.SuperAdmin)?.Value).ToBoolean(false); + + /// + /// 当前用户账号 + /// + public static string UserAccount => _appService.User?.FindFirst(ClaimConst.Account)?.Value; + + /// + /// 当前用户Id + /// + public static long UserId => (_appService.User?.FindFirst(ClaimConst.UserId)?.Value).ToLong(); + + /// + /// 当前验证Id + /// + public static long VerificatId => (_appService.User?.FindFirst(ClaimConst.VerificatId)?.Value).ToLong(); + + public static long OrgId => (_appService.User?.FindFirst(ClaimConst.OrgId)?.Value).ToLong(); + + public static long TenantId => (_appService.User?.FindFirst(ClaimConst.TenantId)?.Value)?.ToLong() ?? 0; + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/ThingsGateway.Admin.Application.csproj b/src/Admin/ThingsGateway.Admin.Application/ThingsGateway.Admin.Application.csproj new file mode 100644 index 000000000..c6ea3cb53 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/ThingsGateway.Admin.Application.csproj @@ -0,0 +1,52 @@ + + + + + + + True + + + net9.0;net8.0; + + + + + + Never + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + + + + + + diff --git a/src/Admin/ThingsGateway.Admin.Application/Util/ClearTokenUtil.cs b/src/Admin/ThingsGateway.Admin.Application/Util/ClearTokenUtil.cs new file mode 100644 index 000000000..d7c6c03c4 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Util/ClearTokenUtil.cs @@ -0,0 +1,47 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +namespace ThingsGateway.Admin.Application; + +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class ClearTokenUtil +{ + private static IRelationService RelationService; + private static ISysUserService SysUserService; + + /// + /// 根据角色ID列表清除用户缓存 + /// + public static async Task DeleteUserCacheByRoleIds(IEnumerable roleIds) + { + // 解析角色服务 + RelationService ??= App.RootServices!.GetRequiredService(); + //获取用户和角色关系 + var relations = await RelationService.GetRelationListByTargetIdListAndCategoryAsync(roleIds.Select(it => it.ToString()), RelationCategoryEnum.UserHasRole).ConfigureAwait(false); + if (relations.Any()) + { + var userIds = relations.Select(it => it.ObjectId);//用户ID列表 + + // 解析用户服务 + SysUserService ??= App.RootServices!.GetRequiredService(); + SysUserService.DeleteUserFromCache(userIds); + } + } + + public static async Task DeleteUserTokenByOrgIds(HashSet orgIds) + { + // 获取用户ID列表 + var userIds = await DbContext.Db.CopyNew().QueryableWithAttr().Where(it => orgIds.Contains(it.OrgId)).Select(it => it.Id).ToListAsync().ConfigureAwait(false); + //从redis中删除所属机构的用户token + App.CacheService.HashDel(CacheConst.Cache_Token, userIds.Select(it => it.ToString()).ToArray()); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Util/CommonUtils.cs b/src/Admin/ThingsGateway.Admin.Application/Util/CommonUtils.cs new file mode 100644 index 000000000..26c084b4e --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Util/CommonUtils.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using Yitter.IdGenerator; + +namespace ThingsGateway.Admin.Application; + +/// +/// 公共功能 +/// +public static class CommonUtils +{ + /// + /// 获取唯一Id + /// + /// + public static long GetSingleId() + { + return YitIdHelper.NextId(); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Util/ImportExportUtil.cs b/src/Admin/ThingsGateway.Admin.Application/Util/ImportExportUtil.cs new file mode 100644 index 000000000..f4070d2b0 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Util/ImportExportUtil.cs @@ -0,0 +1,42 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using ThingsGateway.NewLife; + +namespace ThingsGateway.Admin.Application; + +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class ImportExportUtil +{ + public static string GetFileDir(ref string fileName) + { + if (!fileName.Contains('.')) + fileName += ".xlsx"; + + var path = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "exports"); + Directory.CreateDirectory(path); + + string searchPattern = $"*{fileName}"; // 文件名匹配模式 + string[] files = Directory.GetFiles(path, searchPattern); + + //删除同后缀的文件 + var whereFiles = files.Where(file => File.GetLastWriteTime(file) < DateTime.Now.AddMinutes(-2)); + + foreach (var file in whereFiles) + { + FileUtil.DeleteFile(file); + } + + return path; + } + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Util/NoticeUtil.cs b/src/Admin/ThingsGateway.Admin.Application/Util/NoticeUtil.cs new file mode 100644 index 000000000..8ad47f8d2 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Util/NoticeUtil.cs @@ -0,0 +1,64 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +namespace ThingsGateway.Admin.Application; + +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class NoticeUtil +{ + private static INoticeService NoticeService; + + /// + /// 通知用户下线事件 + /// + /// + /// + public static async Task UserLoginOut(UserLoginOutEvent userLoginOutEvent) + { + NoticeService ??= App.RootServices!.GetRequiredService();//获取服务 + //遍历verificat列表获取客户端ID列表 + await NoticeService.UserLoginOut(userLoginOutEvent?.ClientIds, userLoginOutEvent!.Message).ConfigureAwait(false);//发送消息 + } +} + +/// +/// 用户登出事件 +/// +public class UserLoginOutEvent +{ + /// + /// verificat信息 + /// + + public List? ClientIds { get; set; } + + /// + /// 内容 + /// + public string Message { get; set; } +} + +/// +/// 新消息事件 +/// +public class NewMessageEvent +{ + /// + /// 内容 + /// + public AppMessage Message { get; set; } + + /// + /// 用户Id + /// + public List UserIds { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Util/OpenApiUtil.cs b/src/Admin/ThingsGateway.Admin.Application/Util/OpenApiUtil.cs new file mode 100644 index 000000000..7e7b6ba11 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Util/OpenApiUtil.cs @@ -0,0 +1,44 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class OpenApiUtil +{ + /// + /// 构建树节点,传入的列表已经是树结构 + /// + public static List> BuildTreeItemList(IEnumerable openApiPermissionTreeSelectors, List selectedItems, Microsoft.AspNetCore.Components.RenderFragment render, TreeViewItem? parent = null) + { + if (openApiPermissionTreeSelectors == null) return null; + var trees = new List>(); + foreach (var node in openApiPermissionTreeSelectors) + { + var item = new TreeViewItem(node) + { + Text = node.ApiRoute, + IsActive = selectedItems.Any(v => node.ApiRoute == v), + Parent = parent, + IsExpand = true, + Template = render, + CheckedState = selectedItems.Any(i => i == node.ApiRoute) ? CheckboxState.Checked : CheckboxState.UnChecked + }; + item.Items = BuildTreeItemList(node.Children, selectedItems, render, item) ?? new(); + trees.Add(item); + } + return trees; + } + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Util/OrgUtil.cs b/src/Admin/ThingsGateway.Admin.Application/Util/OrgUtil.cs new file mode 100644 index 000000000..3461b7875 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Util/OrgUtil.cs @@ -0,0 +1,104 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class OrgUtil +{ + /// + /// 构造选择项,ID/TITLE + /// + /// + /// + public static IEnumerable BuildOrgSelectList(IEnumerable items) + { + var data = items + .Select((item, index) => + new SelectedItem(item.Id.ToString(), item.Name) + { + } + ).ToList(); + return data; + } + /// + /// 构造树形数据 + /// + /// 资源列表 + /// 父ID + /// + public static IEnumerable> BuildTableTrees(IEnumerable items, long parentId = 0) + { + return items + .Where(it => it.ParentId == parentId) + .Select((item, index) => + new TableTreeNode(item) + { + HasChildren = items.Any(i => i.ParentId == item.Id), + IsExpand = items.Any(i => i.ParentId == item.Id), + Items = BuildTableTrees(items, item.Id).ToList() + } + ); + } + + /// + /// 构建树节点 + /// + public static List> BuildTreeItemList(IEnumerable sysresources, List selectedItems, Microsoft.AspNetCore.Components.RenderFragment render = null, long parentId = 0, TreeViewItem? parent = null) + { + if (sysresources == null) return null; + var trees = new List>(); + var roots = sysresources.Where(i => i.ParentId == parentId).OrderBy(i => i.SortCode); + foreach (var node in roots) + { + var item = new TreeViewItem(node) + { + Text = node.Name, + IsActive = selectedItems.Contains(node.Id), + IsExpand = true, + Parent = parent, + Template = render, + CheckedState = selectedItems.Contains(node.Id) ? CheckboxState.Checked : CheckboxState.UnChecked + }; + item.Items = BuildTreeItemList(sysresources, selectedItems, render, node.Id, item) ?? new(); + trees.Add(item); + } + return trees; + } + + /// + /// 构建树节点 + /// + public static List> BuildTreeIdItemList(IEnumerable sysresources, List selectedItems, Microsoft.AspNetCore.Components.RenderFragment render = null, long parentId = 0, TreeViewItem? parent = null) + { + if (sysresources == null) return null; + var trees = new List>(); + var roots = sysresources.Where(i => i.ParentId == parentId).OrderBy(i => i.SortCode); + foreach (var node in roots) + { + var item = new TreeViewItem(node.Id) + { + Text = node.Name, + IsActive = selectedItems.Contains(node.Id), + IsExpand = true, + Parent = parent, + Template = render, + CheckedState = selectedItems.Contains(node.Id) ? CheckboxState.Checked : CheckboxState.UnChecked + }; + item.Items = BuildTreeIdItemList(sysresources, selectedItems, render, node.Id, item) ?? new(); + trees.Add(item); + } + return trees; + } + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Util/PositionUtil.cs b/src/Admin/ThingsGateway.Admin.Application/Util/PositionUtil.cs new file mode 100644 index 000000000..32c765d88 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Util/PositionUtil.cs @@ -0,0 +1,67 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class PositionUtil +{ + + /// + /// 构建树节点 + /// + public static List> BuildTreeItemList(IEnumerable sysresources, List selectedItems, Microsoft.AspNetCore.Components.RenderFragment render = null, TreeViewItem? parent = null) + { + if (sysresources == null) return null; + var trees = new List>(); + foreach (var node in sysresources) + { + var item = new TreeViewItem(node) + { + Text = node.Name, + IsActive = selectedItems.Contains(node.Id), + IsExpand = true, + Parent = parent, + Template = render, + CheckedState = selectedItems.Contains(node.Id) ? CheckboxState.Checked : CheckboxState.UnChecked + }; + item.Items = BuildTreeItemList(node.Children, selectedItems, render, item) ?? new(); + trees.Add(item); + } + return trees; + } + + + public static List BuildCascaderItemList(IEnumerable sysresources) + { + if (sysresources == null) return null; + var trees = new List(); + foreach (var node in sysresources) + { + var item = new CascaderItem() + { + Text = node.Name, + Value = node.Id.ToString(), + }; + var data = BuildCascaderItemList(node.Children); + foreach (var children in data) + { + item.AddItem(children); + } + trees.Add(item); + } + return trees; + } + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Util/RoleUtil.cs b/src/Admin/ThingsGateway.Admin.Application/Util/RoleUtil.cs new file mode 100644 index 000000000..35699ea48 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Util/RoleUtil.cs @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class RoleUtil +{ + + + /// + /// 构建树节点 + /// + public static List> BuildTreeItemList(IEnumerable sysresources, List selectedItems, Microsoft.AspNetCore.Components.RenderFragment render = null, TreeViewItem? parent = null) + { + if (sysresources == null) return null; + var trees = new List>(); + foreach (var node in sysresources) + { + var item = new TreeViewItem(node) + { + Text = node.Name, + IsActive = selectedItems.Contains(node.Id), + IsExpand = true, + Parent = parent, + Template = render, + CheckedState = selectedItems.Contains(node.Id) ? CheckboxState.Checked : CheckboxState.UnChecked + }; + item.Items = BuildTreeItemList(node.Children, selectedItems, render, item) ?? new(); + trees.Add(item); + } + return trees; + } + + + + +} diff --git a/src/Admin/ThingsGateway.Admin.Application/Util/UserUtil.cs b/src/Admin/ThingsGateway.Admin.Application/Util/UserUtil.cs new file mode 100644 index 000000000..4a3998afd --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Application/Util/UserUtil.cs @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Admin.Application; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class UserUtil +{ + + /// + /// 构造选择项,ID/TITLE + /// + /// + /// + public static IEnumerable BuildUserSelectList(IEnumerable items) + { + var data = items + .Select((item, index) => + new SelectedItem(item.Id.ToString(), item.Account) + { + } + ).ToList(); + return data; + } + + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/AdminTable.razor b/src/Admin/ThingsGateway.Admin.Razor/Components/AdminTable.razor new file mode 100644 index 000000000..a04ca8de7 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/AdminTable.razor @@ -0,0 +1,41 @@ +@namespace ThingsGateway.Admin.Razor +@typeparam TItem + + +
diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/AdminTable.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Components/AdminTable.razor.cs new file mode 100644 index 000000000..90fd96dd6 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/AdminTable.razor.cs @@ -0,0 +1,415 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Razor; + +[CascadingTypeParameter(nameof(TItem))] +public partial class AdminTable where TItem : class, new() +{ + + /// + [Parameter] + public bool DoubleClickToEdit { get; set; } = false; + /// + [Parameter] + public Func? OnDoubleClickCellCallback { get; set; } + /// + [Parameter] + public Func? OnDoubleClickRowCallback { get; set; } + /// + [Parameter] + public Func? OnClickRowCallback { get; set; } + + + /// + [Parameter] + public bool AllowDragColumn { get; set; } = false; + + /// + [Parameter] + public bool AllowResizing { get; set; } = false; + + /// + [Parameter] + public bool AutoGenerateColumns { get; set; } + + /// + [Parameter] + public int AutoRefreshInterval { get; set; } + + /// + [Parameter] + public RenderFragment? BeforeRowButtonTemplate { get; set; } + + /// + [Parameter] + public bool ClickToSelect { get; set; } + + /// + /// + /// + [Parameter] + public string? ClientTableName { get; set; } + + /// + [Parameter] + public ITableSearchModel? CustomerSearchModel { get; set; } + + /// + [Parameter] + public RenderFragment? CustomerSearchTemplate { get; set; } + + /// + [Parameter] + public bool DisableExtendDeleteButton { get; set; } + + /// + [Parameter] + public Func? DisableExtendDeleteButtonCallback { get; set; } + + /// + [Parameter] + public bool DisableExtendEditButton { get; set; } + + /// + [Parameter] + public Func? DisableExtendEditButtonCallback { get; set; } + + /// + [Parameter] + public RenderFragment EditFooterTemplate { get; set; } + + /// + [Parameter] + public bool ScrollingDialogContent { get; set; } + + /// + [Parameter] + public RenderFragment? EditTemplate { get; set; } + + /// + [Parameter] + public RenderFragment> ExportButtonDropdownTemplate { get; set; } + + /// + [Parameter] + public string? ExportButtonText { get; set; } = App.CreateLocalizerByType(typeof(ThingsGateway.Admin.Razor._Imports))["ExportButtonText"]; + + /// + [Parameter] + public int ExtendButtonColumnWidth { get; set; } = 130; + + /// + [Parameter] + public int? Height { get; set; } = null; + + /// + [Parameter] + public bool IsAutoQueryFirstRender { get; set; } = true; + + /// + [Parameter] + public bool IsAutoRefresh { get; set; } = false; + + /// + [Parameter] + public bool IsFixedHeader { get; set; } = true; + + /// + [Parameter] + public bool IsMultipleSelect { get; set; } = true; + + /// + [Parameter] + public bool IsPagination { get; set; } + + /// + [Parameter] + public bool IsTree { get; set; } + + /// + [Parameter] + public IEnumerable? Items { get; set; } + + /// + [Parameter] + public Func? ModelEqualityComparer { get; set; } + + /// + [Parameter] + public Func, Task>? OnAfterDeleteAsync { get; set; } + + /// + [Parameter] + public Func? OnAfterModifyAsync { get; set; } + + /// + [Parameter] + public Func? OnAfterSaveAsync { get; set; } + + /// + [Parameter] + public Func, Task>? OnDeleteAsync { get; set; } + + /// + [Parameter] + public Func>>? OnQueryAsync { get; set; } + + /// + [Parameter] + public Func>? OnSaveAsync { get; set; } + + /// + [Parameter] + public Func>>>? OnTreeExpand { get; set; } + + /// + [Parameter] + public IEnumerable? PageItemsSource { get; set; } = new int[] + { + 20, + 50, + 100, + 200 + }; + + /// + [Parameter] + public RenderFragment? RowButtonTemplate { get; set; } + + /// + [Parameter] + public float RowHeight { get; set; } = 38; + + /// + [Parameter] + public ScrollMode ScrollMode { get; set; } + + /// + [Parameter] + public SearchMode SearchMode { get; set; } + + /// + [Parameter] + public TItem SearchModel { get; set; } + + /// + [Parameter] + public RenderFragment? SearchTemplate { get; set; } + + /// + [Parameter] + public List? SelectedRows { get; set; } = new List(); + + /// + [Parameter] + public EventCallback> SelectedRowsChanged { get; set; } + + /// + [Parameter] + public Func? SetRowClassFormatter { get; set; } + + /// + [Parameter] + public bool? ShowAddButton { get; set; } + + /// + [Parameter] + public bool ShowAdvancedSearch { get; set; } = true; + + /// + [Parameter] + public bool ShowCardView { get; set; } = true; + + /// + [Parameter] + public bool ShowColumnList { get; set; } = true; + + /// + [Parameter] + public bool ShowDefaultButtons { get; set; } = true; + + /// + [Parameter] + public bool? ShowDeleteButton { get; set; } + + /// + [Parameter] + public Func? ShowDeleteButtonCallback { get; set; } + + /// + [Parameter] + public bool? ShowEditButton { get; set; } + + /// + [Parameter] + public Func? ShowEditButtonCallback { get; set; } + + /// + [Parameter] + public bool ShowEmpty { get; set; } = true; + + /// + [Parameter] + public bool ShowExportButton { get; set; } = false; + + /// + [Parameter] + public bool ShowExportCsvButton { get; set; } = false; + + /// + [Parameter] + public bool ShowExportPdfButton { get; set; } + + /// + [Parameter] + public bool ShowExtendButtons { get; set; } = false; + + /// + [Parameter] + public bool? ShowExtendDeleteButton { get; set; } + + /// + [Parameter] + public Func? ShowExtendDeleteButtonCallback { get; set; } + + /// + [Parameter] + public bool? ShowExtendEditButton { get; set; } + + /// + [Parameter] + public Func? ShowExtendEditButtonCallback { get; set; } + + /// + [Parameter] + public bool ShowFilterHeader { get; set; } = false; + + /// + [Parameter] + public bool ShowLoading { get; set; } = false; + + /// + [Parameter] + public bool ShowMultiFilterHeader { get; set; } = false; + + /// + [Parameter] + public bool FixedExtendButtonsColumn { get; set; } = true; + + /// + [Parameter] + public bool FixedMultipleColumn { get; set; } = false; + + /// + [Parameter] + public bool FixedDetailRowHeaderColumn { get; set; } = false; + + /// + [Parameter] + public bool FixedLineNoColumn { get; set; } = false; + + /// + [Parameter] + public bool ShowRefresh { get; set; } = true; + + /// + [Parameter] + public bool ShowResetButton { get; set; } = true; + + /// + [Parameter] + public bool ShowSearch { get; set; } = true; + + /// + [Parameter] + public bool ShowSearchButton { get; set; } = true; + + /// + [Parameter] + public bool ShowSearchText { get; set; } = false; + + /// + [Parameter] + public bool ShowToolbar { get; set; } = true; + + /// + [Parameter] + public string? SortString { get; set; } + + /// + [NotNull] + [Parameter] + public RenderFragment? TableColumns { get; set; } + + /// + [Parameter] + public TableSize TableSize { get; set; } = TableSize.Normal; + + /// + [NotNull] + [Parameter] + public RenderFragment? TableToolbarBeforeTemplate { get; set; } + + /// + [NotNull] + [Parameter] + public RenderFragment? TableToolbarTemplate { get; set; } + + /// + [Parameter] + public RenderFragment? TableExtensionToolbarBeforeTemplate { get; set; } + + /// + [Parameter] + public RenderFragment? TableExtensionToolbarTemplate { get; set; } + + /// + [Parameter] + public Func, Task>>>? TreeNodeConverter { get; set; } + + /// + [Parameter] + public Size EditDialogSize { get; set; } = Size.ExtraExtraLarge; + + [Inject] + [NotNull] + private BlazorAppContext? AppContext { get; set; } + + [NotNull] + private string? EmptyText { get; set; } = App.CreateLocalizerByType(typeof(ThingsGateway.Admin.Razor._Imports))["EmptyText"]; + + [NotNull] + private Table? Instance { get; set; } + + [Inject] + [NotNull] + private NavigationManager? NavigationManager { get; set; } + + public Task OnAddAsync() + { + return Task.FromResult(new TItem()); + } + + /// + public Task QueryAsync(int? pageIndex) => Instance.QueryAsync(pageIndex); + /// + public Task QueryAsync() => Instance.QueryAsync(); + + /// + public ValueTask ToggleLoading(bool v) => Instance.ToggleLoading(v); + + private bool AuthorizeButton(string operate) + { + var url = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + return AppContext.IsHasButtonWithRole(url, operate); + } + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceModuleComponent.razor b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceModuleComponent.razor new file mode 100644 index 000000000..eb12f9da5 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceModuleComponent.razor @@ -0,0 +1,14 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application +@inherits ComponentDefault +
+ + + @foreach (var item in RibbonTabItems) + { + + } + + + +
\ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceModuleComponent.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceModuleComponent.razor.cs new file mode 100644 index 000000000..4b4104b40 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceModuleComponent.razor.cs @@ -0,0 +1,59 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Admin.Razor; + +public partial class ChoiceModuleComponent +{ + [Parameter] + [EditorRequired] + [NotNull] + public List ModuleList { get; set; } + + [Parameter] + [EditorRequired] + [NotNull] + public Func OnClick { get; set; } + + [Parameter] + public long Value { get; set; } + + private List RibbonTabItems { get; set; } + + protected override void OnParametersSet() + { + RibbonTabItems = GenerateRibbonTabs(); + StateHasChanged(); + base.OnParametersSet(); + } + + private async Task OnMenuClickAsync(TabItem tabItem) + { + if (OnClick != null) + await OnClick.Invoke(tabItem.Url.ToLong()); + } + + + private List GenerateRibbonTabs() + { + var tabs = new List(ModuleList?.Count ?? 1); + foreach (var item in ModuleList) + { + var tab = new RibbonTabItem() { IsActive = Value == item.Id, Id = item.Id.ToString(), Text = item.Title, Icon = item.Icon }; + tabs.Add(tab); + } + return tabs; + } + + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceModuleComponent.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceModuleComponent.razor.css new file mode 100644 index 000000000..aa710470c --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceModuleComponent.razor.css @@ -0,0 +1,17 @@ +.choice-module ::deep .tabs .tabs-body { + padding: 0; +} + +.choice-module ::deep .tabs-body-content { + overflow-y: hidden; + height: auto; +} +.choice-module ::deep .tabs.tabs-card .tabs-header .tabs-item.active { + border-width: 0px 0px 0px 0px; +} +.choice-module ::deep .tabs .tabs-active-bar { + height: 0px !important; +} +.choice-module ::deep .tabs.tabs-border-card .tabs-header .tabs-item.active { + border-width: 0px 0px 0px 0px; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceTable.razor b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceTable.razor new file mode 100644 index 000000000..56b808586 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceTable.razor @@ -0,0 +1,87 @@ +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@using ThingsGateway.Extension +@typeparam TItem +@namespace ThingsGateway.Admin.Razor + +
+ +
+ + + + + + + + + +
+
+ + + + + + + + + + +
+ + +
+ +@code { + [NotNull] + AdminTable? table1 { get; set; } + [NotNull] + AdminTable? table2 { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceTable.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceTable.razor.cs new file mode 100644 index 000000000..4590c1802 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/ChoiceTable.razor.cs @@ -0,0 +1,65 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Razor; + +public partial class ChoiceTable where TItem : class, new() +{ + [Parameter] + [EditorRequired] + [NotNull] + public Func, Task>? OnChangedAsync { get; set; } + [Parameter] + [EditorRequired] + public HashSet SelectedRows { get; set; } + [Parameter] + [EditorRequired] + public Func>> OnQueryAsync { get; set; } + + private List SelectedAddRows { get; set; } = new(); + private List SelectedDeleteRows { get; set; } = new(); + + [Parameter] + public int MaxCount { get; set; } = 0; + + public async Task OnAddAsync(IEnumerable selectorOutputs) + { + if (MaxCount > 0 && selectorOutputs.Count() + SelectedRows.Count > MaxCount) + { + await ToastService.Warning(AdminLocalizer["MaxCount"]); + return; + } + foreach (var item in selectorOutputs) + { + SelectedRows.Add(item); + await table2.QueryAsync(); + await OnChangedAsync(SelectedRows); + } + } + public async Task OnAddAsync(TItem item) + { + if (MaxCount > 0 && 1 + SelectedRows.Count > MaxCount) + { + await ToastService.Warning(AdminLocalizer["MaxCount"]); + return; + } + SelectedRows.Add(item); + await table2.QueryAsync(); + await OnChangedAsync(SelectedRows); + } + public async Task QueryAsync() + { + await table1.QueryAsync(); + } + + + + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/ComponentDefault.cs b/src/Admin/ThingsGateway.Admin.Razor/Components/ComponentDefault.cs new file mode 100644 index 000000000..d75c3fc94 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/ComponentDefault.cs @@ -0,0 +1,60 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public class ComponentDefault : ComponentBase +{ + [Inject] + [NotNull] + public IStringLocalizer? RazorLocalizer { get; set; } + + [Inject] + [NotNull] + public IStringLocalizer? AdminLocalizer { get; set; } + + [Inject] + [NotNull] + public DialogService? DialogService { get; set; } + + [NotNull] + public IStringLocalizer? Localizer { get; set; } + + [Inject] + [NotNull] + public IStringLocalizer? OperDescLocalizer { get; set; } + + [Inject] + [NotNull] + public ToastService? ToastService { get; set; } + + [Inject] + [NotNull] + protected BlazorAppContext? AppContext { get; set; } + + [Inject] + [NotNull] + private NavigationManager? NavigationManager { get; set; } + + public string RouteName => NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + + protected bool AuthorizeButton(string operate) + { + return AppContext.IsHasButtonWithRole(RouteName, operate); + } + + protected override void OnInitialized() + { + Localizer = App.CreateLocalizerByType(GetType()); + base.OnInitialized(); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/LoginConnectionHub.razor b/src/Admin/ThingsGateway.Admin.Razor/Components/LoginConnectionHub.razor new file mode 100644 index 000000000..d7c20ed9f --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/LoginConnectionHub.razor @@ -0,0 +1,10 @@ +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor +@code { + RenderFragment RenderItem => item => + @
+ @item.ConfirmMessage + + +
; +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/LoginConnectionHub.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Components/LoginConnectionHub.razor.cs new file mode 100644 index 000000000..b95b0a688 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/LoginConnectionHub.razor.cs @@ -0,0 +1,134 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + + +using ThingsGateway.Admin.Application; +using ThingsGateway.Extension.Generic; + +namespace ThingsGateway.Admin.Razor; + +public partial class LoginConnectionHub : ComponentBase, IDisposable +{ + [Inject] + private NavigationManager NavigationManager { get; set; } + + [Inject] + private ToastService ToastService { get; set; } + + [Inject] + private IVerificatInfoService VerificatInfoService { get; set; } + [Inject] + private IEventService NewMessage { get; set; } + [Inject] + private IEventService LoginOut { get; set; } + [Inject] + private IEventService NavigationUri { get; set; } + + /// + public void Dispose() + { + UpdateVerificat(ClientId, VerificatId, isConnect: false); + var clientId = ClientId.ToString(); + NewMessage.UnSubscribe(clientId); + LoginOut.UnSubscribe(clientId); + NavigationUri.UnSubscribe(clientId); + } + private long VerificatId; + private long ClientId; + protected override Task OnInitializedAsync() + { + try + { + ClientId = CommonUtils.GetSingleId(); + VerificatId = UserManager.VerificatId; + var clientId = ClientId.ToString(); + LoginOut.Subscribe(clientId, async (message) => + { + await InvokeAsync(async () => await ToastService.Warning(message.Message)); + await Task.Delay(2000); + NavigationManager.NavigateTo(NavigationManager.Uri, true); + }); + NewMessage.Subscribe(clientId, async (message) => + { + if ((byte)message.LogLevel <= 2) + await InvokeAsync(async () => await ToastService.Information(message.Data)); + else + await InvokeAsync(async () => await ToastService.Warning(message.Data)); + }); + NavigationUri.Subscribe(clientId, async (message) => + { + await ShowMessage(message); + }); + UpdateVerificat(ClientId, VerificatId, isConnect: true); + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + + return base.OnInitializedAsync(); + } + + [Inject] + private IStringLocalizer Localizers { get; set; } + [Inject] + private MessageService MessageService { get; set; } + private async Task ShowMessage(NavigationUri navigationUri) + { + await MessageService.Show(new MessageOption() + { + Icon = "fa-solid fa-circle-info", + ShowDismiss = true, + IsAutoHide = false, + ChildContent = RenderItem(navigationUri), + OnDismiss = () => + { + return Task.CompletedTask; + } + }); + } + /// + /// 更新cache + /// + /// 用户id + /// 上线时的验证id + /// 上线 + private void UpdateVerificat(long clientId, long verificatId = 0, bool isConnect = true) + { + if (clientId != 0) + { + //获取cache当前用户的verificat信息列表 + if (isConnect) + { + //获取cache中当前verificat + var verificatInfo = VerificatInfoService.GetOne(verificatId); + if (verificatInfo != null) + { + verificatInfo.ClientIds.Add(clientId);//添加到客户端列表 + VerificatInfoService.Update(verificatInfo);//更新Cache + } + } + else + { + //获取当前客户端ID所在的verificat信息 + var verificatInfo = VerificatInfoService.GetOne(verificatId); + if (verificatInfo != null) + { + verificatInfo.ClientIds.RemoveWhere(it => it == clientId);//从客户端列表删除 + VerificatInfoService.Update(verificatInfo);//更新Cache + } + } + } + } + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Components/UserLogin.razor b/src/Admin/ThingsGateway.Admin.Razor/Components/UserLogin.razor new file mode 100644 index 000000000..3720c1e97 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Components/UserLogin.razor @@ -0,0 +1,19 @@ +@namespace ThingsGateway.Admin.Razor + + @if (TenantOption.Value.Enable) + { + + + + ; + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/LogPage/OperLogPage.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/LogPage/OperLogPage.razor.cs new file mode 100644 index 000000000..d8849ba85 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/LogPage/OperLogPage.razor.cs @@ -0,0 +1,112 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class OperLogPage +{ + [Inject] + [NotNull] + private ISysOperateLogService? SysOperateLogService { get; set; } + + #region 曲线 + + private ChartDataSource? ChartDataSource { get; set; } + private bool chartInit { get; set; } + private Chart LineChart { get; set; } + + private async Task OnInit() + { + if (ChartDataSource == null) + { + var dayStatisticsOutputs = await SysOperateLogService.StatisticsByDayAsync(7); + ChartDataSource = new ChartDataSource(); + ChartDataSource.Options.Title = Localizer[nameof(SysOperateLog)]; + ChartDataSource.Options.X.Title = Localizer["Date"]; + ChartDataSource.Options.Y.Title = Localizer["Count"]; + ChartDataSource.Labels = dayStatisticsOutputs.Select(a => a.Date); + ChartDataSource.Data.Add(new ChartDataset() + { + Tension = 0.4f, + PointRadius = 1, + Label = Localizer["Operate"], + Data = dayStatisticsOutputs.Select(a => (object)a.OperateCount), + }); + ChartDataSource.Data.Add(new ChartDataset() + { + Tension = 0.4f, + PointRadius = 1, + Label = Localizer["Exception"], + Data = dayStatisticsOutputs.Select(a => (object)a.ExceptionCount), + }); + ChartDataSource.Data.Add(new ChartDataset() + { + Tension = 0.4f, + PointRadius = 1, + Label = Localizer["Login"], + Data = dayStatisticsOutputs.Select(a => (object)a.LoginCount), + }); + ChartDataSource.Data.Add(new ChartDataset() + { + Tension = 0.4f, + PointRadius = 1, + Label = Localizer["Logout"], + Data = dayStatisticsOutputs.Select(a => (object)a.LogoutCount), + }); + } + else + { + var dayStatisticsOutputs = await SysOperateLogService.StatisticsByDayAsync(7); + ChartDataSource.Labels = dayStatisticsOutputs.Select(a => a.Date); + ChartDataSource.Data[0].Data = dayStatisticsOutputs.Select(a => (object)a.OperateCount); + ChartDataSource.Data[1].Data = dayStatisticsOutputs.Select(a => (object)a.ExceptionCount); + ChartDataSource.Data[2].Data = dayStatisticsOutputs.Select(a => (object)a.LoginCount); + ChartDataSource.Data[3].Data = dayStatisticsOutputs.Select(a => (object)a.LogoutCount); + } + return ChartDataSource; + } + + #endregion 曲线 + + #region 查询 + + private OperateLogPageInput CustomerSearchModel { get; set; } = new OperateLogPageInput(); + + private async Task> OnQueryAsync(QueryPageOptions options) + { + if (chartInit) + await LineChart.Update(ChartAction.Update); + var data = await SysOperateLogService.PageAsync(options); + return data; + } + + #endregion 查询 + + #region 导出 + + [Inject] + [NotNull] + private ITableExport? TableExport { get; set; } + + private async Task ExcelExportAsync(ITableExportContext context) + { + // 自定义导出模板导出当前页面数据为 Excel 方法 + // 使用 BootstrapBlazor 内置服务 ITableExcelExport 实例方法 ExportAsync 进行导出操作 + // 导出数据使用 context 传递来的 Rows/Columns 即为当前页数据 + var ret = await TableExport.ExportExcelAsync(context.Rows, context.Columns, $"OperLog_{DateTime.Now:yyyyMMddHHmmss}.xlsx"); + + // 返回 true 时自动弹出提示框 + await ToastService.Default(ret); + } + + #endregion 导出 +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/OrgTree.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/OrgTree.razor new file mode 100644 index 000000000..8305ee6e9 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/OrgTree.razor @@ -0,0 +1,14 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application + +
+
@AdminLocalizer["OrgList"]
+ + + + + + +
+ diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/OrgTree.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/OrgTree.razor.cs new file mode 100644 index 000000000..082f61156 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/OrgTree.razor.cs @@ -0,0 +1,106 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +using ThingsGateway.Admin.Application; +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Admin.Razor; + +public partial class OrgTree : IDisposable +{ + [Parameter] + [NotNull] + public long Value { get; set; } + + [Parameter] + public Func ValueChanged { get; set; } + + [NotNull] + private List> Items { get; set; } + + + [Inject] + private IStringLocalizer AdminLocalizer { get; set; } + [Inject] + [NotNull] + private ISysOrgService? SysOrgService { get; set; } + + private static bool ModelEqualityComparer(SysOrg x, SysOrg y) => x.Id == y.Id; + + private async Task OnTreeItemClick(TreeViewItem item) + { + var value = item.Value.Id; + Value = value; + if (ValueChanged != null) + { + await ValueChanged.Invoke(value); + } + } + + + private List> ZItem; + protected override async Task OnInitializedAsync() + { + ZItem = new List>() {new TreeViewItem(new SysOrg(){ }) + { + Text = AdminLocalizer["All"], + IsActive = Value == 0, + IsExpand = false, + CheckedState = Value == 0 ? CheckboxState.Checked : CheckboxState.UnChecked + } }; + var items = (await SysOrgService.SelectorAsync()); + Items = ZItem.Concat(OrgUtil.BuildTreeItemList(items, new List { Value })).ToList(); + context = ExecutionContext.Capture(); + DispatchService.Subscribe(Refresh); + await base.OnInitializedAsync(); + } + private ExecutionContext? context; + private async Task Notify() + { + var current = ExecutionContext.Capture(); + try + { + ExecutionContext.Restore(context); + await InvokeAsync(async () => + { + await OnClickSearch(SearchText); + }); + } + finally + { + ExecutionContext.Restore(current); + } + } + + private async Task Refresh(DispatchEntry entry) + { + await Notify(); + } + + [Inject] + private IDispatchService DispatchService { get; set; } + private string SearchText; + + private async Task>> OnClickSearch(string searchText) + { + SearchText = searchText; + var items = (await SysOrgService.SelectorAsync()); + items = items.WhereIF(!searchText.IsNullOrEmpty(), a => a.Name.Contains(searchText)).ToList(); + return ZItem.Concat(OrgUtil.BuildTreeItemList(items, new List { Value })).ToList(); + } + + public void Dispose() + { + context?.Dispose(); + DispatchService.UnSubscribe(Refresh); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/OrgTree.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/OrgTree.razor.css new file mode 100644 index 000000000..28207bfd6 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/OrgTree.razor.css @@ -0,0 +1,9 @@ + +.listtree-view { + height: 100%; +} + .listtree-view ::deep .tree-view { + --bb-tree-search-height: 32px; + min-height: 300px; + height: calc(100% - var(--bb-tree-search-height) - 50px); + } diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgCopy.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgCopy.razor new file mode 100644 index 000000000..657797e97 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgCopy.razor @@ -0,0 +1,35 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application +
+ +
+ + + + +
+ +
+ +
+
+ + +
+ +
+ + + + +@code { + [NotNull] + SelectTree? selectTree { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgCopy.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgCopy.razor.cs new file mode 100644 index 000000000..6e3130c5d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgCopy.razor.cs @@ -0,0 +1,46 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysOrgCopy +{ + [Parameter] + [NotNull] + public SysOrgCopyInput? SysOrgCopyInput { get; set; } + + [NotNull] + private List> Items { get; set; } + + [Inject] + [NotNull] + private ISysOrgService SysOrgService { get; set; } + + private List ContainsChildBoolItems; + private List ContainsPositionBoolItems; + + protected override async Task OnInitializedAsync() + { + ContainsChildBoolItems = LocalizerUtil.GetBoolItems(SysOrgCopyInput.GetType(), nameof(SysOrgCopyInput.ContainsChild)); + ContainsPositionBoolItems = LocalizerUtil.GetBoolItems(SysOrgCopyInput.GetType(), nameof(SysOrgCopyInput.ContainsPosition)); + var items = (await SysOrgService.SelectorAsync()); + Items = OrgUtil.BuildTreeIdItemList(items, new List { SysOrgCopyInput.TargetId }); + await base.OnInitializedAsync(); + } + private long Key { get; set; } + private Task CleanParentId() + { + SysOrgCopyInput.TargetId = 0; + Key = CommonUtils.GetSingleId(); + return Task.CompletedTask; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgEdit.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgEdit.razor new file mode 100644 index 000000000..4069c2982 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgEdit.razor @@ -0,0 +1,54 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application +
+ + +
+ + + @if (AppContext.CurrentUser.IsGlobal) + { + + } + + +
+ +
+ +
+
+ +
+ + +
+ @if (AppContext.CurrentUser.IsGlobal) + { + + } +
+ +
+ a.DirectorId) Values="new HashSet(){Model.DirectorId??0}" + ValuesChanged="(a)=>Model.DirectorId=a.FirstOrDefault()"> +
+ +
+ +
+
+ +
+
+ +
+
+ + + + +@code{ + [NotNull] + SelectTree? selectTree { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgEdit.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgEdit.razor.cs new file mode 100644 index 000000000..a78545146 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgEdit.razor.cs @@ -0,0 +1,51 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysOrgEdit +{ + [Parameter] + [NotNull] + public SysOrg? Model { get; set; } + + [NotNull] + private List> Items { get; set; } + + [Inject] + [NotNull] + private ISysOrgService SysOrgService { get; set; } + + private List BoolItems; + + protected override async Task OnInitializedAsync() + { + BoolItems = LocalizerUtil.GetBoolItems(Model.GetType(), nameof(Model.Status)); + var items = (await SysOrgService.SelectorAsync()); + Items = OrgUtil.BuildTreeIdItemList(items, new List { Model.ParentId }); + if (!AppContext.CurrentUser.IsGlobal) + Model.Category = OrgEnum.DEPT; + await base.OnInitializedAsync(); + } + private long Key { get; set; } + [Inject] + private BlazorAppContext AppContext { get; set; } + private Task CleanParentId() + { + if (!AppContext.CurrentUser.IsGlobal) + Model.ParentId = Items.FirstOrDefault()?.Value ?? 0; + else + Model.ParentId = 0; + Key = CommonUtils.GetSingleId(); + return Task.CompletedTask; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgPage.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgPage.razor new file mode 100644 index 000000000..38b38c3c5 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgPage.razor @@ -0,0 +1,86 @@ +@page "/admin/org" +@attribute [Authorize] +@attribute [RolePermission] +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor + +
+ +
+ + + + + + +
+
+ + + + + + + + + @* + @if (context is OrgTableSearchModel model) + { + @Render(model) + } + *@ + + + + +
+
+ +@code { + [NotNull] + AdminTable? table { get; set; } +} +@* @code { + RenderFragment Render(OrgTableSearchModel model) => + @
+
+ +
+
+ +
+
+ ; + + + [Inject] + private IStringLocalizer RazorLocalizer { get; set; } + protected override void OnInitialized() + { + base.OnInitialized(); + + NullableBoolItems = new SelectedItem[] + { + new() { Text = RazorLocalizer["SelectPlaceHolder"].Value, Value = "" }, + new() { Text = "True", Value = "true" }, + new() { Text = "False", Value = "false" } + }; + } + private IEnumerable NullableBoolItems { get; set; } +} + *@ diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgPage.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgPage.razor.cs new file mode 100644 index 000000000..1a44ff428 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Org/SysOrgPage.razor.cs @@ -0,0 +1,112 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysOrgPage +{ + private long? ParentId { get; set; } + + [Inject] + [NotNull] + private ISysOrgService? SysOrgService { get; set; } + + #region 查询 + + private async Task> OnQueryAsync(QueryPageOptions options) + { + var data = await SysOrgService.PageAsync(options, + a => a + .WhereIF(ParentId != null && ParentId != 0, b => b.ParentId == ParentId || b.Id == ParentId || SqlFunc.JsonLike(b.ParentIdList, ParentId.ToString())) + + ); + return data; + } + + private Task TreeChangedAsync(long parentId) + { + ParentId = parentId; + return table.QueryAsync(); + } + + #endregion 查询 + + #region 修改 + private List SelectedRows { get; set; } = new(); + private SysOrgCopyInput SysOrgCopyInput = new(); + private async Task OnCopy() + { + SysOrgCopyInput = new(); + var option = new DialogOption() + { + IsScrolling = true, + Size = Size.Medium, + Title = AdminLocalizer["Copy"], + ShowMaximizeButton = true, + Class = "dialog-select", + ShowSaveButton = true, + OnSaveAsync = async () => + { + try + { + await SysOrgService.CopyAsync(SysOrgCopyInput); + await table.QueryAsync(); + await ToastService.Default(); + return true; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + }, + BodyTemplate = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(SysOrgCopy.SysOrgCopyInput)] = SysOrgCopyInput, + }).Render(), + + }; + SysOrgCopyInput.Ids = SelectedRows.Select(a => a.Id).ToList(); + await DialogService.Show(option); + + } + private async Task Delete(IEnumerable sysOrgs) + { + try + { + return await SysOrgService.DeleteOrgAsync(sysOrgs.Select(a => a.Id)); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + private async Task Save(SysOrg sysOrg, ItemChangedType itemChangedType) + { + try + { + return await SysOrgService.SaveOrgAsync(sysOrg, itemChangedType); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + #endregion 修改 + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/PositionTree.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/PositionTree.razor new file mode 100644 index 000000000..d331783dc --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/PositionTree.razor @@ -0,0 +1,14 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application + +
+
@AdminLocalizer["PositionList"]
+ + + + + + +
+ diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/PositionTree.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/PositionTree.razor.cs new file mode 100644 index 000000000..1de3ab416 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/PositionTree.razor.cs @@ -0,0 +1,105 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +using ThingsGateway.Admin.Application; +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Admin.Razor; + +public partial class PositionTree : IDisposable +{ + [Parameter] + [NotNull] + public long Value { get; set; } + + [Parameter] + public Func ValueChanged { get; set; } + + [NotNull] + private List> Items { get; set; } + + [Inject] + private IStringLocalizer AdminLocalizer { get; set; } + [Inject] + [NotNull] + private ISysPositionService? SysPositionService { get; set; } + + private static bool ModelEqualityComparer(PositionTreeOutput x, PositionTreeOutput y) => x.Id == y.Id; + + private async Task OnTreeItemClick(TreeViewItem item) + { + var value = item.Value.Id; + Value = value; + if (ValueChanged != null && item.Value.IsPosition) + { + await ValueChanged.Invoke(value); + } + } + + private List> ZItem; + protected override async Task OnInitializedAsync() + { + ZItem = new List>() {new TreeViewItem(new PositionTreeOutput(){ IsPosition=true}) + { + Text = AdminLocalizer["All"], + IsActive = Value == 0, + IsExpand = false, + CheckedState = Value == 0 ? CheckboxState.Checked : CheckboxState.UnChecked + } }; + var items = (await SysPositionService.TreeAsync()); + Items = ZItem.Concat(PositionUtil.BuildTreeItemList(items, new List { Value })).ToList(); + + context = ExecutionContext.Capture(); + DispatchService.Subscribe(Refresh); + await base.OnInitializedAsync(); + } + private ExecutionContext? context; + private async Task Notify() + { + var current = ExecutionContext.Capture(); + try + { + ExecutionContext.Restore(context); + await InvokeAsync(async () => + { + await OnClickSearch(SearchText); + }); + } + finally + { + ExecutionContext.Restore(current); + } + } + private async Task Refresh(DispatchEntry entry) + { + await Notify(); + } + + [Inject] + private IDispatchService DispatchService { get; set; } + private string SearchText; + + private async Task>> OnClickSearch(string searchText) + { + SearchText = searchText; + var items = (await SysPositionService.TreeAsync()); + items = items.WhereIF(!searchText.IsNullOrEmpty(), a => a.Name.Contains(searchText)).ToList(); + return ZItem.Concat(PositionUtil.BuildTreeItemList(items, new List { Value })).ToList(); + + } + + public void Dispose() + { + context?.Dispose(); + DispatchService.UnSubscribe(Refresh); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/PositionTree.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/PositionTree.razor.css new file mode 100644 index 000000000..ad59ff824 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/PositionTree.razor.css @@ -0,0 +1,10 @@ + +.listtree-view { + height: 100%; +} + + .listtree-view ::deep .tree-view { + --bb-tree-search-height: 32px; + min-height: 300px; + height: calc(100% - var(--bb-tree-search-height) - 50px); + } diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionEdit.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionEdit.razor new file mode 100644 index 000000000..64e33c077 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionEdit.razor @@ -0,0 +1,35 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application +
+ + +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ +
+
+ + + + + diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionEdit.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionEdit.razor.cs new file mode 100644 index 000000000..d81e29330 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionEdit.razor.cs @@ -0,0 +1,40 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysPositionEdit +{ + [Parameter] + [NotNull] + public SysPosition? Model { get; set; } + + [NotNull] + private List> Items { get; set; } + + [Inject] + [NotNull] + private ISysPositionService SysPositionService { get; set; } + + [Inject] + [NotNull] + private ISysOrgService SysOrgService { get; set; } + private List BoolItems; + + protected override async Task OnInitializedAsync() + { + BoolItems = LocalizerUtil.GetBoolItems(Model.GetType(), nameof(Model.Status)); + var items = (await SysOrgService.SelectorAsync()); + Items = OrgUtil.BuildTreeIdItemList(items, new List { Model.OrgId }); + await base.OnInitializedAsync(); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionPage.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionPage.razor new file mode 100644 index 000000000..3470bca6d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionPage.razor @@ -0,0 +1,78 @@ +@page "/admin/position" +@attribute [Authorize] +@attribute [RolePermission] +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor + +
+ +
+ + + + + + +
+
+ + @* + @if (context is PositionTableSearchModel model) + { + @Render(model) + } + *@ + + + + +
+
+ +@code { + [NotNull] + AdminTable? table { get; set; } +} +@* @code { + RenderFragment Render(PositionTableSearchModel model) => + @
+
+ +
+
+ +
+
+ ; + + + [Inject] + private IStringLocalizer RazorLocalizer { get; set; } + protected override void OnInitialized() + { + base.OnInitialized(); + + NullableBoolItems = new SelectedItem[] + { + new() { Text = RazorLocalizer["SelectPlaceHolder"].Value, Value = "" }, + new() { Text = "True", Value = "true" }, + new() { Text = "False", Value = "false" } + }; + } + private IEnumerable NullableBoolItems { get; set; } +} + *@ diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionPage.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionPage.razor.cs new file mode 100644 index 000000000..882064816 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Position/SysPositionPage.razor.cs @@ -0,0 +1,70 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysPositionPage +{ + private long OrgId { get; set; } + + [Inject] + [NotNull] + private ISysPositionService? SysPositionService { get; set; } + + #region 查询 + + private async Task> OnQueryAsync(QueryPageOptions options) + { + var data = await SysPositionService.PageAsync(options, a => + a.WhereIF(OrgId != 0, b => b.OrgId == OrgId)); + return data; + } + + private Task TreeChangedAsync(long id) + { + OrgId = id; + return table.QueryAsync(); + } + + #endregion 查询 + + #region 修改 + + private async Task Delete(IEnumerable sysPositions) + { + try + { + return await SysPositionService.DeletePositionAsync(sysPositions.Select(a => a.Id)); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + private async Task Save(SysPosition sysPosition, ItemChangedType itemChangedType) + { + try + { + return await SysPositionService.SavePositionAsync(sysPosition, itemChangedType); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + #endregion 修改 + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoice.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoice.razor new file mode 100644 index 000000000..ec7c05151 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoice.razor @@ -0,0 +1,8 @@ +@namespace ThingsGateway.Admin.Razor + + + + + + + diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoice.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoice.razor.cs new file mode 100644 index 000000000..8773e2fdb --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoice.razor.cs @@ -0,0 +1,99 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Admin.Razor; + +public partial class MenuChoice +{ + [Inject] + [NotNull] + public IStringLocalizer? AdminLocalizer { get; set; } + + [EditorRequired] + [Parameter] + [NotNull] + public string? DisplayText { get; set; } + + [Parameter] + [NotNull] + public IEnumerable? Items { get; set; } + + [Parameter] + [EditorRequired] + [NotNull] + public long ModuleId { get; set; } + + public long TreeValue { get; set; } + + [Parameter] + [NotNull] + public long Value { get; set; } + [Parameter] + [NotNull] + public EventCallback ValueChanged { get; set; } + + [Inject] + [NotNull] + private DialogService? DialogService { get; set; } + [Inject] + [NotNull] + private ToastService? ToastService { get; set; } + + private Task OnClearText() => OnValueChanged(0, true); + + private async Task OnSelect() + { + var option = new DialogOption() + { + IsScrolling = true, + Size = Size.ExtraLarge, + Title = AdminLocalizer["Choice"], + ShowMaximizeButton = true, + Class = "dialog-table", + ShowSaveButton = true, + OnSaveAsync = async () => + { + try + { + await OnValueChanged(TreeValue, true); + await ToastService.Default(); + return true; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + }, + BodyTemplate = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(MenuChoiceDialog.ModuleId)] = ModuleId, + [nameof(MenuChoiceDialog.Value)] = Value, + + [nameof(MenuChoiceDialog.ValueChanged)] = EventCallback.Factory.Create(this, v => OnValueChanged(v)) + }).Render(), + + }; + await DialogService.Show(option); + } + + private async Task OnValueChanged(long v, bool change = false) + { + if (TreeValue != v) + { + TreeValue = v; + } + if (change && ValueChanged.HasDelegate) + { + Value = TreeValue; + await ValueChanged.InvokeAsync(Value); + } + } +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/SeedData/Gateway/seed_gateway_resourcebutton.json b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoice.razor.css similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/SeedData/Gateway/seed_gateway_resourcebutton.json rename to src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoice.razor.css diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoiceDialog.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoiceDialog.razor new file mode 100644 index 000000000..ec3d7bdaf --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoiceDialog.razor @@ -0,0 +1,16 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application + +
+ +
+ +@code { + RenderFragment RenderTreeItem => item => + @
+ @item.Title + @item.SortCode + @item.Category.ToDisplayName() + @ModuleTitle +
; +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoiceDialog.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoiceDialog.razor.cs new file mode 100644 index 000000000..2ac3b5a8c --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoiceDialog.razor.cs @@ -0,0 +1,58 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class MenuChoiceDialog +{ + private string ModuleTitle; + + [Parameter] + [EditorRequired] + [NotNull] + public long ModuleId { get; set; } + + [Parameter] + [EditorRequired] + [NotNull] + public long Value { get; set; } + + [Parameter] + public EventCallback ValueChanged { get; set; } + + [NotNull] + private List>? Items { get; set; } + + [Inject] + [NotNull] + private ISysResourceService? SysResourceService { get; set; } + + protected override async Task OnParametersSetAsync() + { + var all = (await SysResourceService.GetAllAsync()); + var items = all.Where(a => a.Category == ResourceCategoryEnum.Menu && a.Module == ModuleId); + ModuleTitle = all.FirstOrDefault(a => a.Id == ModuleId)?.Title; + Items = ResourceUtil.BuildTreeItemList(items, new List { Value }, RenderTreeItem); + await base.OnParametersSetAsync(); + } + + private static bool ModelEqualityComparer(SysResource x, SysResource y) => x.Id == y.Id; + + private async Task OnTreeItemClick(TreeViewItem item) + { + Value = item.Value.Id; + if (ValueChanged.HasDelegate) + { + await ValueChanged.InvokeAsync(Value); + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoiceDialog.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoiceDialog.razor.css new file mode 100644 index 000000000..bfdbda96c --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/MenuChoiceDialog.razor.css @@ -0,0 +1,14 @@ +.menu-type { + width: 60px; + margin-left: 1rem; + text-align: left; + color: var(--bs-primary); +} + +.menu-text { + margin-left: 1rem; + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + color: var(--bs-info); +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourceEdit.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourceEdit.razor new file mode 100644 index 000000000..e179ddfdf --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourceEdit.razor @@ -0,0 +1,63 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application +
+ + +
+ +
+ +
+ +
+ + + @if (Model.Category != ResourceCategoryEnum.Module) + { +
+ +
+ } + + @if (Model.Category != ResourceCategoryEnum.Button) + { +
+ + + + + + +
+ } + @if (Model.Category == ResourceCategoryEnum.Menu) + { +
+ +
+
+ +
+
+ +
+ + } +@* @if (Model.Category == ResourceCategoryEnum.Button) + { +
+ +
+ } *@ +
+ +
+
+ +
+
+ + + + + diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourceEdit.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourceEdit.razor.cs new file mode 100644 index 000000000..cbcab0e6c --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourceEdit.razor.cs @@ -0,0 +1,49 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysResourceEdit +{ + [Parameter] + [NotNull] + public SysResource? Model { get; set; } + + [Parameter] + [EditorRequired] + [NotNull] + public long ModuleId { get; set; } + + [Parameter] + [NotNull] + public IEnumerable? MenuItems { get; set; } + + [Inject] + [NotNull] + private DialogService DialogService { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer Localizer { get; set; } + + private Task OnToggleIconDialog() => DialogService.Show(new DialogOption() + { + IsScrolling = false, + Title = Localizer["ChoiceIcon"], + ShowFooter = false, + Component = BootstrapDynamicComponent.CreateComponent(new Dictionary() + { + [nameof(MenuIconList.Value)] = Model.Icon, + [nameof(MenuIconList.ValueChanged)] = EventCallback.Factory.Create(this, v => Model.Icon = v!) + }) + }); +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourcePage.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourcePage.razor new file mode 100644 index 000000000..16ba1aa9d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourcePage.razor @@ -0,0 +1,84 @@ +@page "/admin/resource" +@attribute [Authorize] +@attribute [RolePermission] +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor + + +
+ + + + + +
+
+ +
+
+ +
+ ; + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourcePage.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourcePage.razor.cs new file mode 100644 index 000000000..c37e77106 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Resource/SysResourcePage.razor.cs @@ -0,0 +1,166 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysResourcePage +{ + private ResourceTableSearchModel CustomerSearchModel { get; set; } = new ResourceTableSearchModel(); + + private List ModuleSelectedItems { get; set; } + + private List> MenuTreeItems { get; set; } + private List MenuItems { get; set; } + + [CascadingParameter(Name = "ReloadMenu")] + private Func? ReloadMenu { get; set; } + + [CascadingParameter(Name = "ReloadUser")] + private Func? ReloadUser { get; set; } + + [Inject] + [NotNull] + private ISysResourceService? SysResourceService { get; set; } + + protected override async Task OnInitializedAsync() + { + CustomerSearchModel.Module = (await SysResourceService.GetAllAsync()).FirstOrDefault(a => a.Category == ResourceCategoryEnum.Module)?.Id ?? ResourceConst.SystemId; + await base.OnInitializedAsync(); + } + + protected override async Task OnParametersSetAsync() + { + ModuleSelectedItems = ResourceUtil.BuildModuleSelectList((await SysResourceService.GetAllAsync())).ToList(); + MenuItems = ResourceUtil.BuildMenuSelectList((await SysResourceService.GetAllAsync())).Concat(new List() { new("0", AdminLocalizer["Root"]) }).ToList(); + + await base.OnParametersSetAsync(); + } + + #region 查询 + + private async Task> OnQueryAsync(QueryPageOptions options) + { + MenuTreeItems = new List>() { new TreeViewItem(new SysResource()) { Text = AdminLocalizer["Root"] } }.Concat(ResourceUtil.BuildTreeItemList((await SysResourceService.GetAllAsync()).Where(a => a.Module == CustomerSearchModel.Module), new(), null)).ToList(); + + var data = await SysResourceService.PageAsync(options, CustomerSearchModel); + return data; + } + + #endregion 查询 + + #region 修改 + private List SelectedRows { get; set; } = new(); + private long CopyModule { get; set; } + private long ChangeParentId { get; set; } + + private async Task OnCopy() + { + try + { + await SysResourceService.CopyAsync(SelectedRows.Select(a => a.Id), CopyModule); + await table.QueryAsync(); + await ToastService.Default(); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + } + + } + private async Task OnChangeParent() + { + try + { + await SysResourceService.ChangeParentAsync(SelectedRows.Select(a => a.Id).FirstOrDefault(), ChangeParentId); + await table.QueryAsync(); + await ToastService.Default(); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + } + + } + + private async Task Delete(IEnumerable sysResources) + { + try + { + var result = await SysResourceService.DeleteResourceAsync(sysResources.Select(a => a.Id)); + if (ReloadUser != null) + { + await ReloadUser(); + } + return result; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + private async Task Save(SysResource sysResource, ItemChangedType itemChangedType) + { + try + { + if (itemChangedType == ItemChangedType.Add && sysResource.Category != ResourceCategoryEnum.Module) + sysResource.Module = CustomerSearchModel.Module; + var result = await SysResourceService.SaveResourceAsync(sysResource, itemChangedType); + if (ReloadUser != null) + { + await ReloadUser(); + } + return result; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + #endregion 修改 + + #region 树节点 + + private static bool ModelEqualityComparer(SysResource x, SysResource y) => x.Id == y.Id; + + private async Task>> OnTreeExpand(SysResource menu) + { + var sysResources = await SysResourceService.GetAllAsync(); + var result = ResourceUtil.BuildTableTrees(sysResources, menu.Id); + return result; + } + + private static async Task>> TreeNodeConverter(IEnumerable items) + { + await Task.CompletedTask; + var result = ResourceUtil.BuildTableTrees(items, 0); + return result; + } + + #endregion 树节点 + + #region 更新页面 + + private async Task OnAfterModifyAsync() + { + if (ReloadMenu != null) + { + await ReloadMenu(); + } + await OnParametersSetAsync(); + } + + #endregion 更新页面 +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/RoleTree.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/RoleTree.razor new file mode 100644 index 000000000..3bf59af81 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/RoleTree.razor @@ -0,0 +1,14 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application + +
+
@AdminLocalizer["RoleList"]
+ + + + + + +
+ diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/RoleTree.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/RoleTree.razor.cs new file mode 100644 index 000000000..3bc7b6a83 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/RoleTree.razor.cs @@ -0,0 +1,106 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using SqlSugar; + +using ThingsGateway.Admin.Application; +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Admin.Razor; + +public partial class RoleTree : IDisposable +{ + [Parameter] + [NotNull] + public long Value { get; set; } + + [Parameter] + public Func ValueChanged { get; set; } + + [NotNull] + private List> Items { get; set; } + + [Inject] + private IStringLocalizer AdminLocalizer { get; set; } + [Inject] + [NotNull] + private ISysRoleService? SysRoleService { get; set; } + + private static bool ModelEqualityComparer(RoleTreeOutput x, RoleTreeOutput y) => x.Id == y.Id; + + private async Task OnTreeItemClick(TreeViewItem item) + { + var value = item.Value.Id; + Value = value; + if (ValueChanged != null && item.Value.IsRole) + { + await ValueChanged.Invoke(value); + } + } + + + private List> ZItem; + protected override async Task OnInitializedAsync() + { + ZItem = new List>() {new TreeViewItem(new RoleTreeOutput(){ IsRole=true}) + { + Text = AdminLocalizer["All"], + IsActive = Value == 0, + IsExpand = false, + CheckedState = Value == 0 ? CheckboxState.Checked : CheckboxState.UnChecked + } }; + var items = (await SysRoleService.TreeAsync()); + Items = ZItem.Concat(RoleUtil.BuildTreeItemList(items, new List { Value })).ToList(); + + context = ExecutionContext.Capture(); + DispatchService.Subscribe(Refresh); + await base.OnInitializedAsync(); + } + private ExecutionContext? context; + private async Task Notify() + { + var current = ExecutionContext.Capture(); + try + { + ExecutionContext.Restore(context); + await InvokeAsync(async () => + { + await OnClickSearch(SearchText); + }); + } + finally + { + ExecutionContext.Restore(current); + } + } + private async Task Refresh(DispatchEntry entry) + { + await Notify(); + } + + [Inject] + private IDispatchService DispatchService { get; set; } + + private string SearchText; + + private async Task>> OnClickSearch(string searchText) + { + SearchText = searchText; + var items = (await SysRoleService.TreeAsync()); + items = items.WhereIF(!searchText.IsNullOrEmpty(), a => a.Name.Contains(searchText)).ToList(); + return ZItem.Concat(RoleUtil.BuildTreeItemList(items, new List { Value })).ToList(); + } + + public void Dispose() + { + context?.Dispose(); + DispatchService.UnSubscribe(Refresh); + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/RoleTree.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/RoleTree.razor.css new file mode 100644 index 000000000..ad59ff824 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/RoleTree.razor.css @@ -0,0 +1,10 @@ + +.listtree-view { + height: 100%; +} + + .listtree-view ::deep .tree-view { + --bb-tree-search-height: 32px; + min-height: 300px; + height: calc(100% - var(--bb-tree-search-height) - 50px); + } diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRoleEdit.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRoleEdit.razor new file mode 100644 index 000000000..7e6dfca0d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRoleEdit.razor @@ -0,0 +1,62 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application +
+ + +
+ +
+
+ +
+ +
+ @if (AppContext.CurrentUser.IsGlobal) + { + + + } +
+
+ @if (Model.Category == RoleCategoryEnum.Org) + { + + } +
+ +
+ +
+
+ @if (Model.DefaultDataScope.ScopeCategory == DataScopeEnum.SCOPE_ORG_DEFINE) + { + + + + + + + + + + + + } + +
+ +
+ +
+
+ +
+
+ diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRoleEdit.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRoleEdit.razor.cs new file mode 100644 index 000000000..6b1465473 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRoleEdit.razor.cs @@ -0,0 +1,70 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; +using ThingsGateway.Extension.Generic; +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysRoleEdit +{ + + [Inject] + private IStringLocalizer? AdminLocalizer { get; set; } + + + [Parameter] + [NotNull] + public SysRole? Model { get; set; } + + [NotNull] + private List> Items { get; set; } + [Inject] + [NotNull] + private ISysOrgService? SysOrgService { get; set; } + + private static bool ModelEqualityComparer(SysOrg x, SysOrg y) => x.Id == y.Id; + + private Task OnTreeItemChecked(List> items) + { + Model.DefaultDataScope.ScopeDefineOrgIdList = items.Select(a => a.Value.Id).ToList(); + StateHasChanged(); + return Task.CompletedTask; + } + + private List? items; + [Inject] + private BlazorAppContext AppContext { get; set; } + protected override async Task OnInitializedAsync() + { + items = (await SysOrgService.SelectorAsync()); + Items = OrgUtil.BuildTreeItemList(items, Model.DefaultDataScope.ScopeDefineOrgIdList).ToList(); + + OrgItems = OrgUtil.BuildTreeIdItemList(items, new List { Model.OrgId }); + + if (!AppContext.CurrentUser.IsGlobal) + Model.Category = RoleCategoryEnum.Org; + await base.OnInitializedAsync(); + } + + private string SearchText; + private async Task>> OnClickSearch(string searchText) + { + SearchText = searchText; + var items = (await SysOrgService.SelectorAsync()); + items = items.WhereIf(!searchText.IsNullOrEmpty(), a => a.Name.Contains(searchText)).ToList(); + return OrgUtil.BuildTreeItemList(items, Model.DefaultDataScope.ScopeDefineOrgIdList); + } + [NotNull] + private List> OrgItems { get; set; } + + +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRolePage.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRolePage.razor new file mode 100644 index 000000000..93d18fb02 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRolePage.razor @@ -0,0 +1,50 @@ +@page "/admin/role" +@attribute [Authorize] +@attribute [RolePermission] +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor +
+ +
+ + + + + + +
+
+ + + + + + + + + + + +
+
+ +@code { + [NotNull] + AdminTable? table { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRolePage.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRolePage.razor.cs new file mode 100644 index 000000000..fcc463ddf --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Role/SysRolePage.razor.cs @@ -0,0 +1,241 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysRolePage +{ + private SysRole? SearchModel { get; set; } = new(); + private long OrgId { get; set; } + + [Inject] + [NotNull] + private ISysRoleService? SysRoleService { get; set; } + + [Inject] + [NotNull] + private ISysResourceService? SysResourceService { get; set; } + + [Inject] + [NotNull] + private ISysOrgService? SysOrgService { get; set; } + + + #region 查询 + + private async Task> OnQueryAsync(QueryPageOptions options) + { + var orgIds = await SysOrgService.GetOrgChildIdsAsync(OrgId);//获取下级机构 + var data = await SysRoleService.PageAsync(options, a => a.WhereIF(OrgId != 0, b => orgIds.Contains(b.OrgId))); + return data; + } + private Task TreeChangedAsync(long id) + { + OrgId = id; + return table.QueryAsync(); + } + #endregion 查询 + + #region 修改 + + private async Task Delete(IEnumerable sysRoles) + { + try + { + return await SysRoleService.DeleteRoleAsync(sysRoles.Select(a => a.Id)); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + private async Task Save(SysRole sysRole, ItemChangedType itemChangedType) + { + try + { + return await SysRoleService.SaveRoleAsync(sysRole, itemChangedType); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + #endregion 修改 + + #region 授权 + + private async Task GrantApi(long id) + { + var hasResources = (await SysRoleService.ApiOwnPermissionAsync(id))?.GrantInfoList; + var ids = new List(); + ids.AddRange(hasResources.Select(a => a.ApiUrl)); + + + var op = new DialogOption() + { + IsScrolling = true, + Size = Size.ExtraLarge, + Title = OperDescLocalizer["RoleGrantApiPermission"], + ShowCloseButton = false, + ShowMaximizeButton = true, + ShowSaveButton = true, + OnSaveAsync = async () => + { + try + { + GrantPermissionData data = new(); + data.Id = id; + data.GrantInfoList = ids.Select(a => new RelationPermission() { ApiUrl = a }); + await SysRoleService.GrantApiPermissionAsync(data); + await ToastService.Default(); + return true; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + }, + Class = "dialog-table", + BodyTemplate = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(GrantApiDialog.Value)] = ids, + [nameof(GrantApiDialog.ValueChanged)] = (List v) => { ids = v; return Task.CompletedTask; }, + }).Render(), + }; + + + await DialogService.Show(op); + } + + private async Task GrantResource(long id) + { + var grantInfoList = (await SysRoleService.OwnResourceAsync(id))?.GrantInfoList.ToList(); + + var menuData = grantInfoList.Select(a => a.MenuId); + var buttonData = grantInfoList.SelectMany(a => a.ButtonIds); + var value = menuData.Concat(buttonData).ToList(); + + var op = new DialogOption() + { + IsScrolling = true, + Size = Size.ExtraLarge, + Title = OperDescLocalizer["RoleGrantResource"], + ShowCloseButton = false, + ShowMaximizeButton = true, + ShowSaveButton = true, + OnSaveAsync = async () => + { + try + { + GrantResourceData data = new(); + + var allResource = await SysResourceService.GetAllAsync(); + var resources = allResource.Where(a => value.Contains(a.Id)); + var pResources = SysResourceService.GetMyParentResources(allResource, resources); + var grantInfoList = new List(); + foreach (var item in pResources.Concat(resources).Distinct().Where(a => a.Category == ResourceCategoryEnum.Menu && !a.Href.IsNullOrEmpty())) + { + var relationResourcePermission = new RelationResourcePermission(); + relationResourcePermission.MenuId = item.Id; + relationResourcePermission.ButtonIds = SysResourceService.GetResourceChilden(allResource, item.Id).Where(a => value.Contains(a.Id)).Select(a => a.Id).ToHashSet(); + grantInfoList.Add(relationResourcePermission); + } + + var buttons = resources.Where(a => a.Category == ResourceCategoryEnum.Button && a.ParentId == 0); + grantInfoList.Add(new RelationResourcePermission() + { + MenuId = 0, + ButtonIds = buttons.Select(a => a.Id).ToHashSet() + }); + + data.GrantInfoList = grantInfoList; + data.Id = id; + await SysRoleService.GrantResourceAsync(data); + + await ToastService.Default(); + return true; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + + }, + Class = "dialog-table", + BodyTemplate = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(GrantResourceDialog.Value)] = value, + [nameof(GrantResourceDialog.ValueChanged)] = (List v) => { value = v; return Task.CompletedTask; }, + }).Render(), + }; + await DialogService.Show(op); + } + + + private async Task GrantUser(long id) + { + var data = (await SysRoleService.OwnUserAsync(id)).ToHashSet(); + GrantUserChoiceValues = data; + var option = new DialogOption() + { + IsScrolling = true, + Size = Size.ExtraExtraLarge, + Title = OperDescLocalizer["RoleGrantUser"], + ShowMaximizeButton = true, + Class = "dialog-table", + ShowSaveButton = true, + OnSaveAsync = async () => + { + try + { + await OnGrantUserValueChanged(GrantUserChoiceValues, id, true); + await ToastService.Default(); + return true; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + }, + BodyTemplate = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(UserChoiceDialog.Values)] = data, + [nameof(UserChoiceDialog.ValuesChanged)] = (HashSet v) => OnGrantUserValueChanged(v, id) + }).Render(), + + }; + await DialogService.Show(option); + + } + private HashSet GrantUserChoiceValues = new(); + private async Task OnGrantUserValueChanged(HashSet values, long roleId, bool change = false) + { + GrantUserChoiceValues = values; + if (change) + { + GrantUserOrRoleInput userGrantRoleInput = new(); + userGrantRoleInput.Id = roleId; + userGrantRoleInput.GrantInfoList = values; + await SysRoleService.GrantUserAsync(userGrantRoleInput); + } + } + + #endregion 授权 +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/SessionPage.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/SessionPage.razor new file mode 100644 index 000000000..d49f52b4d --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/SessionPage.razor @@ -0,0 +1,43 @@ +@page "/admin/session" +@attribute [Authorize] +@attribute [RolePermission] +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor + + + +
+ + + + + + + + +
+ +@code { + [NotNull] + AdminTable? table { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/SessionPage.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/SessionPage.razor.cs new file mode 100644 index 000000000..6fc2253ac --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/SessionPage.razor.cs @@ -0,0 +1,61 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class SessionPage +{ + private SessionOutput? SearchModel { get; set; } = new(); + + [Inject] + [NotNull] + private ISessionService? SessionService { get; set; } + + #region 查询 + + private async Task> OnQueryAsync(QueryPageOptions options) + { + return await Task.Run(async () => + { + var data = await SessionService.PageAsync(options); + return data; + }); + } + + #endregion 查询 + + #region 弹出令牌信息表 + + private async Task ShowVerificatList(SessionOutput sessionOutput) + { + if (sessionOutput.VerificatSignList?.Count > 0) + { + var op = new DialogOption() + { + IsScrolling = false, + Title = Localizer[nameof(VerificatInfo)], + ShowMaximizeButton = true, + Class = "dialog-table", + ShowFooter = false, + Size = Size.ExtraExtraLarge, + }; + op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(VerificatListDialog.UserId)] = sessionOutput.Id, + [nameof(VerificatListDialog.VerificatInfos)] = sessionOutput.VerificatSignList, + }); + await DialogService.Show(op); + } + } + + #endregion 弹出令牌信息表 +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/VerificatListDialog.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/VerificatListDialog.razor new file mode 100644 index 000000000..3384a6a53 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/VerificatListDialog.razor @@ -0,0 +1,42 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application +@using ThingsGateway.Extension +@using ThingsGateway.Extension.Generic +@inherits ComponentDefault + + + + { + await Task.Run(async()=>{ + if(a.Any()) + { + await SessionService.ExitVerificat(new ExitVerificatInput(){ Id=UserId, VerificatIds= a.Select(b=> b.Id )}); + VerificatInfos.RemoveWhere(c=>a.Contains(c)); + await InvokeAsync(table.QueryAsync); + } + }); + }) /> + + + + + + +@code { + [NotNull] + AdminTable? table { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/VerificatListDialog.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/VerificatListDialog.razor.cs new file mode 100644 index 000000000..95464e42f --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/Session/VerificatListDialog.razor.cs @@ -0,0 +1,38 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class VerificatListDialog +{ + [Parameter] + public long UserId { get; set; } + + [Parameter] + public List VerificatInfos { get; set; } + + private VerificatInfo? SearchModel { get; set; } = new(); + + [Inject] + [NotNull] + private ISessionService? SessionService { get; set; } + + #region 查询 + + private Task> OnQueryAsync(QueryPageOptions options) + { + var data = VerificatInfos.GetQueryData(options); + return Task.FromResult(data); + } + + #endregion 查询 +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantApiDialog.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantApiDialog.razor new file mode 100644 index 000000000..3b8580f54 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantApiDialog.razor @@ -0,0 +1,16 @@ +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor + +
+ + +
+ +@code { + RenderFragment RenderTreeItem => item => + @
+ @item.ApiName + @item.ApiRoute +
; +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantApiDialog.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantApiDialog.razor.cs new file mode 100644 index 000000000..93dd833d4 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantApiDialog.razor.cs @@ -0,0 +1,54 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class GrantApiDialog +{ + [Parameter] + [EditorRequired] + [NotNull] + public List Value { get; set; } + + [Parameter] + public Func, Task> ValueChanged { get; set; } + + [NotNull] + private List>? Items { get; set; } + + [Inject] + [NotNull] + private IApiPermissionService? ApiPermissionService { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + var items = ApiPermissionService.ApiPermissionTreeSelector(); + Items = OpenApiUtil.BuildTreeItemList(items, Value, RenderTreeItem); + } + + private static bool ModelEqualityComparer(OpenApiPermissionTreeSelector x, OpenApiPermissionTreeSelector y) => x.ApiRoute == y.ApiRoute; + + + + + + private async Task OnTreeItemChecked(List> items) + { + var value = items.Where(a => a.Items == null || a.Items.Count <= 0).Select(a => a.Value.ApiRoute).ToList(); + Value = value; + if (ValueChanged != null) + { + await ValueChanged.Invoke(value); + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantApiDialog.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantApiDialog.razor.css new file mode 100644 index 000000000..91b798be3 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantApiDialog.razor.css @@ -0,0 +1,32 @@ +.menu-type { + width: 60px; + margin-left: 1rem; + text-align: left; + color: var(--bs-primary); +} + +.menu-text { + margin-left: 1rem; + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + color: var(--bs-info); +} + +.tree-menu { + display: flex; + flex-direction: column; +} + + .tree-menu ::deep .tree { + flex: 1; + margin: -1rem; + padding: 1rem; + max-height: calc(100vh - 200px); + overflow-y: auto; + overflow-x: hidden; + } +.tree-menu-item { + padding-right: 5rem !important; + padding-left: 0rem !important; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantResourceDialog.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantResourceDialog.razor new file mode 100644 index 000000000..25d71b7c5 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantResourceDialog.razor @@ -0,0 +1,19 @@ +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor + +
+ + +
+ +@code { + RenderFragment RenderTreeItem => item => + @
+ @item.Title + @item.SortCode + @item.Category.ToDisplayName() + @GetApp(item.Module) + @item.Href +
; +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantResourceDialog.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantResourceDialog.razor.cs new file mode 100644 index 000000000..7371cc520 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantResourceDialog.razor.cs @@ -0,0 +1,58 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class GrantResourceDialog +{ + private List ModuleList; + + [Parameter] + [EditorRequired] + [NotNull] + public List Value { get; set; } + + [Parameter] + public Func, Task> ValueChanged { get; set; } + + [NotNull] + private List>? Items { get; set; } + + [Inject] + [NotNull] + private ISysResourceService? SysResourceService { get; set; } + + + protected override async Task OnInitializedAsync() + { + var items = (await SysResourceService.GetAllAsync()).Where(a => a.Category != ResourceCategoryEnum.Module).OrderBy(a => a.Module).ThenBy(a => a.Id).ToList(); + + Items = ResourceUtil.BuildTreeItemList(items, Value, RenderTreeItem); + ModuleList = (await SysResourceService.GetAllAsync()).Where(a => a.Category == ResourceCategoryEnum.Module).ToList(); + await base.OnInitializedAsync(); + } + + private string GetApp(long? moduleId) => ModuleList.FirstOrDefault(i => i.Id == moduleId)?.Title; + + private static bool ModelEqualityComparer(SysResource x, SysResource y) => x.Id == y.Id; + + + private async Task OnTreeItemChecked(List> items) + { + var value = items.Select(a => a.Value.Id).ToList(); + Value = value; + if (ValueChanged != null) + { + await ValueChanged.Invoke(value); + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantResourceDialog.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantResourceDialog.razor.css new file mode 100644 index 000000000..91b798be3 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/GrantResourceDialog.razor.css @@ -0,0 +1,32 @@ +.menu-type { + width: 60px; + margin-left: 1rem; + text-align: left; + color: var(--bs-primary); +} + +.menu-text { + margin-left: 1rem; + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + color: var(--bs-info); +} + +.tree-menu { + display: flex; + flex-direction: column; +} + + .tree-menu ::deep .tree { + flex: 1; + margin: -1rem; + padding: 1rem; + max-height: calc(100vh - 200px); + overflow-y: auto; + overflow-x: hidden; + } +.tree-menu-item { + padding-right: 5rem !important; + padding-left: 0rem !important; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/RoleChoiceDialog.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/RoleChoiceDialog.razor new file mode 100644 index 000000000..11c013d44 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/RoleChoiceDialog.razor @@ -0,0 +1,24 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application + + +
+ +
+ + + + + + + +
+
+ +
+
+ +@code { + [NotNull] + ChoiceTable? userChoiceTable { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/RoleChoiceDialog.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/RoleChoiceDialog.razor.cs new file mode 100644 index 000000000..b927e3761 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/RoleChoiceDialog.razor.cs @@ -0,0 +1,64 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Mapster; + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class RoleChoiceDialog +{ + [Inject] + [NotNull] + public IStringLocalizer? AdminLocalizer { get; set; } + + [Parameter] + [NotNull] + public HashSet Values { get; set; } + private HashSet SelectedRows { get; set; } = new(); + protected override async Task OnInitializedAsync() + { + SelectedRows = (await SysRoleService.GetRoleListByIdListAsync(Values)).ToHashSet(); + await base.OnInitializedAsync(); + } + [Parameter] + [NotNull] + public Func, Task> ValuesChanged { get; set; } + private async Task OnChanged(HashSet values) + { + SelectedRows = values; + Values = SelectedRows.Select(a => a.Id).ToHashSet(); + if (ValuesChanged != null) + { + await ValuesChanged.Invoke(Values); + } + } + + [Inject] + [NotNull] + private ISysRoleService? SysRoleService { get; set; } + private async Task> OnQueryAsync(QueryPageOptions options) + { + var data = await SysRoleService.PageAsync(options, a => a.Where(b => b.OrgId == OrgId)); + QueryData queryData = data.Adapt>(); + + return queryData; + } + #region 查询 + private long OrgId { get; set; } + + private async Task OrgTreeChangedAsync(long parentId) + { + OrgId = parentId; + await userChoiceTable.QueryAsync(); + } + #endregion +} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatusPage.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/RoleChoiceDialog.razor.css similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatusPage.razor.css rename to src/Admin/ThingsGateway.Admin.Razor/Pages/User/RoleChoiceDialog.razor.css diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserAvatarEdit.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserAvatarEdit.razor new file mode 100644 index 000000000..5fc350c41 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserAvatarEdit.razor @@ -0,0 +1,7 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application + + + + + diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserAvatarEdit.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserAvatarEdit.razor.cs new file mode 100644 index 000000000..8d1a059cf --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserAvatarEdit.razor.cs @@ -0,0 +1,73 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysUserAvatarEdit : IDisposable +{ + private List PreviewFileList; + + [Parameter] + [NotNull] + public SysUser? Model { get; set; } + + [FileValidation(Extensions = [".png", ".jpg", ".jpeg"], FileSize = 200 * 1024)] + public IBrowserFile? Picture { get; set; } + + [Inject] + private IStringLocalizer AdminLocalizer { get; set; } + + private CancellationTokenSource? ReadAvatarToken { get; set; } + + [Inject] + [NotNull] + private ToastService ToastService { get; set; } + + public void Dispose() + { + ReadAvatarToken?.Cancel(); + GC.SuppressFinalize(this); + } + + protected override Task OnParametersSetAsync() + { + PreviewFileList = new(new[] { new UploadFile { PrevUrl = Model.Avatar } }); + return base.OnParametersSetAsync(); + } + + private async Task OnAvatarUpload(UploadFile file) + { + if (file != null && file.File != null) + { + var format = file.File.ContentType; + ReadAvatarToken ??= new CancellationTokenSource(); + if (ReadAvatarToken.IsCancellationRequested) + { + ReadAvatarToken.Dispose(); + ReadAvatarToken = new CancellationTokenSource(); + } + + await file.RequestBase64ImageFileAsync(format, 640, 480, 1024 * 200, ReadAvatarToken.Token); + + if (file.Code != 0) + { + await ToastService.Error($"{file.Error} "); + } + else + { + Model.Avatar = file.PrevUrl; + } + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserEdit.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserEdit.razor new file mode 100644 index 000000000..96358d014 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserEdit.razor @@ -0,0 +1,41 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application +
+ +
+ +
+ +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ +
+ +
+ +
+ a.DirectorId) Values="new HashSet(){Model.DirectorId??0}" + ValuesChanged="(a)=>Model.DirectorId=a.FirstOrDefault()"> +
+ +
+ +
+
+ +
+
+ diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserEdit.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserEdit.razor.cs new file mode 100644 index 000000000..b999d5aad --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserEdit.razor.cs @@ -0,0 +1,48 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysUserEdit +{ + + private List ModuleSelectedItems { get; set; } + [Inject] + private IStringLocalizer? AdminLocalizer { get; set; } + [Inject] + private ISysPositionService? SysPositionService { get; set; } + + + [Parameter] + [NotNull] + public SysUser? Model { get; set; } + + private List Items { get; set; } + private List BoolItems; + [Inject] + private ISysResourceService SysResourceService { get; set; } + protected override async Task OnInitializedAsync() + { + BoolItems = LocalizerUtil.GetBoolItems(Model.GetType(), nameof(Model.Status)); + var items = await SysPositionService.SelectorAsync(new PositionSelectorInput() { }); + Items = PositionUtil.BuildCascaderItemList(items); + ModuleSelectedItems = ResourceUtil.BuildModuleSelectList((await SysResourceService.GetAllAsync())).ToList(); + await base.OnInitializedAsync(); + } + + private Task OnSelectedItemChanged(CascaderItem[] items) + { + Model.OrgId = items.LastOrDefault()?.Parent?.Value?.ToLong() ?? 0; + return Task.CompletedTask; + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserPage.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserPage.razor new file mode 100644 index 000000000..9427dbdcd --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserPage.razor @@ -0,0 +1,58 @@ +@page "/admin/user" +@attribute [Authorize] +@attribute [RolePermission] +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor + +
+ +
+ + + + + + +
+
+ + + + + + + + + + + + + + + + +
+
+ +@code { + [NotNull] + AdminTable? table { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserPage.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserPage.razor.cs new file mode 100644 index 000000000..5349f1607 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserPage.razor.cs @@ -0,0 +1,247 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Admin.Razor; + +public partial class SysUserPage +{ + private SysUser? SearchModel { get; set; } = new(); + private long OrgId { get; set; } + + [Inject] + [NotNull] + private ISysUserService? SysUserService { get; set; } + + [Inject] + [NotNull] + private ISysResourceService? SysResourceService { get; set; } + + #region 查询 + + private async Task> OnQueryAsync(QueryPageOptions options) + { + var data = await SysUserService.PageAsync(options, new UserSelectorInput() { OrgId = OrgId }); + return data; + } + private Task TreeChangedAsync(long id) + { + OrgId = id; + return table.QueryAsync(); + } + #endregion 查询 + + #region 修改 + + private async Task Delete(IEnumerable sysUsers) + { + try + { + return await SysUserService.DeleteUserAsync(sysUsers.Select(a => a.Id)); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + private async Task GrantApi(long id) + { + var hasResources = (await SysUserService.ApiOwnPermissionAsync(id))?.GrantInfoList; + var ids = new List(); + ids.AddRange(hasResources.Select(a => a.ApiUrl)); + + var op = new DialogOption() + { + IsScrolling = true, + Size = Size.ExtraLarge, + Title = OperDescLocalizer["UserGrantApiPermission"], + ShowCloseButton = false, + ShowMaximizeButton = true, + ShowSaveButton = true, + OnSaveAsync = async () => + { + try + { + GrantPermissionData data = new(); + data.Id = id; + data.GrantInfoList = ids.Select(a => new RelationPermission() { ApiUrl = a }); + await SysUserService.GrantApiPermissionAsync(data); + await ToastService.Default(); + return true; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + }, + Class = "dialog-table", + BodyTemplate = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(GrantApiDialog.Value)] = ids, + [nameof(GrantApiDialog.ValueChanged)] = (List v) => { ids = v; return Task.CompletedTask; }, + }).Render(), + }; + + await DialogService.Show(op); + } + + private async Task GrantResource(long id) + { + var grantInfoList = (await SysUserService.OwnResourceAsync(id))?.GrantInfoList.ToList(); + + var menuData = grantInfoList.Select(a => a.MenuId); + var buttonData = grantInfoList.SelectMany(a => a.ButtonIds); + var value = menuData.Concat(buttonData).ToList(); + var op = new DialogOption() + { + IsScrolling = true, + Size = Size.ExtraLarge, + Title = OperDescLocalizer["UserGrantResource"], + ShowCloseButton = false, + ShowMaximizeButton = true, + ShowSaveButton = true, + OnSaveAsync = async () => + { + try + { + GrantResourceData data = new(); + + + var allResource = await SysResourceService.GetAllAsync(); + var resources = allResource.Where(a => value.Contains(a.Id)); + var pResources = SysResourceService.GetMyParentResources(allResource, resources); + var grantInfoList = new List(); + foreach (var item in pResources.Concat(resources).Distinct().Where(a => a.Category == ResourceCategoryEnum.Menu && !a.Href.IsNullOrEmpty())) + { + var relationResourcePermission = new RelationResourcePermission(); + relationResourcePermission.MenuId = item.Id; + relationResourcePermission.ButtonIds = SysResourceService.GetResourceChilden(allResource, item.Id).Where(a => value.Contains(a.Id)).Select(a => a.Id).ToHashSet(); + grantInfoList.Add(relationResourcePermission); + } + + var buttons = resources.Where(a => a.Category == ResourceCategoryEnum.Button && a.ParentId == 0); + grantInfoList.Add(new RelationResourcePermission() + { + MenuId = 0, + ButtonIds = buttons.Select(a => a.Id).ToHashSet() + }); + + data.GrantInfoList = grantInfoList; + data.Id = id; + + await SysUserService.GrantResourceAsync(data); + + await ToastService.Default(); + return true; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + + }, + Class = "dialog-table", + BodyTemplate = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(GrantResourceDialog.Value)] = value, + [nameof(GrantResourceDialog.ValueChanged)] = (List v) => { value = v; return Task.CompletedTask; }, + }).Render(), + }; + await DialogService.Show(op); + } + + public HashSet GrantRoleChoiceValues { get; set; } + private async Task GrantRole(long id) + { + var data = (await SysUserService.OwnRoleAsync(id)).ToHashSet(); + GrantRoleChoiceValues = data; + var option = new DialogOption() + { + IsScrolling = true, + Size = Size.ExtraExtraLarge, + Title = OperDescLocalizer["UserGrantRole"], + ShowMaximizeButton = true, + Class = "dialog-table", + ShowSaveButton = true, + OnSaveAsync = async () => + { + try + { + await OnGrantRoleValueChanged(GrantRoleChoiceValues, id, true); + await ToastService.Default(); + return true; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + }, + BodyTemplate = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(RoleChoiceDialog.Values)] = data, + [nameof(RoleChoiceDialog.ValuesChanged)] = (HashSet v) => OnGrantRoleValueChanged(v, id), + + }).Render(), + + }; + await DialogService.Show(option); + } + + private async Task OnGrantRoleValueChanged(HashSet values, long userId, bool change = false) + { + if (GrantRoleChoiceValues != values) + { + GrantRoleChoiceValues = values; + } + if (change) + { + GrantUserOrRoleInput userGrantRoleInput = new(); + userGrantRoleInput.Id = userId; + userGrantRoleInput.GrantInfoList = GrantRoleChoiceValues; + await SysUserService.GrantRoleAsync(userGrantRoleInput); + } + } + + + private async Task ResetPassword(long id) + { + try + { + await SysUserService.ResetPasswordAsync(id); + await ToastService.Default(); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + } + } + + private async Task Save(SysUser sysUser, ItemChangedType itemChangedType) + { + try + { + return await SysUserService.SaveUserAsync(sysUser, itemChangedType); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + } + + #endregion 修改 +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserPage.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserPage.razor.css new file mode 100644 index 000000000..4d04d3507 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/SysUserPage.razor.css @@ -0,0 +1,4 @@ +.user-avatar { + width: 24px; + height:24px; +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoice.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoice.razor new file mode 100644 index 000000000..cb246bdb4 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoice.razor @@ -0,0 +1,8 @@ +@namespace ThingsGateway.Admin.Razor + + + + @* *@ + + + diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoice.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoice.razor.cs new file mode 100644 index 000000000..a1ca0a9f7 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoice.razor.cs @@ -0,0 +1,102 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class UserChoice +{ + [Inject] + [NotNull] + public IStringLocalizer? AdminLocalizer { get; set; } + + [Inject] + [NotNull] + public ISysUserService SysUserService { get; set; } + + [EditorRequired] + [Parameter] + [NotNull] + public string? DisplayText { get; set; } + + public HashSet ChoiceValues { get; set; } + + [Parameter] + [NotNull] + public HashSet Values { get; set; } = new(); + + private long SelectedArrayValue { get; set; } + + [Parameter] + [NotNull] + public EventCallback> ValuesChanged { get; set; } + + [Inject] + [NotNull] + private DialogService? DialogService { get; set; } + + [Inject] + [NotNull] + private ToastService? ToastService { get; set; } + + public IEnumerable Items { get; set; } + private Task OnClearText() => OnValueChanged(new(), true); + + private async Task OnSelect() + { + var option = new DialogOption() + { + IsScrolling = true, + Size = Size.ExtraExtraLarge, + Title = AdminLocalizer["Choice"], + ShowMaximizeButton = true, + Class = "dialog-table", + ShowSaveButton = true, + OnSaveAsync = async () => + { + try + { + await OnValueChanged(ChoiceValues, true); + await ToastService.Default(); + return true; + } + catch (Exception ex) + { + await ToastService.Warn(ex); + return false; + } + }, + BodyTemplate = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + [nameof(UserChoiceDialog.MaxCount)] = 1, + [nameof(UserChoiceDialog.Values)] = Values, + [nameof(UserChoiceDialog.ValuesChanged)] = (HashSet v) => OnValueChanged(v) + }).Render(), + + }; + await DialogService.Show(option); + } + + private async Task OnValueChanged(HashSet values, bool change = false) + { + if (ChoiceValues != values) + { + ChoiceValues = values; + } + if (change && ValuesChanged.HasDelegate) + { + Values = ChoiceValues; + Items = UserUtil.BuildUserSelectList(await SysUserService.GetUserListByIdListAsync(Values)); + SelectedArrayValue = Values.FirstOrDefault(); + await ValuesChanged.InvokeAsync(Values); + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoice.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoice.razor.css new file mode 100644 index 000000000..5f282702b --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoice.razor.css @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoiceDialog.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoiceDialog.razor new file mode 100644 index 000000000..fd0b13279 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoiceDialog.razor @@ -0,0 +1,30 @@ +@namespace ThingsGateway.Admin.Razor +@using ThingsGateway.Admin.Application + + +
+ +
+ + + + + + + + + + + + + +
+
+ +
+
+ +@code { + [NotNull] + ChoiceTable? userChoiceTable { get; set; } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoiceDialog.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoiceDialog.razor.cs new file mode 100644 index 000000000..2fd90d11e --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoiceDialog.razor.cs @@ -0,0 +1,83 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Mapster; + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class UserChoiceDialog +{ + [Inject] + [NotNull] + public IStringLocalizer? AdminLocalizer { get; set; } + + [Parameter] + [NotNull] + public HashSet Values { get; set; } + private HashSet SelectedRows { get; set; } = new(); + protected override async Task OnInitializedAsync() + { + SelectedRows = (await SysUserService.GetUserListByIdListAsync(Values)).ToHashSet(); + await base.OnInitializedAsync(); + } + [Parameter] + [NotNull] + public Func, Task> ValuesChanged { get; set; } + private async Task OnChanged(HashSet values) + { + SelectedRows = values; + Values = SelectedRows.Select(a => a.Id).ToHashSet(); + if (ValuesChanged != null) + { + await ValuesChanged.Invoke(Values); + } + } + + [Parameter] + public int MaxCount { get; set; } = 0; + + [Inject] + [NotNull] + private ISysUserService? SysUserService { get; set; } + private async Task> OnQueryAsync(QueryPageOptions options) + { + var data = await SysUserService.PageAsync(options, new UserSelectorInput() + { + RoleId = RoleId, + OrgId = OrgId, + PositionId = PositionId, + }); + QueryData queryData = data.Adapt>(); + + return queryData; + } + #region 查询 + private long RoleId { get; set; } + private long OrgId { get; set; } + private long PositionId { get; set; } + private async Task RoleTreeChangedAsync(long parentId) + { + RoleId = parentId; + await userChoiceTable.QueryAsync(); + } + private async Task PositionTreeChangedAsync(long parentId) + { + PositionId = parentId; + await userChoiceTable.QueryAsync(); + } + private async Task OrgTreeChangedAsync(long parentId) + { + OrgId = parentId; + await userChoiceTable.QueryAsync(); + } + #endregion +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoiceDialog.razor.css b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoiceDialog.razor.css new file mode 100644 index 000000000..5f282702b --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/User/UserChoiceDialog.razor.css @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/UserCenter/UserCenterPage.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/UserCenter/UserCenterPage.razor new file mode 100644 index 000000000..237b99d92 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/UserCenter/UserCenterPage.razor @@ -0,0 +1,21 @@ +@page "/usercenter" +@attribute [Authorize] +@attribute [RolePermission] +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@namespace ThingsGateway.Admin.Razor + + + + + + + + + + + + + + + diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/UserCenter/UserCenterPage.razor.cs b/src/Admin/ThingsGateway.Admin.Razor/Pages/UserCenter/UserCenterPage.razor.cs new file mode 100644 index 000000000..2e99c7ec8 --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/UserCenter/UserCenterPage.razor.cs @@ -0,0 +1,97 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Mapster; + +using Microsoft.AspNetCore.Components.Forms; + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.Admin.Razor; + +public partial class UserCenterPage +{ + + [CascadingParameter(Name = "ReloadMenu")] + private Func? ReloadMenu { get; set; } + + [CascadingParameter(Name = "ReloadUser")] + private Func? ReloadUser { get; set; } + + private SysUser SysUser { get; set; } + + private UpdatePasswordInput UpdatePasswordInput { get; set; } = new(); + + [Inject] + [NotNull] + private IUserCenterService? UserCenterService { get; set; } + + private WorkbenchInfo WorkbenchInfo { get; set; } = new(); + + protected override async Task OnParametersSetAsync() + { + SysUser = AppContext.CurrentUser.Adapt(); + SysUser.Avatar = AppContext.CurrentUser.Avatar; + WorkbenchInfo = (await UserCenterService.GetLoginWorkbenchAsync(SysUser.Id)).Adapt(); + + await base.OnParametersSetAsync(); + } + + private async Task OnSavePassword(EditContext editContext) + { + try + { + await UserCenterService.UpdatePasswordAsync(UpdatePasswordInput); + if (ReloadUser != null) + { + await ReloadUser(); + } + await ToastService.Success(Localizer["UpdatePassword"], $"{RazorLocalizer["Save"]}{RazorLocalizer["Success"]}"); + } + catch (Exception ex) + { + await ToastService.Warning(Localizer["UpdatePassword"], $"{RazorLocalizer["Save"]}{RazorLocalizer["Fail", ex.Message]}"); + } + } + + private async Task OnSaveUserInfo(EditContext editContext) + { + try + { + await UserCenterService.UpdateUserInfoAsync(SysUser); + if (ReloadUser != null) + { + await ReloadUser(); + } + await ToastService.Success(Localizer["UpdateUserInfo"], $"{RazorLocalizer["Save"]}{RazorLocalizer["Success"]}"); + } + catch (Exception ex) + { + await ToastService.Warning(Localizer["UpdateUserInfo"], $"{RazorLocalizer["Save"]}{RazorLocalizer["Fail", ex.Message]}"); + } + } + + private async Task OnSaveWorkbench(EditContext editContext) + { + try + { + await UserCenterService.UpdateWorkbenchInfoAsync(WorkbenchInfo); + if (ReloadMenu != null) + { + await ReloadMenu(); + } + await ToastService.Success(Localizer["UpdateWorkbenchInfo"], $"{RazorLocalizer["Save"]}{RazorLocalizer["Success"]}"); + } + catch (Exception ex) + { + await ToastService.Warning(Localizer["UpdateWorkbenchInfo"], $"{RazorLocalizer["Save"]}{RazorLocalizer["Fail", ex.Message]}"); + } + } +} diff --git a/src/Admin/ThingsGateway.Admin.Razor/Pages/UserCenter/UserInfoEditComponent.razor b/src/Admin/ThingsGateway.Admin.Razor/Pages/UserCenter/UserInfoEditComponent.razor new file mode 100644 index 000000000..d3a043dbd --- /dev/null +++ b/src/Admin/ThingsGateway.Admin.Razor/Pages/UserCenter/UserInfoEditComponent.razor @@ -0,0 +1,13 @@ +@namespace ThingsGateway.Admin.Razor +@inherits ComponentDefault +@using ThingsGateway.Admin.Application + + + + + + + + + + + + + diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/AccessDenied.razor.cs b/src/Admin/ThingsGateway.AdminServer/Layout/AccessDenied.razor.cs new file mode 100644 index 000000000..bdc9139a0 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/AccessDenied.razor.cs @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; + +using System.Diagnostics.CodeAnalysis; + +using ThingsGateway.Admin.Application; + +namespace ThingsGateway.AdminServer; + +public partial class AccessDenied +{ + [SupplyParameterFromQuery] + [Parameter] + public string? ReturnUrl { get; set; } + + [Inject] + [NotNull] + private IAppService? AppService { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + [Inject] + [NotNull] + private NavigationManager? NavigationManager { get; set; } +} diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/BlazorApp.razor b/src/Admin/ThingsGateway.AdminServer/Layout/BlazorApp.razor new file mode 100644 index 000000000..1d810d3e8 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/BlazorApp.razor @@ -0,0 +1,49 @@ +@using BootstrapBlazor.Components +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.Extensions.Localization +@using ThingsGateway.Razor +@inject IHostEnvironment Env +@inject IStringLocalizer Localizer +@namespace ThingsGateway.AdminServer + + + + + + + + + + + + + + + + ThingsGateway + + + + + + @* *@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/Login.razor b/src/Admin/ThingsGateway.AdminServer/Layout/Login.razor new file mode 100644 index 000000000..7038308d7 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/Login.razor @@ -0,0 +1,55 @@ +@page "/Account/Login" +@layout BaseLayout +@namespace ThingsGateway.AdminServer +@using BootstrapBlazor.Components +@using ThingsGateway.Admin.Application; +@using ThingsGateway.Admin.Razor +@using ThingsGateway.Extension; +@using ThingsGateway.Razor + + diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/Login.razor.cs b/src/Admin/ThingsGateway.AdminServer/Layout/Login.razor.cs new file mode 100644 index 000000000..5726251ed --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/Login.razor.cs @@ -0,0 +1,110 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +#pragma warning disable CA2007 // 考虑对等待的任务调用 ConfigureAwait +using BootstrapBlazor.Components; + +using Mapster; + +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +using System.Diagnostics.CodeAnalysis; + +using ThingsGateway.Admin.Application; +using ThingsGateway.DataEncryption; +using ThingsGateway.NewLife.Extension; +using ThingsGateway.Razor; + +namespace ThingsGateway.AdminServer; + +public partial class Login +{ + private string _versionString = string.Empty; + private LoginInput loginModel = new LoginInput(); + + [SupplyParameterFromQuery] + [Parameter] + public string? ReturnUrl { get; set; } + + [Inject] + [NotNull] + private AjaxService? AjaxService { get; set; } + [Inject] + [NotNull] + private IAuthRazorService? AuthRazorService { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + [Inject] + [NotNull] + private ToastService? ToastService { get; set; } + + [Inject] + [NotNull] + private IAppVersionService? VersionService { get; set; } + + [Inject] + [NotNull] + private IAppService? AppService { get; set; } + [Inject] + [NotNull] + private IOptions? WebsiteOption { get; set; } + + protected override Task OnInitializedAsync() + { + _versionString = $"v{VersionService.Version}"; + return base.OnInitializedAsync(); + } + + private async Task LoginAsync(EditContext context) + { + var model = loginModel.Adapt(); + model.Password = DESEncryption.Encrypt(model.Password); + model.Device = AppService.ClientInfo.Device.Family; + + try + { + + var ret = await AuthRazorService.LoginAsync(model); + + if (ret.Code != 200) + { + await ToastService.Error(Localizer["LoginErrorh1"], $"{ret.Msg}"); + } + else + { + await ToastService.Information(Localizer["LoginSuccessh1"], Localizer["LoginSuccessc1"]); + await Task.Delay(1000); + + if (ReturnUrl.IsNullOrWhiteSpace() || ReturnUrl == @"/") + { + await AjaxService.Goto(ReturnUrl ?? "/"); + } + else + { + await AjaxService.Goto(ReturnUrl); + } + } + } + catch + { + await ToastService.Error(Localizer["LoginErrorh2"], Localizer["LoginErrorc2"]); + } + } + + + + +} diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/Login.razor.css b/src/Admin/ThingsGateway.AdminServer/Layout/Login.razor.css new file mode 100644 index 000000000..059cd2466 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/Login.razor.css @@ -0,0 +1,43 @@ +.login { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: 100%; + width: 100%; +} + +.login-form { + padding: 5rem 5rem 3rem 5rem; + font-size: 0.875rem; + box-shadow: 0px 20px 40px 0px rgba(0,0,0,0.3); + border-radius: 6px; +} + +.login-left { + box-shadow: 0 0px 3px 0 rgba(0,0,0,0.1),0 0 4px 0 rgba(0,0,0,0.1); +} + +@media (max-width: 1200px) { + .login-left { + display: none + } +} + +::deep .avatar { + border-radius: 1.5rem; + width: 36px; + height: 36px; + background-color: var(--bs-green); + color: #fff; +} + +::deep .h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 { + margin-bottom: 0; +} + +.user-avatar { + width: 36px; + height: 36px; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/MainLayout.razor b/src/Admin/ThingsGateway.AdminServer/Layout/MainLayout.razor new file mode 100644 index 000000000..31b5a9288 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/MainLayout.razor @@ -0,0 +1,110 @@ +@inherits LayoutComponentBase +@layout BaseLayout +@namespace ThingsGateway.AdminServer +@using BootstrapBlazor.Components +@using ThingsGateway.Admin.Application +@using ThingsGateway.Admin.Razor +@using ThingsGateway.Extension +@using ThingsGateway.NewLife.Extension +@using ThingsGateway.Razor +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.Extensions.Localization + +@using System.ComponentModel +@using System.ComponentModel.DataAnnotations + +@inject NavigationManager NavigationManager +@if (AppContext.CurrentUser != null && AppContext.OwnMenus != null) +{ + + + + + +
+ + +
+
+ + +
+ + @* 搜索框 *@ + + + @* 语言选择 *@ +
+ +
+ + + + @Localizer["系统首页"] + + @Localizer["UserCenter"] + @Localizer["Logout"] + + + + + + @* 全屏按钮 *@ + + + @if (WebsiteOption.Value.IsShowAbout) + { +
+ +
+ + @WebsiteOption.Value.Title?.GetNameLen2() + + +
+ @WebsiteOption.Value.Title +
+
+
+
+ + { + return Task.FromResult(!(a.Url=="/"||a.Url.IsNullOrEmpty())); + })> + + +
+ + + +
+ +
+ +
+
+ +} diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/MainLayout.razor.cs b/src/Admin/ThingsGateway.AdminServer/Layout/MainLayout.razor.cs new file mode 100644 index 000000000..a41ea4191 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/MainLayout.razor.cs @@ -0,0 +1,229 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +#pragma warning disable CA2007 // 考虑对等待的任务调用 ConfigureAwait +using BootstrapBlazor.Components; + +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +using System.Diagnostics.CodeAnalysis; + +using ThingsGateway.Admin.Application; +using ThingsGateway.Admin.Razor; +using ThingsGateway.Razor; + +namespace ThingsGateway.AdminServer; + +public partial class MainLayout : IDisposable +{ + #region 全局通知 + + [Inject] + [NotNull] + private IDispatchService? DispatchService { get; set; } + + private async Task Dispatch(DispatchEntry entry) + { + if (entry.Entry != null) + { + await InvokeAsync(async () => + { + await ToastService.Show(new ToastOption() + { + Title = $"{entry.Entry.Title} ", + Content = $"{entry.Entry.Message}", + Category = entry.Entry.Category, + Delay = 10 * 1000, + ForceDelay = true + }); + }); + } + } + + #endregion 全局通知 + + #region 切换模块 + + [Inject] + private ISysResourceService SysResourceService { get; set; } + + [Inject] + private IUserCenterService UserCenterService { get; set; } + + private Task ChoiceModule(long moduleId) + { + return ReloadMenu(moduleId); + } + + #endregion 切换模块 + + #region 个人信息修改 + + private Task OnUserInfoDialog() + { + return DialogService.Show(new DialogOption() + { + IsScrolling = false, + Title = Localizer["UserCenter"], + ShowFooter = false, + Component = BootstrapDynamicComponent.CreateComponent(new Dictionary() + { + }) + }); + } + + #endregion 个人信息修改 + + #region 注销 + + [Inject] + private AjaxService AjaxService { get; set; } + [Inject] + private IAppService AppService { get; set; } + [Inject] + [NotNull] + private IAuthRazorService? AuthRazorService { get; set; } + + private async Task LogoutAsync() + { + + try + { + + var ret = await AuthRazorService.LoginOutAsync(); + if (ret.Code != 200) + { + await ToastService.Error(Localizer["LoginErrorh1"], $"{ret.Msg}"); + } + else + { + await ToastService.Information(Localizer["LoginSuccessh1"], Localizer["LoginSuccessc1"]); + await Task.Delay(1000); + var url = AppService.GetReturnUrl(NavigationManager.ToBaseRelativePath(NavigationManager.Uri)); + await AjaxService.Goto(url); + } + } + catch + { + await ToastService.Error(Localizer["LoginErrorh2"], Localizer["LoginErrorc2"]); + } + } + + #endregion 注销 + + private string _versionString = string.Empty; + [Inject] + [NotNull] + private BlazorAppContext? AppContext { get; set; } + [Inject] + private IStringLocalizer AdminLocalizer { get; set; } + + [Inject] + private DialogService DialogService { get; set; } + + [Inject] + [NotNull] + private FullScreenService FullScreenService { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + [Inject] + [NotNull] + private IMenuService? MenuService { get; set; } + + [Inject] + [NotNull] + private ToastService? ToastService { get; set; } + + [Inject] + [NotNull] + private IAppVersionService? VersionService { get; set; } + + [Inject] + [NotNull] + private IOptions? WebsiteOption { get; set; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected override async Task OnInitializedAsync() + { + _versionString = $"v{VersionService.Version}"; + DispatchService.Subscribe(Dispatch); + await AppContext.InitUserAsync(); + await AppContext.InitMenus(NavigationManager.ToBaseRelativePath(NavigationManager.Uri)); + StateHasChanged(); + await base.OnInitializedAsync(); + } + private Tab Tab { get; set; } + protected override void OnAfterRender(bool firstRender) + { + if (firstRender) + { + //var items = Tab.Items.ToList(); + //var tab = Tab.GetActiveTab(); + //Tab.CloseAllTabs(); + //Tab.AddTab("", Localizer["系统首页"], "fas fa-house", false, false); + //foreach (var item in items) + //{ + // if (item.Url == "/" || item.Url.IsNullOrWhiteSpace()) + // continue; + // Tab.AddTab(item.Url, item.Text, item.Icon, item.IsActive, item.Closable); + //} + //if (!(tab.Url == "/" || tab.Url.IsNullOrWhiteSpace())) + // NavigationManager.NavigateTo(ServiceProvider, tab.Url, tab.Text, tab.Icon, true); + } + base.OnAfterRender(firstRender); + } + + [Inject] + IServiceProvider ServiceProvider { get; set; } + private void Dispose(bool disposing) + { + if (disposing) + { + DispatchService.UnSubscribe(Dispatch); + } + } + + private async Task ReloadMenu(long? moduleId = null) + { + await AppContext.InitMenus(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), moduleId); + await InvokeAsync(StateHasChanged); + } + + private async Task ReloadUser() + { + await AppContext.InitUserAsync(); + await InvokeAsync(StateHasChanged); + } + + private async Task ShowAbout() + { + DialogOption? op = null; + + op = new DialogOption() + { + IsScrolling = false, + Size = Size.Medium, + ShowFooter = false, + Title = Localizer["About"], + BodyTemplate = BootstrapDynamicComponent.CreateComponent().Render(), + }; + await DialogService.Show(op); + } +} diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/MainLayout.razor.css b/src/Admin/ThingsGateway.AdminServer/Layout/MainLayout.razor.css new file mode 100644 index 000000000..331d0124e --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/MainLayout.razor.css @@ -0,0 +1,152 @@ +::deep .avatar { + border-radius: 1.5rem; + width: 36px; + height: 36px; + background-color: var(--bs-green); + color: #fff; + flex: 0 0 auto; + font-size: 1rem; +} + +.mainlayout ::deep .menu-icon { + width: 16px; +} + +.mainlayout ::deep .layout-main > .tabs > .tabs-body { + background-color: var(--tabs-body-bg); +} + + .mainlayout ::deep .layout-main > .tabs > .tabs-body > .tabs-body-content { + height: var(--bb-layout-body-height); + background-color: var(--bs-body-bg); + padding: 4px; + } + +.mainlayout ::deep .tabs { + --bb-tabs-item-height: 32px; + --bb-tabs-body-padding: 0.5rem; +} + + .mainlayout ::deep .tabs.tabs-border-card { + box-shadow: 0 0px 0px 0 rgba(0,0,0,0),0 0 6px 0 rgba(0,0,0,0); + } + + .mainlayout ::deep .tabs .extend .nav-link-bar.left { + border-width: 0px 0px 0px 0px; + } + +.mainlayout ::deep .tabs-nav-wrap > .nav-link-bar.dropdown { + border-width: 0px 0px 0px 0px; +} + +.mainlayout ::deep .tabs .extend .nav-link-bar.right { + border-width: 0px 0px 0px 0px; +} + +.mainlayout ::deep .tabs .tabs-item-fix { + border-width: 0px 0px 0px 0px; +} + +.mainlayout ::deep .tabs.tabs-card > .tabs-header .tabs-item { + border-width: 0px 0px 0px 0px; + border: none; +} + +.mainlayout ::deep .tabs.tabs-border-card > .tabs-header .tabs-item { + border-width: 0px 0px 0px 0px; + border: none; +} + +.mainlayout ::deep .tabs.tabs-card .tabs-header .tabs-item.active { + border-width: 0px 0px 1px 0px; + border-style: solid; + border-color: var(--bb-tabs-item-active-color); +} + +.mainlayout ::deep .tabs.tabs-border-card .tabs-header .tabs-item.active { + border-width: 0px 0px 1px 0px; + border-style: solid; + border-color: var(--bb-tabs-item-active-color); +} + +.mainlayout ::deep .tabs.tabs-card .tabs-header .tabs-item.active { + background-color: var(--bs-primary-bg1); +} + +.mainlayout ::deep.tabs.tabs-card .tabs-header .tabs-item:hover { + background-color: var(--bs-primary-bg1); +} + +.mainlayout ::deep.tabs.tabs-border-card .tabs-header .tabs-item:hover { + background-color: var(--bs-primary-bg1); +} + +.mainlayout ::deep .tabs-nav-wrap .nav-link-bar { + font-size: 0.7rem; +} + +.mainlayout ::deep .tabs-item .tabs-item-close { + top: 5px; +} + +.mainlayout ::deep .table-wrapper { + border-radius: unset; +} + +.mainlayout ::deep .layout-side { + box-shadow: inset -1px 0 0px 0px var(--bs-border-color); /* 下内阴影 */ +} + +.mainlayout ::deep .layout-banner { + box-shadow: inset -1px 0 0px 0px var(--bs-border-color); /* 下内阴影 */ +} + +.mainlayout ::deep .layout { + --bb-layout-header-height: 44px; + --bb-layout-headerbar-background: transparent; + --bs-navbar-color: var(--bb-layout-header-color); + --bb-layout-header-color: var(--bs-body-color); + --bb-layout-title-color: var(--bs-body-color); + --bs-navbar-hover-color: var(--bs-primary); + --bb-layout-header-background: var(--tg-nav-bg); + --bb-layout-sidebar-background: var(--tg-nav-bg); + --bb-layout-footer-background: var(--tg-nav-bg); + --bb-layout-sidebar-banner-background: var(--tg-nav-bg); + --bb-layout-banner-font-size: 1.2rem; + --bb-layout-banner-logo-width: 36px; + --bb-layout-banner-logo-height: 36px; + --bb-layout-banner-border-color: var(--tg-nav-bg); + --bb-layout-header-border-color: var(--tg-nav-bg); + --line-chart-height: 350px; + --bb-layout-body-height: calc(100vh - var(--bs-header-height) - var(--bb-layout-header-height) - 20px); + --line-chart-table-height: calc(100% - var(--line-chart-height) - 20px); + --table-height: calc(100vh - var(--bs-header-height) - var(--bb-layout-header-height) - 40px); + --bs-header-height: 30px; +} + +@media (min-width: 768px) { + .mainlayout ::deep .layout-menu .scroll { + overflow-x: hidden; + } + + .mainlayout ::deep .layout-right { + width: calc(100vw - var(--bb-layout-sidebar-width)); + } +} + +.mainlayout ::deep .layout-header .dropdown-logout { + --bb-logout-avatar-width: 32px; + --bb-logout-avatar-height: 32px; + --bb-logout-user-bg: rgba(52,58,64,0.7); + --bb-logout-menu-border-color: var(--bs-border-color); +} + +.mainlayout ::deep .layout-header-bar { + border-color: transparent; + border: 0px; + color: var(--bb-layout-header-color); +} + + .mainlayout ::deep .layout-header-bar:hover { + color: var(--bs-navbar-hover-color); + } diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/NotFound404.razor b/src/Admin/ThingsGateway.AdminServer/Layout/NotFound404.razor new file mode 100644 index 000000000..6e7e2f0e1 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/NotFound404.razor @@ -0,0 +1,15 @@ +@page "/404" +@layout BaseLayout +@namespace ThingsGateway.AdminServer +@using BootstrapBlazor.Components +@using ThingsGateway.Admin.Application; +@using ThingsGateway.Extension; +@using ThingsGateway.Razor +
+
+ 404 +
+
@Localizer["404"]
+ +
+ diff --git a/src/Admin/ThingsGateway.AdminServer/Layout/NotFound404.razor.cs b/src/Admin/ThingsGateway.AdminServer/Layout/NotFound404.razor.cs new file mode 100644 index 000000000..ddbc51870 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Layout/NotFound404.razor.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; + +using System.Diagnostics.CodeAnalysis; + +namespace ThingsGateway.AdminServer; + +public partial class NotFound404 +{ + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + [Inject] + [NotNull] + private NavigationManager? NavigationManager { get; set; } +} diff --git a/src/ThingsGateway.Winform/Routes.razor b/src/Admin/ThingsGateway.AdminServer/Layout/Routes.razor similarity index 80% rename from src/ThingsGateway.Winform/Routes.razor rename to src/Admin/ThingsGateway.AdminServer/Layout/Routes.razor index c58a8a276..19b62bb1b 100644 --- a/src/ThingsGateway.Winform/Routes.razor +++ b/src/Admin/ThingsGateway.AdminServer/Layout/Routes.razor @@ -1,6 +1,8 @@ @using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web @using ThingsGateway.Admin.Application -@namespace ThingsGateway +@using ThingsGateway.Razor +@namespace ThingsGateway.AdminServer @{ #if NET6_0 @@ -19,13 +21,13 @@ @if (UserManager.UserId > 0) { - + } else { - + } @@ -35,13 +37,12 @@ - + - @@ -51,13 +52,13 @@ - + - + diff --git a/src/Admin/ThingsGateway.AdminServer/Locales/en-US.json b/src/Admin/ThingsGateway.AdminServer/Locales/en-US.json new file mode 100644 index 000000000..57c9e3dd6 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Locales/en-US.json @@ -0,0 +1,81 @@ +{ + "ThingsGateway.AdminServer.NotFound404": { + "404": "Sorry, the page you are looking for does not exist.", + "401": "Sorry, you do not have permission to access this page.", + "Home": "Back to Home", + "Login": "Login" + }, + "ThingsGateway.AdminServer.Login": { + "LoginErrorh1": "Login Error", + "LoginSuccessh1": "Login Success", + "LoginSuccessc1": "Redirecting to the page", + "LoginErrorh2": "Login Failed", + "LoginErrorc2": "Please contact the administrator!", + "Remark1": "Admin", + "Remark2": "", + "Remark3": "Permission Framework Based on BlazorServer", + "Welcome": "Welcome" + }, + + "ThingsGateway.AdminServer.MainLayout": { + "About": "About", + "FullScreenButton": "Full Screen", + "UserCenter": "User Center", + "ChoiceModule": "Switch Module", + + "LoginErrorh1": "Login Error", + "LoginSuccessh1": "Login Success", + "LoginSuccessc1": "Redirecting to the page", + "LoginErrorh2": "Login Failed", + "LoginErrorc2": "Please contact the administrator!", + "Logout": "Logout", + + "系统首页": "Home", + "权限管理": "Permission", + "用户管理": "User", + "角色管理": "Role", + "菜单管理": "Menu", + "机构管理": "Organization", + "职位管理": "Position", + "系统运维": "SystemOperation", + "系统配置": "System", + "字典管理": "Dictionary", + "操作日志": "Operation", + "会话管理": "Session", + "硬件信息": "HardwareInfo", + "网关管理": "GatewayManagement", + "插件管理": "PluginManagement", + "插件调试": "PluginDebugging", + "通道管理": "Channel", + "采集设备": "CollectionDevices", + "业务设备": "BusinessDevices", + "变量管理": "Variable", + "网关状态": "GatewayStatus", + "设备状态": "Device", + "实时数据": "RealTimeData", + "实时报警": "RealTimeAlarms", + "网关日志": "GatewayLogs", + "后台日志": "Backend", + "RPC日志": "RPC", + "物联网关": "Gateway", + "系统管理": "Admin" + }, + + "ThingsGateway.AdminServer.AdminIndex": { + "CollectDevice": "Collect Device", + "BusinessDevice": "Business Device", + "Variable": "Variable", + "Alarm": "Real-time Alarm", + "AlarmCount": "Alarm Count", + "OnLine": "Online", + "OffLine": "Offline", + "Shortcuts": "Shortcuts", + "OperLog": "Recent Operations", + "BackendLog": "Gateway Backend Log", + "RpcLog": "Gateway RPC Log", + "HardwareInfoChart": "Hardware Information Historical Chart", + "DateTime": "Date Time", + "Data": "Data", + "HistoryHardwareInfo": "Historical Chart" + } +} diff --git a/src/Admin/ThingsGateway.AdminServer/Locales/zh-CN.json b/src/Admin/ThingsGateway.AdminServer/Locales/zh-CN.json new file mode 100644 index 000000000..b3358bf93 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Locales/zh-CN.json @@ -0,0 +1,85 @@ +{ + + "ThingsGateway.AdminServer.NotFound404": { + "404": "抱歉,您访问的页面不存在。", + "401": "抱歉,您无权限访问该页面。", + "Home": "回到首页", + "Login": "重新登录" + }, + "ThingsGateway.AdminServer.Login": { + "LoginErrorh1": "登录异常", + "LoginSuccessh1": "登录成功", + "LoginSuccessc1": "即将跳转页面", + "LoginErrorh2": "登录失败", + "LoginErrorc2": "请联系管理员!", + "Remark1": "后台管理", + "Remark2": "", + "Remark3": "基于BlazorServer的权限框架", + "Welcome": "欢迎使用" + }, + + + "ThingsGateway.AdminServer.MainLayout": { + "About": "关于", + "FullScreenButton": "全屏", + "UserCenter": "个人中心", + "ChoiceModule": "切换模块", + + "LoginErrorh1": "登录异常", + "LoginSuccessh1": "登录成功", + "LoginSuccessc1": "即将跳转页面", + "LoginErrorh2": "登录失败", + "LoginErrorc2": "请联系管理员!", + "Logout": "注销", + + "系统首页": "系统首页", + "权限管理": "权限管理", + "用户管理": "用户管理", + "角色管理": "角色管理", + "菜单管理": "菜单管理", + "机构管理": "机构管理", + "职位管理": "职位管理", + "系统运维": "系统运维", + "系统配置": "系统配置", + "字典管理": "字典管理", + "操作日志": "操作日志", + "会话管理": "会话管理", + "硬件信息": "硬件信息", + "网关管理": "网关管理", + "插件管理": "插件管理", + "插件调试": "插件调试", + "通道管理": "通道管理", + "采集设备": "采集设备", + "业务设备": "业务设备", + "变量管理": "变量管理", + "网关状态": "网关状态", + "设备状态": "设备状态", + "实时数据": "实时数据", + "实时报警": "实时报警", + "网关日志": "网关日志", + "后台日志": "后台日志", + "RPC日志": "RPC日志", + "物联网关": "物联网关", + "系统管理": "系统管理" + }, + + + "ThingsGateway.AdminServer.AdminIndex": { + "CollectDevice": "采集设备", + "BusinessDevice": "业务设备", + "Variable": "变量", + "Alarm": "实时报警", + "AlarmCount": "报警数量", + "OnLine": "在线", + "OffLine": "离线", + "Shortcuts": "快捷方式", + "OperLog": "最近操作", + "BackendLog": "网关后台日志", + "RpcLog": "网关RPC日志", + "HardwareInfoChart": "硬件信息历史曲线", + "DateTime": "时间", + "Data": "数据", + "HistoryHardwareInfo": "历史曲线" + } + +} diff --git a/src/Admin/ThingsGateway.AdminServer/Pages/_Host.cshtml b/src/Admin/ThingsGateway.AdminServer/Pages/_Host.cshtml new file mode 100644 index 000000000..d2392c96c --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Pages/_Host.cshtml @@ -0,0 +1,56 @@ + +@page "_Host" +@using Microsoft.AspNetCore.Components.Web; +@using Microsoft.AspNetCore.Authorization; +@using BootstrapBlazor.Components +@using Microsoft.Extensions.Localization +@using ThingsGateway.Razor +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@namespace ThingsGateway.AdminServer + + + + + + + + + + + + + + + + + ThingsGateway + + + + + + @* *@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ThingsGateway.Winform/Program.cs b/src/Admin/ThingsGateway.AdminServer/Program/Program.cs similarity index 68% rename from src/ThingsGateway.Winform/Program.cs rename to src/Admin/ThingsGateway.AdminServer/Program/Program.cs index 230e9d874..ea7d7fdd4 100644 --- a/src/ThingsGateway.Winform/Program.cs +++ b/src/Admin/ThingsGateway.AdminServer/Program/Program.cs @@ -8,31 +8,24 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.ResponseCompression; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; using System.Runtime.InteropServices; using System.Text; -using System.Windows.Forms; using ThingsGateway.NewLife.Log; -namespace ThingsGateway.Winform; +namespace ThingsGateway.AdminServer; -internal sealed class Program +public class Program { - internal static void Closing(object? sender, FormClosingEventArgs e) - { - webApplication.StopAsync(); - } - [STAThread] - private static void Main(string[] args) + private static readonly string[] second = new[] { "application/octet-stream" }; + + public static async Task Main(string[] args) { //当前工作目录设为程序集的基目录 System.IO.Directory.SetCurrentDirectory(AppContext.BaseDirectory); + // 增加中文编码支持 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); @@ -60,14 +53,18 @@ internal sealed class Program #endregion 控制台输出Logo - var options = RunOptions.Default.ConfigureBuilder(builder => + await Serve.RunAsync(RunOptions.Default.ConfigureFirstActionBuilder(builder => { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) builder.Host.UseWindowsService(); else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) builder.Host.UseSystemd(); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + builder.Logging.ClearProviders(); //去除默认的事件日志提供者,某些情况下会日志输出异常,导致程序崩溃 + + }).ConfigureBuilder(builder => + { if (!builder.Environment.IsDevelopment()) { builder.Services.AddResponseCompression( @@ -91,34 +88,24 @@ internal sealed class Program }) - .Configure(app => - { + .Configure(app => + { +#if NET8_0_OR_GREATER + app.MapRazorComponents() + .AddAdditionalAssemblies(App.RazorAssemblies.Distinct().Where(a => a != typeof(Program).Assembly).ToArray()) + .AddInteractiveServerRenderMode(); +#else - }).Silence(true, true) - .ConfigureServices(services => - { - services.AddWindowsFormsBlazorWebView(); - }); - ; + app.MapBlazorHub(); + app.MapFallbackToPage("/_Host"); - Serve.BuildApplication(options, default, out var startUrls, out var app); - webApplication = app; - app.Start(); +#endif + + }) + ).ConfigureAwait(false); - AppDomain.CurrentDomain.UnhandledException += (sender, error) => - { - MessageBox.Show(text: error.ExceptionObject.ToString(), caption: "Error"); - }; - Application.SetHighDpiMode(HighDpiMode.SystemAware); - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - Application.Run(new MainForm(app.Services)); - Thread.Sleep(5000); } - - private static WebApplication webApplication; - internal static readonly string[] second = new[] { "application/octet-stream" }; } diff --git a/src/Admin/ThingsGateway.AdminServer/Program/SingleFilePublish.cs b/src/Admin/ThingsGateway.AdminServer/Program/SingleFilePublish.cs new file mode 100644 index 000000000..257516cde --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Program/SingleFilePublish.cs @@ -0,0 +1,47 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.AdminServer; + +/// +/// 解决单文件发布程序集扫描问题 +/// +public class SingleFilePublish : ISingleFilePublish +{ + /// + /// 解决单文件不能扫描的程序集 + /// + /// 可同时配置 + /// + public Assembly[] IncludeAssemblies() + { + return Array.Empty(); + } + + /// + /// 解决单文件不能扫描的程序集名称 + /// + /// 可同时配置 + /// + public string[] IncludeAssemblyNames() + { + return + [ + "ThingsGateway.Furion", + "ThingsGateway.NewLife.X", + "ThingsGateway.Razor", + "ThingsGateway.Admin.Razor" , + "ThingsGateway.Admin.Application" + ]; + } +} + diff --git a/src/Admin/ThingsGateway.AdminServer/Program/Startup.cs b/src/Admin/ThingsGateway.AdminServer/Program/Startup.cs new file mode 100644 index 000000000..dae15885b --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Program/Startup.cs @@ -0,0 +1,441 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +using Newtonsoft.Json; + +using System.Reflection; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Unicode; + +using ThingsGateway.Admin.Application; +using ThingsGateway.Admin.Razor; +using ThingsGateway.Extension; +using ThingsGateway.Logging; + +namespace ThingsGateway.AdminServer; + +[AppStartup(-99999)] +public class Startup : AppStartup +{ + public void ConfigBlazorServer(IServiceCollection services) + { + // 增加中文编码支持网页源码显示汉字 + services.AddSingleton(HtmlEncoder.Create(UnicodeRanges.All)); + + //并发启动/停止host + services.Configure(options => + { + options.ServicesStartConcurrently = true; + options.ServicesStopConcurrently = true; + }); + + // 事件总线 + services.AddEventBus(options => + { + + }); + + // 任务调度 + services.AddSchedule(options => + { + options.AddPersistence(); + }); + + + // 允许跨域 + services.AddCorsAccessor(); + + // + services.AddRazorPages(); + + // Json序列化设置 + static void SetNewtonsoftJsonSetting(JsonSerializerSettings setting) + { + setting.DateFormatHandling = DateFormatHandling.IsoDateFormat; + setting.DateTimeZoneHandling = DateTimeZoneHandling.Local; + // setting.Converters.AddDateTimeTypeConverters(localized: true); // 时间本地化 + //setting.DateFormatString = "yyyy-MM-dd HH:mm:ss"; // 时间格式化 + setting.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; // 忽略循环引用 + + // setting.ContractResolver = new CamelCasePropertyNamesContractResolver(); // 解决动态对象属性名大写 + setting.NullValueHandling = NullValueHandling.Ignore; // 忽略空值 + // setting.Converters.AddLongTypeConverters(); // long转string(防止js精度溢出) 超过17位开启 + // setting.MetadataPropertyHandling = MetadataPropertyHandling.Ignore; // 解决DateTimeOffset异常 + // setting.DateParseHandling = DateParseHandling.None; // 解决DateTimeOffset异常 + // setting.Converters.Add(new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }); // 解决DateTimeOffset异常 + }; + + services.AddControllers() + .AddNewtonsoftJson(options => SetNewtonsoftJsonSetting(options.SerializerSettings)) + //.AddXmlSerializerFormatters() + //.AddXmlDataContractSerializerFormatters() + .AddInjectWithUnifyResult(); + + +#if NET8_0_OR_GREATER + services + .AddRazorComponents(options => + { + options.TemporaryRedirectionUrlValidityDuration = TimeSpan.FromMinutes(10); + }) + .AddInteractiveServerComponents(options => + { + options.RootComponents.MaxJSRootComponents = 500; + options.JSInteropDefaultCallTimeout = TimeSpan.FromMinutes(2); + options.MaxBufferedUnacknowledgedRenderBatches = 20; + options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(10); + }) + .AddHubOptions(options => + { + //单个传入集线器消息的最大大小。默认 32 KB + options.MaximumReceiveMessageSize = 1024 * 1024; + //可为客户端上载流缓冲的最大项数。 如果达到此限制,则会阻止处理调用,直到服务器处理流项。 + options.StreamBufferCapacity = 30; + options.ClientTimeoutInterval = TimeSpan.FromMinutes(2); + options.KeepAliveInterval = TimeSpan.FromSeconds(15); + options.HandshakeTimeout = TimeSpan.FromSeconds(30); + }); + +#else + + services.AddServerSideBlazor(options => + { + options.RootComponents.MaxJSRootComponents = 500; + options.JSInteropDefaultCallTimeout = TimeSpan.FromMinutes(2); + options.MaxBufferedUnacknowledgedRenderBatches = 20; + options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(10); + }).AddHubOptions(options => + { + //单个传入集线器消息的最大大小。默认 32 KB + options.MaximumReceiveMessageSize = 1024 * 1024; + //可为客户端上载流缓冲的最大项数。 如果达到此限制,则会阻止处理调用,直到服务器处理流项。 + options.StreamBufferCapacity = 30; + options.ClientTimeoutInterval = TimeSpan.FromMinutes(2); + options.KeepAliveInterval = TimeSpan.FromSeconds(15); + options.HandshakeTimeout = TimeSpan.FromSeconds(30); + }); + +#endif + + // 配置Nginx转发获取客户端真实IP + // 注1:如果负载均衡不是在本机通过 Loopback 地址转发请求的,一定要加上options.KnownNetworks.Clear()和options.KnownProxies.Clear() + // 注2:如果设置环境变量 ASPNETCORE_FORWARDEDHEADERS_ENABLED 为 True,则不需要下面的配置代码 + services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.All; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); + }); + + + services.AddHealthChecks(); + + + #region 控制台美化 + + services.AddConsoleFormatter(options => + { + options.WriteFilter = (logMsg) => + { + return true; + ////如果不是LoggingMonitor日志才格式化 + //if (logMsg.LogName != "System.Logging.LoggingMonitor") + //{ + // return true; + //} + //else + //{ + // return false; + //} + }; + + options.MessageFormat = (logMsg) => + { + //如果不是LoggingMonitor日志才格式化 + if (logMsg.LogName != "System.Logging.LoggingMonitor") + { + var stringBuilder = new StringBuilder(); + stringBuilder.AppendLine("【日志级别】:" + logMsg.LogLevel); + stringBuilder.AppendLine("【日志类名】:" + logMsg.LogName); + stringBuilder.AppendLine("【日志时间】:" + DateTime.Now.ToDefaultDateTimeFormat()); + stringBuilder.AppendLine("【日志内容】:" + logMsg.Message); + if (logMsg.Exception != null) + { + stringBuilder.AppendLine("【异常信息】:" + logMsg.Exception); + } + return stringBuilder.ToString(); + } + else + { + return logMsg.Message; + } + }; + options.WriteHandler = (logMsg, scopeProvider, writer, fmtMsg, opt) => + { + ConsoleColor consoleColor = ConsoleColor.White; + switch (logMsg.LogLevel) + { + case LogLevel.Information: + consoleColor = ConsoleColor.DarkGreen; + break; + + case LogLevel.Warning: + consoleColor = ConsoleColor.DarkYellow; + break; + + case LogLevel.Error: + consoleColor = ConsoleColor.DarkRed; + break; + } + writer.WriteWithColor(fmtMsg, ConsoleColor.Black, consoleColor); + }; + }); + + #endregion 控制台美化 + + #region api日志 + + //Monitor日志配置 + services.AddMonitorLogging(options => + { + options.JsonIndented = true;// 是否美化 JSON + options.GlobalEnabled = false;//全局启用 + options.ConfigureLogger((logger, logContext, context) => + { + var httpContext = context.HttpContext;//获取httpContext + + //获取头 + var userAgent = httpContext.Request.Headers["User-Agent"]; + if (string.IsNullOrEmpty(userAgent)) userAgent = "Other";//如果没有这个头就指定一个 + + //获取客户端信息 + var client = App.GetService().ClientInfo; + // 获取控制器/操作描述器 + var controllerActionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; + //操作名称默认是控制器名加方法名,自定义操作名称要在action上加Description特性 + var option = $"{controllerActionDescriptor.ControllerName}/{controllerActionDescriptor.ActionName}"; + + var desc = App.CreateLocalizerByType(controllerActionDescriptor.ControllerTypeInfo.AsType())[controllerActionDescriptor.MethodInfo.Name]; + //获取特性 + option = desc.Value;//则将操作名称赋值为控制器上写的title + + logContext.Set(LoggingConst.CateGory, option);//传操作名称 + logContext.Set(LoggingConst.Operation, option);//传操作名称 + logContext.Set(LoggingConst.Client, client);//客户端信息 + logContext.Set(LoggingConst.Path, httpContext.Request.Path.Value);//请求地址 + logContext.Set(LoggingConst.Method, httpContext.Request.Method);//请求方法 + }); + }); + + //日志写入数据库配置 + services.AddDatabaseLogging(options => + { + options.WriteFilter = (logMsg) => + { + return logMsg.LogName == "System.Logging.LoggingMonitor";//只写入LoggingMonitor日志 + }; + }); + + #endregion api日志 + + //已添加AddOptions + // 增加多语言支持配置信息 + services.AddRequestLocalization>((localizerOption, blazorOption) => + { + blazorOption.OnChange(op => Invoke(op)); + Invoke(blazorOption.CurrentValue); + + void Invoke(BootstrapBlazor.Components.BootstrapBlazorOptions option) + { + var supportedCultures = option.GetSupportedCultures(); + localizerOption.SupportedCultures = supportedCultures; + localizerOption.SupportedUICultures = supportedCultures; + } + }); + + services.AddScoped(a => + { + var appContext = new BlazorAppContext( + a.GetService(), + a.GetService(), + a.GetService()); + appContext.TitleLocalizer = a.GetRequiredService>(); + + return appContext; + }); + + services.AddHttpContextAccessor(); + + //添加cookie授权 + var authenticationBuilder = services.AddAuthentication(Assembly.GetEntryAssembly().GetName().Name).AddCookie(Assembly.GetEntryAssembly().GetName().Name, a => + { + a.AccessDeniedPath = "/Account/AccessDenied/"; + a.LogoutPath = "/Account/Logout/"; + a.LoginPath = "/Account/Login/"; + }); + + // 添加jwt授权 + authenticationBuilder.AddJwt(); + + services.AddAuthorization(); +#if NET8_0_OR_GREATER + services.AddCascadingAuthenticationState(); +#endif + services.AddScoped(); + + } + + + + public void Use(IApplicationBuilder applicationBuilder, IWebHostEnvironment env) + { + var app = (WebApplication)applicationBuilder; + app.UseBootstrapBlazor(); + + app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.All }); + + // 启用本地化 + var option = app.Services.GetService>(); + if (option != null) + { + app.UseRequestLocalization(option.Value); + } + + // 任务调度看板 + app.UseScheduleUI(options => + { + options.RequestPath = "/schedule"; // 必须以 / 开头且不以 / 结尾 + options.DisableOnProduction = true; // 生产环境关闭 + options.DisplayEmptyTriggerJobs = true; // 是否显示空作业触发器的作业 + options.DisplayHead = false; // 是否显示页头 + options.DefaultExpandAllJobs = false; // 是否默认展开所有作业 + }); + + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + app.UseExceptionHandler("/Error"); + app.UseResponseCompression(); + app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => ctx.ProcessCache(app.Configuration) }); + } + + + app.UseStaticFiles(new StaticFileOptions + { + OnPrepareResponse = (stf) => + { + stf.ProcessCache(app.Configuration); + stf.Context.Response.Headers.AccessControlAllowOrigin = "*"; + stf.Context.Response.Headers.AccessControlAllowHeaders = "*"; + } + }); + var provider = new FileExtensionContentTypeProvider(); + provider.Mappings[".properties"] = "application/octet-stream"; + provider.Mappings[".moc"] = "application/x-msdownload"; + provider.Mappings[".moc3"] = "application/x-msdownload"; + provider.Mappings[".mtn"] = "application/x-msdownload"; + + app.UseStaticFiles(new StaticFileOptions { ContentTypeProvider = provider }); + app.UseStaticFiles(); + + app.Use(async (context, next) => + { + context.Response.Headers.Append("ThingsGateway", "ThingsGateway"); + await next().ConfigureAwait(false); + }); + + + // 特定文件类型(文件后缀)处理 + var contentTypeProvider = GetFileExtensionContentTypeProvider(); + // contentTypeProvider.Mappings[".文件后缀"] = "MIME 类型"; + app.UseStaticFiles(new StaticFileOptions + { + ContentTypeProvider = contentTypeProvider + }); + + //// 启用HTTPS + //app.UseHttpsRedirection(); + + + // 添加状态码拦截中间件 + app.UseUnifyResultStatusCodes(); + + // 路由注册 + app.UseRouting(); + + // 启用跨域,必须在 UseRouting 和 UseAuthentication 之间注册 + app.UseCorsAccessor(); + + // 启用鉴权授权 + app.UseAuthentication(); + app.UseAuthorization(); + + // 任务调度看板 + app.UseScheduleUI(options => + { + options.RequestPath = "/schedule"; // 必须以 / 开头且不以 / 结尾 + options.DisableOnProduction = true; // 生产环境关闭 + options.DisplayEmptyTriggerJobs = true; // 是否显示空作业触发器的作业 + options.DisplayHead = false; // 是否显示页头 + options.DefaultExpandAllJobs = false; // 是否默认展开所有作业 + }); + + app.UseInject(); + +#if NET8_0_OR_GREATER + app.UseAntiforgery(); +#endif + + app.MapControllers(); + + + + } + + /// + /// 初始化文件 ContentType 提供器 + /// + /// + private static FileExtensionContentTypeProvider GetFileExtensionContentTypeProvider() + { + var fileExtensionProvider = new FileExtensionContentTypeProvider(); + fileExtensionProvider.Mappings[".iec"] = "application/octet-stream"; + fileExtensionProvider.Mappings[".patch"] = "application/octet-stream"; + fileExtensionProvider.Mappings[".apk"] = "application/vnd.android.package-archive"; + fileExtensionProvider.Mappings[".pem"] = "application/x-x509-user-cert"; + fileExtensionProvider.Mappings[".gzip"] = "application/x-gzip"; + fileExtensionProvider.Mappings[".7zip"] = "application/zip"; + fileExtensionProvider.Mappings[".jpg2"] = "image/jp2"; + fileExtensionProvider.Mappings[".et"] = "application/kset"; + fileExtensionProvider.Mappings[".dps"] = "application/ksdps"; + fileExtensionProvider.Mappings[".cdr"] = "application/x-coreldraw"; + fileExtensionProvider.Mappings[".shtml"] = "text/html"; + fileExtensionProvider.Mappings[".php"] = "application/x-httpd-php"; + fileExtensionProvider.Mappings[".php3"] = "application/x-httpd-php"; + fileExtensionProvider.Mappings[".php4"] = "application/x-httpd-php"; + fileExtensionProvider.Mappings[".phtml"] = "application/x-httpd-php"; + fileExtensionProvider.Mappings[".pcd"] = "image/x-photo-cd"; + fileExtensionProvider.Mappings[".bcmap"] = "application/octet-stream"; + fileExtensionProvider.Mappings[".properties"] = "application/octet-stream"; + fileExtensionProvider.Mappings[".m3u8"] = "application/x-mpegURL"; + return fileExtensionProvider; + } +} diff --git a/src/Admin/ThingsGateway.AdminServer/Properties/launchSettings.json b/src/Admin/ThingsGateway.AdminServer/Properties/launchSettings.json new file mode 100644 index 000000000..95006a41c --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5000", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Admin/ThingsGateway.AdminServer/ThingsGateway.AdminServer.csproj b/src/Admin/ThingsGateway.AdminServer/ThingsGateway.AdminServer.csproj new file mode 100644 index 000000000..b793bcef7 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/ThingsGateway.AdminServer.csproj @@ -0,0 +1,76 @@ + + + + + + + net9.0;net8.0; + + + + false + zh-Hans;en-US + true + wwwroot\favicon.ico + + + 1 + + + + + + + + + + + + + + + + + Never + + + + + + Always + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/src/Admin/ThingsGateway.AdminServer/WindowsServiceCreate.bat b/src/Admin/ThingsGateway.AdminServer/WindowsServiceCreate.bat new file mode 100644 index 000000000..7e0066125 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/WindowsServiceCreate.bat @@ -0,0 +1,5 @@ +cd .. +sc create ThingsGateway binPath= %~dp0ThingsGateway.AdminServer.exe start= auto +sc description ThingsGateway "ThingsGateway" +Net Start ThingsGateway +pause diff --git a/src/Admin/ThingsGateway.AdminServer/WindowsServiceDelete.bat b/src/Admin/ThingsGateway.AdminServer/WindowsServiceDelete.bat new file mode 100644 index 000000000..ed03478f7 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/WindowsServiceDelete.bat @@ -0,0 +1,3 @@ +net stop ThingsGateway +sc delete ThingsGateway +pause diff --git a/src/Admin/ThingsGateway.AdminServer/appsettings.Development.json b/src/Admin/ThingsGateway.AdminServer/appsettings.Development.json new file mode 100644 index 000000000..9ad7c33c6 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "urls": "http://*:5000", + + "ConfigurationScanDirectories": [ "Configuration", "" ], // 扫描配置文件json文件夹(自动合并该文件夹里面所有json文件) + "IgnoreConfigurationFiles": [ "" ], + "ExternalAssemblies": [ "" ] + +} diff --git a/src/Admin/ThingsGateway.AdminServer/appsettings.json b/src/Admin/ThingsGateway.AdminServer/appsettings.json new file mode 100644 index 000000000..9ad7c33c6 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/appsettings.json @@ -0,0 +1,8 @@ +{ + "urls": "http://*:5000", + + "ConfigurationScanDirectories": [ "Configuration", "" ], // 扫描配置文件json文件夹(自动合并该文件夹里面所有json文件) + "IgnoreConfigurationFiles": [ "" ], + "ExternalAssemblies": [ "" ] + +} diff --git a/src/Admin/ThingsGateway.AdminServer/pm2-linux.json b/src/Admin/ThingsGateway.AdminServer/pm2-linux.json new file mode 100644 index 000000000..d735b207f --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/pm2-linux.json @@ -0,0 +1,22 @@ +{ + "apps": { + "name": "ThingsGateway", + "script": "dotnet", + "exec_mode": "fork", + "error_file": "logs/pm2err.log", + "out_file": "logs/pm2out.log", + "merge_logs": true, + "log_date_format": "YYYY-MM-DD HH:mm:ss", + "min_uptime": "60s", + "max_restarts": 30, + "autorestart": true, + "restart_delay": "60", + "args": [ + "ThingsGateway.AdminServer.dll", + " --urls=http://*:5000" + ], + "env": { + "ASPNETCORE_ENVIRONMENT": "Production" + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.AdminServer/pm2-windows.json b/src/Admin/ThingsGateway.AdminServer/pm2-windows.json new file mode 100644 index 000000000..d735b207f --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/pm2-windows.json @@ -0,0 +1,22 @@ +{ + "apps": { + "name": "ThingsGateway", + "script": "dotnet", + "exec_mode": "fork", + "error_file": "logs/pm2err.log", + "out_file": "logs/pm2out.log", + "merge_logs": true, + "log_date_format": "YYYY-MM-DD HH:mm:ss", + "min_uptime": "60s", + "max_restarts": 30, + "autorestart": true, + "restart_delay": "60", + "args": [ + "ThingsGateway.AdminServer.dll", + " --urls=http://*:5000" + ], + "env": { + "ASPNETCORE_ENVIRONMENT": "Production" + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.AdminServer/thingsgateway.service b/src/Admin/ThingsGateway.AdminServer/thingsgateway.service new file mode 100644 index 000000000..27b9aaf29 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/thingsgateway.service @@ -0,0 +1,39 @@ +# Unit 文件描述 +[Unit] +Description=ThingsGateway + +# Service 配置参数 +[Service] +#Type=notify +#KillSignal=SIGINT +#KillMode=mixed +# 自启动项目所在的位置路径 +WorkingDirectory=/iot/ThingsGateway + +# 自启动项目的命令 +ExecStart=/usr/share/dotnet/dotnet /iot/ThingsGateway/ThingsGateway.AdminServer.dll --urls=http://*:5000 +Restart=always +RestartSec=10 +# User=iot +TimeoutStopSec=90 +SyslogIdentifier=ThingsGateway + +# Development 开发环境,Production 生产环境 +Environment=ASPNETCORE_ENVIRONMENT=Production +Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false + +[Install] +WantedBy=multi-user.target + + +# 加载服务配置文件 +# systemctl daemon-reload +# 将服务设置为开机启动 +# systemctl enable thingsgateway.service +# 启动服务 +# systemctl start thingsgateway.service +# 查看服务状态 +# systemctl status thingsgateway.service + +# 查看日志 +# sudo journalctl -fu thingsgateway.service diff --git a/src/ThingsGateway.Winform/favicon.ico b/src/Admin/ThingsGateway.AdminServer/wwwroot/favicon.ico similarity index 100% rename from src/ThingsGateway.Winform/favicon.ico rename to src/Admin/ThingsGateway.AdminServer/wwwroot/favicon.ico diff --git a/src/Admin/ThingsGateway.AdminServer/wwwroot/favicon.png b/src/Admin/ThingsGateway.AdminServer/wwwroot/favicon.png new file mode 100644 index 000000000..e88c7d32f Binary files /dev/null and b/src/Admin/ThingsGateway.AdminServer/wwwroot/favicon.png differ diff --git a/src/Admin/ThingsGateway.AdminServer/wwwroot/manifest.json b/src/Admin/ThingsGateway.AdminServer/wwwroot/manifest.json new file mode 100644 index 000000000..17a3f00a4 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/wwwroot/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "ThingsGateway", + "short_name": "ThingsGateway", + "start_url": "./", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#03173d", + "prefer_related_applications": false, + "icons": [ + { + "src": "favicon.png", + "type": "image/png", + "sizes": "256x256" + }, + { + "src": "favicon.ico", + "type": "image/ico", + "sizes": "128x128" + } + ] +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.AdminServer/wwwroot/service-worker.js b/src/Admin/ThingsGateway.AdminServer/wwwroot/service-worker.js new file mode 100644 index 000000000..1ed99c225 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/wwwroot/service-worker.js @@ -0,0 +1,4 @@ +// In development, always fetch from the network and do not enable offline support. +// This is because caching would make development more difficult (changes would not +// be reflected on the first load after each change). +self.addEventListener('fetch', () => { }); \ No newline at end of file diff --git a/src/Admin/ThingsGateway.AdminServer/wwwroot/service-worker.published.js b/src/Admin/ThingsGateway.AdminServer/wwwroot/service-worker.published.js new file mode 100644 index 000000000..db062b306 --- /dev/null +++ b/src/Admin/ThingsGateway.AdminServer/wwwroot/service-worker.published.js @@ -0,0 +1,48 @@ +// Caution! Be sure you understand the caveats before publishing an application with +// offline support. See https://aka.ms/blazor-offline-considerations + +self.importScripts('./service-worker-assets.js'); +self.addEventListener('install', event => event.waitUntil(onInstall(event))); +self.addEventListener('activate', event => event.waitUntil(onActivate(event))); +self.addEventListener('fetch', event => event.respondWith(onFetch(event))); + +const cacheNamePrefix = 'offline-cache-'; +const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; +const offlineAssetsInclude = [/\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/]; +const offlineAssetsExclude = [/^service-worker\.js$/]; + +async function onInstall(event) { + console.info('Service worker: Install'); + + // Fetch and cache all matching items from the assets manifest + const assetsRequests = self.assetsManifest.assets + .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url))) + .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url))) + .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' })); + await caches.open(cacheName).then(cache => cache.addAll(assetsRequests)); +} + +async function onActivate(event) { + console.info('Service worker: Activate'); + + // Delete unused caches + const cacheKeys = await caches.keys(); + await Promise.all(cacheKeys + .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName) + .map(key => caches.delete(key))); +} + +async function onFetch(event) { + let cachedResponse = null; + if (event.request.method === 'GET') { + // For all navigation requests, try to serve index.html from cache + // If you need some URLs to be server-rendered, edit the following check to exclude those URLs + const shouldServeIndexHtml = event.request.mode === 'navigate'; + + const request = shouldServeIndexHtml ? 'index.html' : event.request; + const cache = await caches.open(cacheName); + cachedResponse = await cache.match(request); + } + + return cachedResponse || fetch(event.request); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/App.cs b/src/Admin/ThingsGateway.Furion/App/App.cs new file mode 100644 index 000000000..a930e7bc7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/App.cs @@ -0,0 +1,605 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.CommandLine; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyModel; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +using StackExchange.Profiling; + +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Security.Claims; + +using ThingsGateway.ConfigurableOptions; +using ThingsGateway.NewLife.Caching; +using ThingsGateway.NewLife.Collections; +using ThingsGateway.NewLife.Log; +using ThingsGateway.Reflection; +using ThingsGateway.Templates; + +namespace ThingsGateway; + +/// +/// 全局应用类 +/// +[SuppressSniffer] +public static class App +{ + /// + /// 私有设置,避免重复解析 + /// + internal static AppSettingsOptions _settings; + + /// + /// 应用全局配置 + /// + public static AppSettingsOptions Settings => _settings ??= GetConfig("AppSettings", true); + + /// + /// 全局配置选项 + /// + public static IConfiguration Configuration => CatchOrDefault(() => InternalApp.Configuration.Reload(), new ConfigurationBuilder().Build()); + + /// + /// 获取Web主机环境,如,是否是开发环境,生产环境等 + /// + public static IWebHostEnvironment WebHostEnvironment => InternalApp.WebHostEnvironment; + + /// + /// 获取泛型主机环境,如,是否是开发环境,生产环境等 + /// + public static IHostEnvironment HostEnvironment => InternalApp.HostEnvironment; + + /// + /// 存储根服务,可能为空 + /// + public static IServiceProvider RootServices => InternalApp.RootServices; + + private static IStringLocalizerFactory? stringLocalizerFactory; + + /// + /// 本地化服务工厂 + /// + public static IStringLocalizerFactory? StringLocalizerFactory + + { + get + { + if ((stringLocalizerFactory == null)) + { + stringLocalizerFactory = RootServices?.GetService(); + } + return stringLocalizerFactory; + } + } + + [NotNull] + private static ICache? cacheService; + + /// + /// 当前缓存服务 + /// + public static ICache? CacheService + + { + get + { + if ((cacheService == null)) + { + cacheService = App.GetService(); + } + return cacheService; + } + } + + /// + /// 根据类型创建本地化服务 + /// + public static IStringLocalizer? CreateLocalizerByType(Type resourceSource) + { + return resourceSource.Assembly.IsDynamic ? null : StringLocalizerFactory?.Create(resourceSource); + } + + /// + /// 根据名称创建本地化服务 + /// + public static IStringLocalizer? CreateLocalizerByName(string baseName, string location) + { + return StringLocalizerFactory?.Create(baseName, location); + } + + + /// + /// 判断是否是单文件环境 + /// + public static bool SingleFileEnvironment => string.IsNullOrWhiteSpace(Assembly.GetEntryAssembly().Location); + + /// + /// 应用有效程序集 + /// + public static readonly List Assemblies; + + /// + /// 有效程序集类型 + /// + public static readonly List EffectiveTypes; + + /// + /// 获取请求上下文 + /// + public static HttpContext HttpContext => CatchOrDefault(() => +{ + var httpContextAccessor = RootServices?.GetService(); + try + { + return httpContextAccessor.HttpContext; + } + catch + { + return null; + } +}); + + /// + /// 获取请求上下文用户 + /// + /// 只有授权访问的页面或接口才存在值,否则为 null + public static ClaimsPrincipal User => HttpContext?.User; + + + /// + /// 获取配置 + /// + /// 强类型选项类 + /// 配置中对应的Key + /// + /// TOptions + public static TOptions GetConfig(string path, bool loadPostConfigure = false) + { + var options = Configuration.GetSection(path).Get(); + + // 加载默认选项配置 + if (loadPostConfigure && typeof(IConfigurableOptions).IsAssignableFrom(typeof(TOptions))) + { + var postConfigure = typeof(TOptions).GetMethod("PostConfigure"); + if (postConfigure != null) + { + options ??= Activator.CreateInstance(); + postConfigure.Invoke(options, new object[] { options, Configuration }); + } + } + + return options; + } + + /// + /// 获取选项 + /// + /// 强类型选项类 + /// + /// TOptions + public static TOptions GetOptions(IServiceProvider serviceProvider = default) + where TOptions : class, new() + { + return Penetrates.GetOptionsOnStarting() + ?? GetService>(serviceProvider ?? RootServices)?.Value; + } + + /// + /// 获取请求生存周期的服务 + /// + /// + /// + /// + public static object GetService(Type type, IServiceProvider serviceProvider = default) + { + return serviceProvider == null ? RootServices.GetService(type) : serviceProvider.GetService(type); + } + + /// + /// 获取请求生存周期的服务 + /// + /// + /// + /// + public static TService GetService(IServiceProvider serviceProvider = default) + where TService : class + { + return GetService(typeof(TService), serviceProvider) as TService; + } + /// + /// 获取选项 + /// + /// 强类型选项类 + /// + /// TOptions + public static TOptions GetOptionsMonitor(IServiceProvider serviceProvider = default) + where TOptions : class, new() + { + return Penetrates.GetOptionsOnStarting() + ?? GetService>(serviceProvider ?? RootServices)?.CurrentValue; + } + + /// + /// 获取选项 + /// + /// 强类型选项类 + /// + /// TOptions + public static TOptions GetOptionsSnapshot(IServiceProvider serviceProvider = default) + where TOptions : class, new() + { + // 这里不能从根服务解析,因为是 Scoped 作用域 + return Penetrates.GetOptionsOnStarting() + ?? GetService>(serviceProvider)?.Value; + } + + /// + /// 获取命令行配置 + /// + /// + /// + /// + public static CommandLineConfigurationProvider GetCommandLineConfiguration(string[] args, IDictionary switchMappings = null) + { + var commandLineConfiguration = new CommandLineConfigurationProvider(args, switchMappings); + commandLineConfiguration.Load(); + + return commandLineConfiguration; + } + + /// + /// 获取当前线程 Id + /// + /// + public static int GetThreadId() + { + return Environment.CurrentManagedThreadId; + } + + /// + /// 获取当前请求 TraceId + /// + /// + public static string GetTraceId() + { + return Activity.Current?.Id ?? (InternalApp.RootServices == null ? default : HttpContext?.TraceIdentifier); + } + + /// + /// 获取一段代码执行耗时 + /// + /// 委托 + /// + public static long GetExecutionTime(Action action) + { + // 空检查 + if (action == null) throw new ArgumentNullException(nameof(action)); + + // 计算接口执行时间 + var timeOperation = Stopwatch.StartNew(); + action(); + timeOperation.Stop(); + return timeOperation.ElapsedMilliseconds; + } + + /// + /// 获取服务注册的生命周期类型 + /// + /// + /// + public static ServiceLifetime? GetServiceLifetime(Type serviceType) + { + var serviceDescriptor = InternalApp.InternalServices + .FirstOrDefault(u => u.ServiceType == (serviceType.IsGenericType ? serviceType.GetGenericTypeDefinition() : serviceType)); + + return serviceDescriptor?.Lifetime; + } + + + /// + /// 打印验证信息到 MiniProfiler + /// + /// 分类 + /// 状态 + /// 消息 + /// 是否为警告消息 + public static void PrintToMiniProfiler(string category, string state, string message = null, bool isError = false) + { + if (!CanBeMiniProfiler()) return; + + // 打印消息 + var titleCaseCategory = Thread.CurrentThread.CurrentCulture.TextInfo.ToTitleCase(category); + var customTiming = MiniProfiler.Current?.CustomTiming(category, string.IsNullOrWhiteSpace(message) ? $"{titleCaseCategory} {state}" : message, state); + if (customTiming == null) return; + + // 判断是否是警告消息 + if (isError) customTiming.Errored = true; + } + + /// + /// 构造函数 + /// + static App() + { + + // 加载程序集 + var assObject = GetAssemblies(); + Assemblies = assObject.Assemblies.ToList(); + ExternalAssemblies = assObject.ExternalAssemblies; + PathOfExternalAssemblies = assObject.PathOfExternalAssemblies; + + // 获取有效的类型集合 + EffectiveTypes = Assemblies.SelectMany(GetTypes).ToList(); + RazorAssemblies = EffectiveTypes.Where(u => !u.IsInterface && !u.IsAbstract && u.IsClass +&& u.IsDefined(typeof(Microsoft.AspNetCore.Components.RouteAttribute), true)).Select(a => a.Assembly).Distinct().ToList(); + AppStartups = new ConcurrentBag(); + } + + /// + /// 应用所有启动配置对象 + /// + internal static ConcurrentBag AppStartups; + + /// + /// 外部程序集 + /// + internal static IEnumerable ExternalAssemblies; + + /// + /// 外部程序集文件路径 + /// + internal static IEnumerable PathOfExternalAssemblies; + + /// + /// 直接引用程序集中的Route Razor类,不支持单文件 + /// + public static IEnumerable RazorAssemblies { get; private set; } + + public static readonly ConcurrentHashSet BakImagePaths = new(); + public static readonly ConcurrentHashSet BakImageNames = new(); + + /// + /// 获取应用有效程序集 + /// + /// IEnumerable + private static (IEnumerable Assemblies, IEnumerable ExternalAssemblies, IEnumerable PathOfExternalAssemblies) GetAssemblies() + { + // 需排除的程序集后缀 + var excludeAssemblyNames = new string[] { + "Database.Migrations", + "ThingsGateway.NewLife.X" + }; + + // 读取应用配置 + var supportPackageNamePrefixs = Settings.SupportPackageNamePrefixs ?? Array.Empty(); + + IEnumerable scanAssemblies; + + // 获取入口程序集 + var entryAssembly = Assembly.GetEntryAssembly(); + + // 非独立发布/非单文件发布 + if (!string.IsNullOrWhiteSpace(entryAssembly.Location)) + { + var dependencyContext = DependencyContext.Default; + + // 读取项目程序集或 内部发布的包,或手动添加引用的dll,或配置特定的包前缀 + scanAssemblies = dependencyContext.RuntimeLibraries + .Where(u => + (u.Type == "project" && !excludeAssemblyNames.Any(j => u.Name.EndsWith(j))) || + (u.Type == "package" && !excludeAssemblyNames.Any(j => u.Name.EndsWith(j)) && ( + //(u.Name.StartsWith(nameof(ThingsGateway)) && !u.Name.Contains("Plugin")) || + supportPackageNamePrefixs.Any(p => u.Name.StartsWith(p) && u.RuntimeAssemblyGroups.Count > 0))) || + (Settings.EnabledReferenceAssemblyScan == true && u.Type == "reference")) // 判断是否启用引用程序集扫描 + .Select(u => Reflect.GetAssembly(u.Name)).Where(a => a != null); + } + // 独立发布/单文件发布 + else + { + IEnumerable fixedSingleFileAssemblies = new[] { entryAssembly }; + + // 扫描实现 ISingleFilePublish 接口的类型 + var singleFilePublishType = entryAssembly.GetTypes() + .FirstOrDefault(u => u.IsClass && !u.IsInterface && !u.IsAbstract && typeof(ISingleFilePublish).IsAssignableFrom(u)); + if (singleFilePublishType != null) + { + var singleFilePublish = Activator.CreateInstance(singleFilePublishType) as ISingleFilePublish; + + // 加载用户自定义配置单文件所需程序集 + var nativeAssemblies = singleFilePublish.IncludeAssemblies(); + var loadAssemblies = singleFilePublish.IncludeAssemblyNames() + .Select(u => Reflect.GetAssembly(u)).Where(a => a != null); + + fixedSingleFileAssemblies = fixedSingleFileAssemblies.Concat(nativeAssemblies) + .Concat(loadAssemblies); + + + } + else + { + Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.Red; + // 提示没有正确配置单文件配置 + Console.WriteLine(TP.Wrapper("Deploy Console" + , "Single file deploy error." + , "##Exception## Single file deployment configuration error." + , "##Documentation## https://furion.net/docs/singlefile")); + Console.ResetColor(); + } + + // 通过 AppDomain.CurrentDomain 扫描,默认为延迟加载,正常只能扫描到 此程序集 和 入口程序集(启动层) + scanAssemblies = AppDomain.CurrentDomain.GetAssemblies() + .Where(ass => + // 排除 System,Microsoft,netstandard 开头的程序集 + !ass.FullName.StartsWith(nameof(System)) + && !ass.FullName.StartsWith(nameof(Microsoft)) + && !ass.FullName.StartsWith("netstandard")) + .Concat(fixedSingleFileAssemblies) + .Distinct(); + } + + IEnumerable externalAssemblies = Array.Empty(); + IEnumerable pathOfExternalAssemblies = Array.Empty(); + + // 加载 appsettings.json 配置的外部程序集 + if (Settings.ExternalAssemblies != null && Settings.ExternalAssemblies.Length > 0) + { + var externalDlls = new List(); + foreach (var item in Settings.ExternalAssemblies) + { + if (string.IsNullOrWhiteSpace(item)) continue; + + var path = Path.Combine(AppContext.BaseDirectory, item); + + // 若以 .dll 结尾则认为是一个文件 + if (item.EndsWith(".dll")) + { + if (File.Exists(path)) externalDlls.Add(path); + } + // 否则作为目录查找或拼接 .dll 后缀作为文件名查找 + else + { + // 作为目录查找所有 .dll 文件 + if (Directory.Exists(path)) + { + externalDlls.AddRange(Directory.EnumerateFiles(path, "*.dll", SearchOption.AllDirectories)); + } + // 拼接 .dll 后缀查找 + else + { + var pathDll = path + ".dll"; + if (File.Exists(pathDll)) externalDlls.Add(pathDll); + } + } + } + + // 加载外部程序集 + foreach (var assemblyFileFullPath in externalDlls) + { + try + { + if (BakImagePaths.Contains(assemblyFileFullPath)) continue; + // 根据路径加载程序集 + //var loadedAssembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyFileFullPath); + var runtimeAssembliesPath = Path.GetDirectoryName(typeof(object).Assembly.Location); + // 将目标程序集和运行时核心程序集一起提供给 PathAssemblyResolver + var assemblies = Directory.GetFiles(runtimeAssembliesPath, "*.dll"); + var resolver = new PathAssemblyResolver(new[] { assemblyFileFullPath }.Concat(assemblies)); + // 使用 MetadataLoadContext + using var metadataContext = new MetadataLoadContext(resolver); + + var referencedAssemblies = metadataContext.LoadFromAssemblyPath(assemblyFileFullPath)?.GetReferencedAssemblies(); + if ((referencedAssemblies?.Any(a => a.Name.StartsWith("ThingsGateway"))) != true) + { + continue; + } + var loadedAssembly = Reflect.LoadAssembly(assemblyFileFullPath); + if (loadedAssembly == default) continue; + + var loadTypes = GetTypes(loadedAssembly); + if (!loadTypes.Any()) + { + + BakImagePaths.TryAdd(assemblyFileFullPath); + BakImageNames.TryAdd(loadedAssembly.GetName().Name); + continue; + } + var assembly = new[] { loadedAssembly }; + + if (scanAssemblies.Any(u => u == loadedAssembly)) continue; + + // 合并程序集 + scanAssemblies = scanAssemblies.Concat(assembly); + externalAssemblies = externalAssemblies.Concat(assembly); + pathOfExternalAssemblies = pathOfExternalAssemblies.Concat(new[] { assemblyFileFullPath }); + } + catch (Exception ex) + { + BakImagePaths.TryAdd(assemblyFileFullPath); + XTrace.Log.Warn("Load external assembly error: {0} {1} {2}", assemblyFileFullPath, Environment.NewLine, ex); + Console.WriteLine("Load external assembly error: {0} {1} {2}", assemblyFileFullPath, Environment.NewLine, ex); + } + } + } + + // 处理排除的程序集 + if (Settings.ExcludeAssemblies != null && Settings.ExcludeAssemblies.Length > 0) + { + scanAssemblies = scanAssemblies.Where(ass => !Settings.ExcludeAssemblies.Contains(ass.GetName().Name, StringComparer.OrdinalIgnoreCase)); + } + + return (scanAssemblies.Distinct(), externalAssemblies.Distinct(), pathOfExternalAssemblies.Distinct()); + } + + /// + /// 加载程序集中的所有类型 + /// + /// + /// + private static IEnumerable GetTypes(Assembly ass) + { + var types = Array.Empty(); + + try + { + types = ass.GetTypes(); + } + catch + { + XTrace.Log.Warn($"Error load `{ass.FullName}` assembly."); + Console.WriteLine($"Error load `{ass.FullName}` assembly."); + } + + return types.Where(u => u.IsPublic && !u.IsDefined(typeof(SuppressSnifferAttribute), false)); + } + + /// + /// 判断是否启用 MiniProfiler + /// + /// + internal static bool CanBeMiniProfiler() + { + // 减少不必要的监听 + if (Settings.InjectMiniProfiler != true || HttpContext == null + || !(HttpContext.Request.Headers.TryGetValue("request-from", out var value) && value == "swagger")) return false; + + return true; + } + + /// + /// 处理获取对象异常问题 + /// + /// 类型 + /// 获取对象委托 + /// 默认值 + /// T + private static T CatchOrDefault(Func action, T defaultValue = null) + where T : class + { + try + { + return action(); + } + catch + { + return defaultValue ?? null; + } + } +} diff --git a/src/Admin/ThingsGateway.Furion/App/Attributes/AppStartupAttribute.cs b/src/Admin/ThingsGateway.Furion/App/Attributes/AppStartupAttribute.cs new file mode 100644 index 000000000..a79cdf222 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Attributes/AppStartupAttribute.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +/// 注册服务启动配置 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class)] +public sealed class AppStartupAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// + public AppStartupAttribute(int order) + { + Order = order; + } + + /// + /// 排序 + /// + /// 优先调用数值较大的 + public int Order { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/AppApplicationBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/AppApplicationBuilderExtensions.cs new file mode 100644 index 000000000..1ea4dc842 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/AppApplicationBuilderExtensions.cs @@ -0,0 +1,125 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; + +using ThingsGateway; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// 应用中间件拓展类(由框架内部调用) +/// +[SuppressSniffer] +public static class AppApplicationBuilderExtensions +{ + + /// + /// 设置默认服务存储器 + /// + /// + /// + /// 解决在主机启动前解析服务问题 + /// 使用:var app = builder.Build().UseDefaultServiceProvider(); + /// + /// + public static WebApplication UseDefaultServiceProvider(this WebApplication app) + { + InternalApp.RootServices ??= app.Services; + + return app; + } + + /// + /// 注入基础中间件(带Swagger) + /// + /// + /// 空字符串将为首页 + /// + /// 解决 Swagger 被代理问题 + /// + public static IApplicationBuilder UseInject(this IApplicationBuilder app, string routePrefix = default, Action configure = null, bool withProxy = false) + { + // 载入中间件配置选项 + var configureOptions = new UseInjectOptions(); + configure?.Invoke(configureOptions); + + app.UseSpecificationDocuments(routePrefix, UseInjectOptions.SwaggerConfigure, UseInjectOptions.SwaggerUIConfigure, withProxy); + + return app; + } + + /// + /// 注入基础中间件(带Swagger) + /// + /// + /// + /// 解决 Swagger 被代理问题 + /// + public static IApplicationBuilder UseInject(this IApplicationBuilder app, Action configure, bool withProxy = false) + { + return app.UseInject(default, configure: configure, withProxy: withProxy); + } + + /// + /// 注入基础中间件 + /// + /// + /// + public static IApplicationBuilder UseInjectBase(this IApplicationBuilder app) + { + return app; + } + + /// + /// 解决 .NET6 WebApplication 模式下二级虚拟目录错误问题 + /// + /// + /// + public static IApplicationBuilder MapRouteControllers(this IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + + return app; + } + + /// + /// 启用 Body 重复读功能 + /// + /// 须在 app.UseRouting() 之前注册 + /// + /// + public static IApplicationBuilder EnableBuffering(this IApplicationBuilder app) + { + return app.Use(next => context => + { + context.Request.EnableBuffering(); + return next(context); + }); + } + + /// + /// 添加应用中间件 + /// + /// 应用构建器 + /// 应用配置 + /// + internal static IApplicationBuilder UseApp(this IApplicationBuilder app, Action configure = null) + { + // 调用自定义服务 + configure?.Invoke(app); + return app; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/AppServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/AppServiceCollectionExtensions.cs new file mode 100644 index 000000000..afbc22d21 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/AppServiceCollectionExtensions.cs @@ -0,0 +1,269 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Hosting; + +using System.Reflection; +using System.Text; + +using ThingsGateway; +using ThingsGateway.UnifyResult; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 应用服务集合拓展类(由框架内部调用) +/// +[SuppressSniffer] +public static class AppServiceCollectionExtensions +{ + /// + /// Mvc 注入基础配置(带Swagger) + /// + /// Mvc构建器 + /// + /// IMvcBuilder + public static IMvcBuilder AddInject(this IMvcBuilder mvcBuilder, Action configure = null) + { + mvcBuilder.Services.AddInject(configure); + + return mvcBuilder; + } + + /// + /// 服务注入基础配置(带Swagger) + /// + /// 服务集合 + /// + /// IMvcBuilder + public static IServiceCollection AddInject(this IServiceCollection services, Action configure = null) + { + // 载入服务配置选项 + var configureOptions = new AddInjectOptions(); + configure?.Invoke(configureOptions); + + services.AddSpecificationDocuments(AddInjectOptions.SwaggerGenConfigure) + .AddDynamicApiControllers() + .AddDataValidation(AddInjectOptions.DataValidationConfigure) + .AddFriendlyException(AddInjectOptions.FriendlyExceptionConfigure); + + return services; + } + + /// + /// MiniAPI 服务注入基础配置(带Swagger) + /// + /// 服务集合 + /// + /// IMvcBuilder + /// https://docs.microsoft.com/zh-cn/aspnet/core/fundamentals/minimal-apis?view=aspnetcore-6.0 + public static IServiceCollection AddInjectMini(this IServiceCollection services, Action configure = null) + { + // 载入服务配置选项 + var configureOptions = new AddInjectOptions(); + configure?.Invoke(configureOptions); + + services.AddSpecificationDocuments(AddInjectOptions.SwaggerGenConfigure) + .AddDataValidation(AddInjectOptions.DataValidationConfigure) + .AddFriendlyException(AddInjectOptions.FriendlyExceptionConfigure); + + return services; + } + + /// + /// Mvc 注入基础配置 + /// + /// Mvc构建器 + /// + /// IMvcBuilder + public static IMvcBuilder AddInjectBase(this IMvcBuilder mvcBuilder, Action configure = null) + { + mvcBuilder.Services.AddInjectBase(configure); + + return mvcBuilder; + } + + /// + /// Mvc 注入基础配置 + /// + /// 服务集合 + /// + /// IMvcBuilder + public static IServiceCollection AddInjectBase(this IServiceCollection services, Action configure = null) + { + // 载入服务配置选项 + var configureOptions = new AddInjectOptions(); + configure?.Invoke(configureOptions); + + services.AddDataValidation(AddInjectOptions.DataValidationConfigure) + .AddFriendlyException(AddInjectOptions.FriendlyExceptionConfigure); + + return services; + } + + /// + /// Mvc 注入基础配置和规范化结果 + /// + /// + /// + /// + public static IMvcBuilder AddInjectWithUnifyResult(this IMvcBuilder mvcBuilder, Action configure = null) + { + mvcBuilder.Services.AddInjectWithUnifyResult(configure); + + return mvcBuilder; + } + + /// + /// 注入基础配置和规范化结果 + /// + /// + /// + /// + public static IServiceCollection AddInjectWithUnifyResult(this IServiceCollection services, Action configure = null) + { + services.AddInject(configure) + .AddUnifyResult(); + + return services; + } + + /// + /// Mvc 注入基础配置和规范化结果 + /// + /// + /// + /// + /// + public static IMvcBuilder AddInjectWithUnifyResult(this IMvcBuilder mvcBuilder, Action configure = null) + where TUnifyResultProvider : class, IUnifyResultProvider + { + mvcBuilder.Services.AddInjectWithUnifyResult(configure); + + return mvcBuilder; + } + + /// + /// Mvc 注入基础配置和规范化结果 + /// + /// + /// + /// + /// + public static IServiceCollection AddInjectWithUnifyResult(this IServiceCollection services, Action configure = null) + where TUnifyResultProvider : class, IUnifyResultProvider + { + services.AddInject(configure) + .AddUnifyResult(); + + return services; + } + + /// + /// 自动添加主机服务 + /// + /// + /// + public static IServiceCollection AddAppHostedService(this IServiceCollection services) + { + // 获取所有 BackgroundService 类型,排除泛型主机 + var backgroundServiceTypes = App.EffectiveTypes.Where(u => !u.IsAbstract && !u.IsInterface && typeof(IHostedService).IsAssignableFrom(u) && u.Name != "GenericWebHostService"); + var addHostServiceMethod = typeof(ServiceCollectionHostedServiceExtensions).GetMethods(BindingFlags.Static | BindingFlags.Public) + .Where(u => u.Name.Equals("AddHostedService") && u.IsGenericMethod && u.GetParameters().Length == 1) + .FirstOrDefault(); + + foreach (var type in backgroundServiceTypes) + { + addHostServiceMethod.MakeGenericMethod(type).Invoke(null, new object[] { services }); + } + + return services; + } + + /// + /// 添加应用配置 + /// + /// 服务集合 + /// 服务配置 + /// 服务集合 + internal static IServiceCollection AddApp(this IServiceCollection services, Action configure = null) + { + // 注册全局配置选项 + services.AddConfigurableOptions(); + + // 注册内存和分布式内存 + services.AddMemoryCache(); + services.AddDistributedMemoryCache(); + + // 注册全局依赖注入 + services.AddDependencyInjection(); + + // 注册全局 Startup 扫描 + services.AddStartups(); + + // 添加对象映射 + services.AddObjectMapper(); + + // 默认内置 GBK,Windows-1252, Shift-JIS, GB2312 编码支持 + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + // 自定义服务 + configure?.Invoke(services); + + return services; + } + + /// + /// 添加 Startup 自动扫描 + /// + /// 服务集合 + /// 服务集合 + internal static IServiceCollection AddStartups(this IServiceCollection services) + { + // 扫描所有继承 AppStartup 的类 + var startups = App.EffectiveTypes + .Where(u => typeof(AppStartup).IsAssignableFrom(u) && u.IsClass && !u.IsAbstract && !u.IsGenericType) + .OrderByDescending(u => GetStartupOrder(u)); + + // 注册自定义 startup + foreach (var type in startups) + { + var startup = Activator.CreateInstance(type) as AppStartup; + App.AppStartups.Add(startup); + + // 获取所有符合依赖注入格式的方法,如返回值void,且第一个参数是 IServiceCollection 类型 + var serviceMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(u => u.ReturnType == typeof(void) + && u.GetParameters().Length > 0 + && u.GetParameters().First().ParameterType == typeof(IServiceCollection)); + + if (!serviceMethods.Any()) continue; + + // 自动安装属性调用 + foreach (var method in serviceMethods) + { + method.Invoke(startup, new[] { services }); + } + } + + return services; + } + + /// + /// 获取 Startup 排序 + /// + /// 排序类型 + /// int + private static int GetStartupOrder(Type type) + { + return !type.IsDefined(typeof(AppStartupAttribute), true) ? 0 : type.GetCustomAttribute(true).Order; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/AppWebApplicationBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/AppWebApplicationBuilderExtensions.cs new file mode 100644 index 000000000..7ea065ba4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/AppWebApplicationBuilderExtensions.cs @@ -0,0 +1,149 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +using ThingsGateway; +using ThingsGateway.Components; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// WebApplication 拓展 +/// +public static class AppWebApplicationBuilderExtensions +{ + /// + /// Web 应用注入 + /// + /// Web应用构建器 + /// + /// WebApplicationBuilder + public static WebApplicationBuilder Inject(this WebApplicationBuilder webApplicationBuilder, Action configure = default) + { + // 载入服务配置选项 + var configureOptions = new InjectOptions(); + configure?.Invoke(webApplicationBuilder, configureOptions); + + // 为了兼容 .NET 5 无缝升级至 .NET 6,故传递 WebHost 和 Host + InternalApp.WebHostEnvironment = webApplicationBuilder.Environment; + + // 初始化配置 + InternalApp.ConfigureApplication(webApplicationBuilder.WebHost, webApplicationBuilder.Host); + + return webApplicationBuilder; + } + + /// + /// 注册依赖组件 + /// + /// 派生自 + /// Web应用构建器 + /// 组件参数 + /// + public static WebApplicationBuilder AddComponent(this WebApplicationBuilder webApplicationBuilder, object options = default) + where TComponent : class, IServiceComponent, new() + { + webApplicationBuilder.Services.AddComponent(options); + + return webApplicationBuilder; + } + + /// + /// 注册依赖组件 + /// + /// 派生自 + /// 组件参数 + /// Web应用构建器 + /// 组件参数 + /// + public static WebApplicationBuilder AddComponent(this WebApplicationBuilder webApplicationBuilder, TComponentOptions options = default) + where TComponent : class, IServiceComponent, new() + { + webApplicationBuilder.Services.AddComponent(options); + + return webApplicationBuilder; + } + + /// + /// 注册依赖组件 + /// + /// Web应用构建器 + /// 组件类型 + /// 组件参数 + /// + public static WebApplicationBuilder AddComponent(this WebApplicationBuilder webApplicationBuilder, Type componentType, object options = default) + { + webApplicationBuilder.Services.AddComponent(componentType, options); + + return webApplicationBuilder; + } + + + + /// + /// 注册 WebApplicationBuilder 依赖组件 + /// + /// 派生自 + /// 组件参数 + /// Web应用构建器 + /// 组件参数 + /// + public static WebApplicationBuilder AddWebComponent(this WebApplicationBuilder webApplicationBuilder, TComponentOptions options = default) + where TComponent : class, IWebComponent, new() + { + webApplicationBuilder.AddWebComponent(options); + + return webApplicationBuilder; + } + + /// + /// 注册 WebApplicationBuilder 依赖组件 + /// + /// + /// 组件类型 + /// 组件参数 + /// + public static WebApplicationBuilder AddWebComponent(this WebApplicationBuilder webApplicationBuilder, Type componentType, object options = default) + { + // 创建组件依赖链 + var componentContextLinkList = Penetrates.CreateDependLinkList(componentType, options); + + // 逐条创建组件实例并调用 + foreach (var context in componentContextLinkList) + { + // 创建组件实例 + var component = Activator.CreateInstance(context.ComponentType) as IWebComponent; + + // 调用 + component.Load(webApplicationBuilder, context); + } + + return webApplicationBuilder; + } + + /// + /// 解决 .NET6 WebApplication 模式下二级虚拟目录错误问题 + /// + /// + /// + /// + public static IApplicationBuilder UseVirtualPath(this WebApplication app, Action configuration) + { + if (!string.IsNullOrWhiteSpace(App.Settings.VirtualPath)) + { + return app.Map(App.Settings.VirtualPath, configuration); + } + + configuration(app); + return app; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/HostBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/HostBuilderExtensions.cs new file mode 100644 index 000000000..cf45db84e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/HostBuilderExtensions.cs @@ -0,0 +1,111 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Hosting; + +using ThingsGateway; +using ThingsGateway.Extensions; +using ThingsGateway.Reflection; + +namespace Microsoft.Extensions.Hosting; + +/// +/// 主机构建器拓展类 +/// +[SuppressSniffer] +public static class HostBuilderExtensions +{ + /// + /// Web 主机注入 + /// + /// Web主机构建器 + /// + /// IWebHostBuilder + public static IWebHostBuilder Inject(this IWebHostBuilder hostBuilder, Action configure = default) + { + // 载入服务配置选项 + var configureOptions = new InjectOptions(); + configure?.Invoke(hostBuilder, configureOptions); + + // 获取默认程序集名称 + var defaultAssemblyName = configureOptions.AssemblyName ?? Reflect.GetAssemblyName(typeof(HostBuilderExtensions)); + + // 获取环境变量 ASPNETCORE_HOSTINGSTARTUPASSEMBLIES 配置 + var environmentVariables = Environment.GetEnvironmentVariable("ASPNETCORE_HOSTINGSTARTUPASSEMBLIES"); + var combineAssembliesName = $"{defaultAssemblyName};{environmentVariables}".ClearStringAffixes(1, ";"); + + hostBuilder.UseSetting(WebHostDefaults.HostingStartupAssembliesKey, combineAssembliesName); + + // 实现假的 Starup,解决泛型主机启动问题 + hostBuilder.UseStartup(); + return hostBuilder; + } + + /// + /// 泛型主机注入 + /// + /// 泛型主机注入构建器 + /// + /// IHostBuilder + public static IHostBuilder Inject(this IHostBuilder hostBuilder, Action configure = default) + { + // 载入服务配置选项 + var configureOptions = new InjectOptions(); + configure?.Invoke(hostBuilder, configureOptions); + + InternalApp.ConfigureApplication(hostBuilder, configureOptions.AutoRegisterBackgroundService); + + return hostBuilder; + } + + /// + /// 注册 IWebHostBuilder 依赖组件 + /// + /// 派生自 + /// Web应用构建器 + /// 组件参数 + /// + public static IWebHostBuilder AddWebComponent(this IWebHostBuilder hostBuilder, object options = default) + where TComponent : class, IWebComponent, new() + { + hostBuilder.AddWebComponent(options); + + return hostBuilder; + } + + /// + /// 注册 IWebHostBuilder 依赖组件 + /// + /// 派生自 + /// 组件参数 + /// Web应用构建器 + /// 组件参数 + /// + public static IWebHostBuilder AddWebComponent(this IWebHostBuilder hostBuilder, TComponentOptions options = default) + where TComponent : class, IWebComponent, new() + { + hostBuilder.AddWebComponent(options); + + return hostBuilder; + } + + /// + /// 注册 IWebHostBuilder 依赖组件 + /// + /// + /// 组件类型 + /// 组件参数 + /// + public static IWebHostBuilder AddWebComponent(this IWebHostBuilder hostBuilder, Type componentType, object options = default) + { + return hostBuilder; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/IConfigurationExtenstions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/IConfigurationExtenstions.cs new file mode 100644 index 000000000..d1d66ec84 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/IConfigurationExtenstions.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway; + +namespace Microsoft.Extensions.Configuration; + +/// +/// 拓展 +/// +public static class IConfigurationExtenstions +{ + /// + /// 刷新配置对象 + /// + /// + /// + public static IConfiguration Reload(this IConfiguration configuration) + { + if (App.RootServices == null) return configuration; + + var newConfiguration = App.GetService(App.RootServices); + InternalApp.Configuration = newConfiguration; + + return newConfiguration; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/IServiceScopeExtensions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/IServiceScopeExtensions.cs new file mode 100644 index 000000000..c3714d2dd --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/IServiceScopeExtensions.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; + +using System.Security.Claims; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 拓展类 +/// +public static class IServiceScopeExtensions +{ + /// + /// 在当前服务作用域下创建 实例 + /// + /// 解决多线程中获取 空问题 + /// + /// ,可通过 HttpContext.Features 获取 + /// ,可通过 HttpContext.User 获取 + public static void CreateDefaultHttpContext(this IServiceScope serviceScope, IFeatureCollection feature, ClaimsPrincipal claims) + { + var httpContextAccessor = serviceScope.ServiceProvider.GetService(); + httpContextAccessor.HttpContext = new DefaultHttpContext(feature) + { + RequestServices = serviceScope.ServiceProvider, + User = claims + }; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/ObjectExtensions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/ObjectExtensions.cs new file mode 100644 index 000000000..3855a3558 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/ObjectExtensions.cs @@ -0,0 +1,674 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; + +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ThingsGateway.Extensions; + +/// +/// 对象拓展类 +/// +[SuppressSniffer] +public static class ObjectExtensions +{ + /// + /// 将 DateTimeOffset 转换成本地 DateTime + /// + /// + /// + public static DateTime ConvertToDateTime(this DateTimeOffset dateTime) + { + if (dateTime.Offset.Equals(TimeSpan.Zero)) + return dateTime.UtcDateTime; + if (dateTime.Offset.Equals(TimeZoneInfo.Local.GetUtcOffset(dateTime.DateTime))) + return dateTime.ToLocalTime().DateTime; + else + return dateTime.DateTime; + } + + /// + /// 将 DateTimeOffset? 转换成本地 DateTime? + /// + /// + /// + public static DateTime? ConvertToDateTime(this DateTimeOffset? dateTime) + { + return dateTime.HasValue ? dateTime.Value.ConvertToDateTime() : null; + } + + /// + /// 将 DateTime 转换成 DateTimeOffset + /// + /// + /// + public static DateTimeOffset ConvertToDateTimeOffset(this DateTime dateTime) + { + return DateTime.SpecifyKind(dateTime, DateTimeKind.Local); + } + + /// + /// 将 DateTime? 转换成 DateTimeOffset? + /// + /// + /// + public static DateTimeOffset? ConvertToDateTimeOffset(this DateTime? dateTime) + { + return dateTime.HasValue ? dateTime.Value.ConvertToDateTimeOffset() : null; + } + + /// + /// 将时间戳转换为 DateTime + /// + /// + /// + internal static DateTime ConvertToDateTime(this long timestamp) + { + var timeStampDateTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var digitCount = (int)Math.Floor(Math.Log10(timestamp) + 1); + + if (digitCount != 13 && digitCount != 10) + { + throw new ArgumentException("Data is not a valid timestamp format."); + } + + return (digitCount == 13 + ? timeStampDateTime.AddMilliseconds(timestamp) // 13 位时间戳 + : timeStampDateTime.AddSeconds(timestamp)).ToLocalTime(); // 10 位时间戳 + } + + /// + /// 将 IFormFile 转换成 byte[] + /// + /// + /// + public static byte[] ToByteArray(this IFormFile formFile) + { + var fileLength = formFile.Length; + using var stream = formFile.OpenReadStream(); + var bytes = new byte[fileLength]; + + _ = stream.Read(bytes, 0, (int)fileLength); + + return bytes; + } + + /// + /// 将流保存到本地磁盘 + /// + /// + /// + /// + public static void CopyToSave(this Stream stream, string path) + { + // 空检查 + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path)); + + using var fileStream = File.Create(path); + stream.CopyTo(fileStream); + } + + /// + /// 将字节数组保存到本地磁盘 + /// + /// + /// + /// + public static void CopyToSave(this byte[] bytes, string path) + { + using var stream = new MemoryStream(bytes); + stream.CopyToSave(path); + } + + /// + /// 将流保存到本地磁盘 + /// + /// + /// 需包含文件名完整路径 + /// + public static async Task CopyToSaveAsync(this Stream stream, string path) + { + // 空检查 + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentNullException(nameof(path)); + } + + // 文件名判断 + if (string.IsNullOrWhiteSpace(Path.GetFileName(path))) + { + throw new ArgumentException("The parameter of parameter must include the complete file name."); + } + + using var fileStream = File.Create(path); + await stream.CopyToAsync(fileStream).ConfigureAwait(false); + } + + /// + /// 将字节数组保存到本地磁盘 + /// + /// + /// + /// + public static async Task CopyToSaveAsync(this byte[] bytes, string path) + { + using var stream = new MemoryStream(bytes); + await stream.CopyToSaveAsync(path).ConfigureAwait(false); + } + /// + /// 添加防抖操作 + /// + /// + /// + /// + /// + public static Action Debounce(this Action func, int milliseconds = 300) + { + var last = 0; + + return arg => + { + var current = Interlocked.Increment(ref last); + Task.Delay(milliseconds).ContinueWith(task => + { + if (current == last) func(arg); + task.Dispose(); + }); + }; + } + + /// + /// 添加防抖操作 + /// + /// + /// + /// + public static Action Debounce(this Action func, int milliseconds = 300) + { + var last = 0; + + return () => + { + var current = Interlocked.Increment(ref last); + Task.Delay(milliseconds).ContinueWith(task => + { + if (current == last) func(); + task.Dispose(); + }); + }; + } + + /// + /// 判断是否是富基元类型 + /// + /// 类型 + /// + internal static bool IsRichPrimitive(this Type type) + { + // 处理元组类型 + if (type.IsValueTuple()) return false; + + // 处理数组类型,基元数组类型也可以是基元类型 + if (type.IsArray) return type.GetElementType().IsRichPrimitive(); + + // 基元类型或值类型或字符串类型 + if (type.IsPrimitive || type.IsValueType || type == typeof(string)) return true; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) return type.GenericTypeArguments[0].IsRichPrimitive(); + + return false; + } + + /// + /// 合并两个字典 + /// + /// + /// 字典 + /// 新字典 + /// + internal static Dictionary AddOrUpdate(this Dictionary dic, IDictionary newDic) + { + foreach (var key in newDic.Keys) + { + if (dic.TryGetValue(key, out var value)) + { + dic[key] = value; + } + else + { + dic.Add(key, newDic[key]); + } + } + + return dic; + } + + /// + /// 合并两个字典 + /// + /// + /// 字典 + /// 新字典 + internal static void AddOrUpdate(this ConcurrentDictionary dic, Dictionary newDic) + { + foreach (var (key, value) in newDic) + { + dic.AddOrUpdate(key, value, (key, old) => value); + } + } + + /// + /// 判断是否是元组类型 + /// + /// 类型 + /// + internal static bool IsValueTuple(this Type type) + { + return type.Namespace == "System" && type.Name.Contains("ValueTuple`"); + } + + /// + /// 判断方法是否是异步 + /// + /// 方法 + /// + internal static bool IsAsync(this MethodInfo method) + { + return method.GetCustomAttribute() != null + || method.ReturnType.ToString().StartsWith(typeof(Task).FullName); + } + + /// + /// 判断类型是否实现某个泛型 + /// + /// 类型 + /// 泛型类型 + /// bool + internal static bool HasImplementedRawGeneric(this Type type, Type generic) + { + // 检查接口类型 + var isTheRawGenericType = type.GetInterfaces().Any(IsTheRawGenericType); + if (isTheRawGenericType) return true; + + // 检查类型 + while (type != null && type != typeof(object)) + { + isTheRawGenericType = IsTheRawGenericType(type); + if (isTheRawGenericType) return true; + type = type.BaseType; + } + + return false; + + // 判断逻辑 + bool IsTheRawGenericType(Type type) => generic == (type.IsGenericType ? type.GetGenericTypeDefinition() : type); + } + + /// + /// 判断是否是匿名类型 + /// + /// 对象 + /// + internal static bool IsAnonymous(this object obj) + { + var type = obj is Type t ? t : obj.GetType(); + + return Attribute.IsDefined(type, typeof(CompilerGeneratedAttribute), false) + && type.IsGenericType && type.Name.Contains("AnonymousType") + && (type.Name.StartsWith("<>") || type.Name.StartsWith("VB$")) + && type.Attributes.HasFlag(TypeAttributes.NotPublic); + } + + /// + /// 获取所有祖先类型 + /// + /// + /// + internal static IEnumerable GetAncestorTypes(this Type type) + { + var ancestorTypes = new List(); + while (type != null && type != typeof(object)) + { + if (IsNoObjectBaseType(type)) + { + var baseType = type.BaseType; + ancestorTypes.Add(baseType); + type = baseType; + } + else break; + } + + return ancestorTypes; + + static bool IsNoObjectBaseType(Type type) => type.BaseType != typeof(object); + } + + /// + /// 获取方法真实返回类型 + /// + /// + /// + internal static Type GetRealReturnType(this MethodInfo method) + { + // 判断是否是异步方法 + var isAsyncMethod = method.IsAsync(); + + // 获取类型返回值并处理 Task 和 Task 类型返回值 + var returnType = method.ReturnType; + return isAsyncMethod ? (returnType.GenericTypeArguments.FirstOrDefault() ?? typeof(void)) : returnType; + } + + /// + /// 将一个对象转换为指定类型 + /// + /// + /// + /// + public static T ChangeType(this object obj) + { + return (T)ChangeType(obj, typeof(T)); + } + + /// + /// 将一个对象转换为指定类型 + /// + /// 待转换的对象 + /// 目标类型 + /// 转换后的对象 + public static object ChangeType(this object obj, Type type) + { + if (type == null) return obj; + if (type == typeof(string)) return obj?.ToString(); + if (type == typeof(Guid) && obj != null) return Guid.Parse(obj.ToString()); + if (type == typeof(bool) && obj != null && obj is not bool) + { + var objStr = obj.ToString().ToLower(); + if (objStr == "1" || objStr == "true" || objStr == "yes" || objStr == "on") return true; + return false; + } + if (obj == null) return type.IsValueType ? Activator.CreateInstance(type) : null; + + var underlyingType = Nullable.GetUnderlyingType(type); + if (type.IsAssignableFrom(obj.GetType())) return obj; + else if ((underlyingType ?? type).IsEnum) + { + if (underlyingType != null && string.IsNullOrWhiteSpace(obj.ToString())) return null; + else return Enum.Parse(underlyingType ?? type, obj.ToString()); + } + // 处理 DateTime -> DateTimeOffset 类型 + else if (obj.GetType().Equals(typeof(DateTime)) && (underlyingType ?? type).Equals(typeof(DateTimeOffset))) + { + return ((DateTime)obj).ConvertToDateTimeOffset(); + } + // 处理 DateTimeOffset -> DateTime 类型 + else if (obj.GetType().Equals(typeof(DateTimeOffset)) && (underlyingType ?? type).Equals(typeof(DateTime))) + { + return ((DateTimeOffset)obj).ConvertToDateTime(); + } + // 处理 DateTime -> DateOnly 类型 + else if (obj.GetType().Equals(typeof(DateTime)) && (underlyingType ?? type).Equals(typeof(DateOnly))) + { + return DateOnly.FromDateTime(((DateTime)obj)); + } + // 处理 DateTime -> TimeOnly 类型 + else if (obj.GetType().Equals(typeof(DateTime)) && (underlyingType ?? type).Equals(typeof(TimeOnly))) + { + return TimeOnly.FromDateTime(((DateTime)obj)); + } + else if (typeof(IConvertible).IsAssignableFrom(underlyingType ?? type)) + { + try + { + return Convert.ChangeType(obj, underlyingType ?? type, null); + } + catch + { + return underlyingType == null ? Activator.CreateInstance(type) : null; + } + } + else + { + var converter = TypeDescriptor.GetConverter(type); + if (converter.CanConvertFrom(obj.GetType())) return converter.ConvertFrom(obj); + + var constructor = type.GetConstructor(Type.EmptyTypes); + if (constructor != null) + { + var o = constructor.Invoke(null); + var propertys = type.GetProperties(); + var oldType = obj.GetType(); + + foreach (var property in propertys) + { + var p = oldType.GetProperty(property.Name); + if (property.CanWrite && p != null && p.CanRead) + { + property.SetValue(o, ChangeType(p.GetValue(obj, null), property.PropertyType), null); + } + } + return o; + } + } + + return obj; + } + + /// + /// 查找方法指定特性,如果没找到则继续查找声明类 + /// + /// + /// + /// + /// + internal static TAttribute GetFoundAttribute(this MethodInfo method, bool inherit) + where TAttribute : Attribute + { + // 获取方法所在类型 + var declaringType = method.DeclaringType; + + var attributeType = typeof(TAttribute); + + // 判断方法是否定义指定特性,如果没有再查找声明类 + var foundAttribute = method.IsDefined(attributeType, inherit) + ? method.GetCustomAttribute(inherit) + : ( + declaringType.IsDefined(attributeType, inherit) + ? declaringType.GetCustomAttribute(inherit) + : default + ); + + return foundAttribute; + } + + /// + /// 格式化字符串 + /// + /// + /// + /// + internal static string Format(this string str, params object[] args) + { + return args == null || args.Length == 0 ? str : string.Format(str, args); + } + + /// + /// 切割骆驼命名式字符串 + /// + /// + /// + internal static string[] SplitCamelCase(this string str) + { + if (str == null) return Array.Empty(); + + if (string.IsNullOrWhiteSpace(str)) return new string[] { str }; + if (str.Length == 1) return new string[] { str }; + + return Regex.Split(str, @"(?=\p{Lu}\p{Ll})|(?<=\p{Ll})(?=\p{Lu})") + .Where(u => u.Length > 0) + .ToArray(); + } + + /// + /// JsonElement 转 Object + /// + /// + /// + internal static object ToObject(this JsonElement jsonElement) + { + switch (jsonElement.ValueKind) + { + case JsonValueKind.String: + return jsonElement.GetString(); + + case JsonValueKind.Undefined: + case JsonValueKind.Null: + return default; + + case JsonValueKind.Number: + return jsonElement.GetDecimal(); + + case JsonValueKind.True: + case JsonValueKind.False: + return jsonElement.GetBoolean(); + + case JsonValueKind.Object: + var enumerateObject = jsonElement.EnumerateObject(); + var dic = new Dictionary(); + foreach (var item in enumerateObject) + { + dic.Add(item.Name, item.Value.ToObject()); + } + return dic; + + case JsonValueKind.Array: + var enumerateArray = jsonElement.EnumerateArray(); + var list = new List(); + foreach (var item in enumerateArray) + { + list.Add(item.ToObject()); + } + return list; + + default: + return default; + } + } + + /// + /// 清除字符串前后缀 + /// + /// 字符串 + /// 0:前后缀,1:后缀,-1:前缀 + /// 前后缀集合 + /// + internal static string ClearStringAffixes(this string str, int pos = 0, params string[] affixes) + { + // 空字符串直接返回 + if (string.IsNullOrWhiteSpace(str)) return str; + + // 空前后缀集合直接返回 + if (affixes == null || affixes.Length == 0) return str; + + var startCleared = false; + var endCleared = false; + + string tempStr = null; + foreach (var affix in affixes) + { + if (string.IsNullOrWhiteSpace(affix)) continue; + + if (pos != 1 && !startCleared && str.StartsWith(affix, StringComparison.OrdinalIgnoreCase)) + { + tempStr = str[affix.Length..]; + startCleared = true; + } + if (pos != -1 && !endCleared && str.EndsWith(affix, StringComparison.OrdinalIgnoreCase)) + { + var _tempStr = !string.IsNullOrWhiteSpace(tempStr) ? tempStr : str; + tempStr = _tempStr[..^affix.Length]; + endCleared = true; + + if (string.IsNullOrWhiteSpace(tempStr)) + { + tempStr = null; + endCleared = false; + } + } + if (startCleared && endCleared) break; + } + + return !string.IsNullOrWhiteSpace(tempStr) ? tempStr : str; + } + + /// + /// 首字母小写 + /// + /// + /// + internal static string ToLowerCamelCase(this string str) + { + if (string.IsNullOrWhiteSpace(str)) return str; + + return string.Concat(str.First().ToString().ToLower(), str.AsSpan(1)); + } + + /// + /// 首字母大写 + /// + /// + /// + internal static string ToUpperCamelCase(this string str) + { + if (string.IsNullOrWhiteSpace(str)) return str; + + return string.Concat(str.First().ToString().ToUpper(), str.AsSpan(1)); + } + + /// + /// 判断集合是否为空 + /// + /// 元素类型 + /// 集合对象 + /// 实例,true 表示空集合,false 表示非空集合 + internal static bool IsEmpty(this IEnumerable collection) + { + return collection == null || !collection.Any(); + } + + + /// + /// 获取类型自定义特性 + /// + /// 特性类型 + /// 类类型 + /// 是否继承查找 + /// 特性对象 + internal static TAttribute GetTypeAttribute(this Type type, bool inherit = false) + where TAttribute : Attribute + { + // 空检查 + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + // 检查特性并获取特性对象 + return type.IsDefined(typeof(TAttribute), inherit) + ? type.GetCustomAttribute(inherit) + : default; + } +} diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/Options/AddInjectOptions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/Options/AddInjectOptions.cs new file mode 100644 index 000000000..a531b8ed2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/Options/AddInjectOptions.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Swashbuckle.AspNetCore.SwaggerGen; + +using ThingsGateway.DataValidation; +using ThingsGateway.FriendlyException; + +namespace ThingsGateway; + +/// +/// AddInject 配置选项 +/// +public sealed class AddInjectOptions +{ + /// + /// 配置 Swagger Gen + /// + /// + public void ConfigureSwaggerGen(Action configure) + { + SwaggerGenConfigure = configure; + } + + /// + /// 配置 DataValidation + /// + /// + public void ConfigureDataValidation(Action configure) + { + DataValidationConfigure = configure; + } + + /// + /// 配置 FriendlyException + /// + /// + public void ConfigureFriendlyException(Action configure) + { + FriendlyExceptionConfigure = configure; + } + + /// + /// Swagger Gen 配置 + /// + internal static Action SwaggerGenConfigure { get; private set; } + + /// + /// DataValidation 配置 + /// + internal static Action DataValidationConfigure { get; private set; } + + /// + /// FriendlyException 配置 + /// + internal static Action FriendlyExceptionConfigure { get; private set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/Options/InjectOptions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/Options/InjectOptions.cs new file mode 100644 index 000000000..e98658b5c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/Options/InjectOptions.cs @@ -0,0 +1,117 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace ThingsGateway; + +/// +/// Inject 配置选项 +/// +public sealed class InjectOptions +{ + /// + /// 外部程序集名称 + /// + public string AssemblyName { get; set; } + + /// + /// 是否自动注册 BackgroundService + /// + public bool AutoRegisterBackgroundService { get; set; } = true; + + /// + /// 配置 ConfigurationScanDirectories + /// + /// + public void ConfigurationScanDirectories(params string[] directories) + { + InternalConfigurationScanDirectories = directories ?? Array.Empty(); + } + + /// + /// 配置 IgnoreConfigurationFiles + /// + /// + public void IgnoreConfigurationFiles(params string[] files) + { + InternalIgnoreConfigurationFiles = files ?? Array.Empty(); + } + + /// + /// 配置 ConfigureAppConfiguration + /// + /// + public void ConfigureAppConfiguration(Action configure) + { + AppConfigurationConfigure = configure; + } + + /// + /// 配置 ConfigureAppConfiguration(Web) + /// + /// + public void ConfigureWebAppConfiguration(Action configure) + { + WebAppConfigurationConfigure = configure; + } + + /// + /// 配置 ConfigureServices + /// + /// + public void ConfigureServices(Action configure) + { + ServicesConfigure = configure; + } + + /// + /// 配置 ConfigureServices(Web) + /// + /// + public void ConfigureWebServices(Action configure) + { + WebServicesConfigure = configure; + } + + /// + /// 配置配置文件扫描目录 + /// + internal static IEnumerable InternalConfigurationScanDirectories { get; private set; } = Array.Empty(); + + /// + /// 配置配置文件忽略注册文件 + /// + internal static IEnumerable InternalIgnoreConfigurationFiles { get; private set; } = Array.Empty(); + + /// + /// AppConfiguration 配置 + /// + internal static Action AppConfigurationConfigure { get; private set; } + + /// + /// AppConfiguration 配置(Web) + /// + internal static Action WebAppConfigurationConfigure { get; private set; } + + /// + /// Services 配置 + /// + internal static Action ServicesConfigure { get; private set; } + + /// + /// Services 配置(Web) + /// + internal static Action WebServicesConfigure { get; private set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Extensions/Options/UseInjectOptions.cs b/src/Admin/ThingsGateway.Furion/App/Extensions/Options/UseInjectOptions.cs new file mode 100644 index 000000000..ff9393709 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Extensions/Options/UseInjectOptions.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerUI; + +namespace ThingsGateway; + +/// +/// UseInject 配置选项 +/// +public sealed class UseInjectOptions +{ + /// + /// 配置 Swagger + /// + /// + public void ConfigureSwagger(Action configure) + { + SwaggerConfigure = configure; + } + + /// + /// 配置 Swagger UI + /// + /// + public void ConfigureSwaggerUI(Action configure) + { + SwaggerUIConfigure = configure; + } + + /// + /// Swagger 配置 + /// + internal static Action SwaggerConfigure { get; private set; } + + /// + /// Swagger UI 配置 + /// + internal static Action SwaggerUIConfigure { get; private set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Filters/StartupFilter.cs b/src/Admin/ThingsGateway.Furion/App/Filters/StartupFilter.cs new file mode 100644 index 000000000..957bab01d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Filters/StartupFilter.cs @@ -0,0 +1,79 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; + +namespace ThingsGateway; + +/// +/// 应用启动时自动注册中间件 +/// +/// +/// +[SuppressSniffer] +public class StartupFilter : IStartupFilter +{ + /// + /// 配置中间件 + /// + /// + /// + public Action Configure(Action next) + { + return app => + { + // 存储根服务 + InternalApp.RootServices ??= app.ApplicationServices; + + // 环境名 + var envName = App.HostEnvironment?.EnvironmentName ?? "Unknown"; + var version = $"{GetType().Assembly.GetName().Version}"; + + // 设置响应报文头信息 + app.Use(async (context, next) => + { + // 处理 WebSocket 请求 + if (context.IsWebSocketRequest()) await next.Invoke().ConfigureAwait(false); + else + { + // 输出当前环境标识 + context.Response.Headers["environment"] = envName; + + // 输出框架版本 + context.Response.Headers[nameof(ThingsGateway)] = version; + + // 执行下一个中间件 + await next.Invoke().ConfigureAwait(false); + + // 解决刷新 Token 时间和 Token 时间相近问题 + if (!context.Response.HasStarted + && context.Response.StatusCode == StatusCodes.Status401Unauthorized + && context.Response.Headers.ContainsKey("access-token") + && context.Response.Headers.ContainsKey("x-access-token")) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + } + + } + }); + + // 调用默认中间件 + app.UseApp(); + + + // 调用启动层的 Startup + next(app); + }; + } + +} diff --git a/src/Admin/ThingsGateway.Furion/App/Internal/InternalApp.cs b/src/Admin/ThingsGateway.Furion/App/Internal/InternalApp.cs new file mode 100644 index 000000000..12269624d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Internal/InternalApp.cs @@ -0,0 +1,254 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileSystemGlobbing; +using Microsoft.Extensions.Hosting; + +namespace ThingsGateway; + +/// +/// 内部 App 副本 +/// +internal static class InternalApp +{ + /// + /// 应用服务 + /// + internal static IServiceCollection InternalServices; + + /// + /// 根服务 + /// + internal static IServiceProvider RootServices; + + /// + /// 配置对象 + /// + internal static IConfiguration Configuration; + + /// + /// 获取Web主机环境 + /// + internal static IWebHostEnvironment WebHostEnvironment; + + /// + /// 获取泛型主机环境 + /// + internal static IHostEnvironment HostEnvironment; + + /// + /// 配置框架(Web) + /// + /// 此次添加 参数是为了兼容 .NET 5 直接升级到 .NET 6 问题 + /// + /// + internal static void ConfigureApplication(IWebHostBuilder builder, IHostBuilder hostBuilder = default) + { + // 自动装载配置 + if (hostBuilder == default) + { + builder.ConfigureAppConfiguration((hostContext, configurationBuilder) => + { + // 存储环境对象 + HostEnvironment = WebHostEnvironment = hostContext.HostingEnvironment; + + // 加载配置 + AddJsonFiles(configurationBuilder, hostContext.HostingEnvironment); + + // 加载自定义配置 + InjectOptions.WebAppConfigurationConfigure?.Invoke(hostContext, configurationBuilder); + }); + } + // 自动装载配置 + else ConfigureHostAppConfiguration(hostBuilder); + + // 应用初始化服务 + builder.ConfigureServices((hostContext, services) => + { + // 存储配置对象 + Configuration = hostContext.Configuration; + + // 存储服务提供器 + InternalServices = services; + + // 存储根服务(解决 Web 主机还未启动时在 HostedService 中使用 App.GetService 问题 + services.AddHostedService(); + + // 注册 Startup 过滤器 + services.AddTransient(); + + // 注册 HttpContextAccessor 服务 + services.AddHttpContextAccessor(); + + // 初始化应用服务 + services.AddApp(); + + // 加载自定义配置 + InjectOptions.WebServicesConfigure?.Invoke(hostContext, services); + }); + } + + /// + /// 配置框架(非 Web) + /// + /// + /// + internal static void ConfigureApplication(IHostBuilder builder, bool autoRegisterBackgroundService = true) + { + // 自动装载配置 + ConfigureHostAppConfiguration(builder); + + // 自动注入 AddApp() 服务 + builder.ConfigureServices((hostContext, services) => + { + // 存储配置对象 + Configuration = hostContext.Configuration; + + // 存储服务提供器 + InternalServices = services; + + // 存储根服务 + services.AddHostedService(); + + // 初始化应用服务 + services.AddApp(); + + // 自动注册 BackgroundService + if (autoRegisterBackgroundService) services.AddAppHostedService(); + + // 加载自定义配置 + InjectOptions.ServicesConfigure?.Invoke(hostContext, services); + }); + } + + /// + /// 自动装载主机配置 + /// + /// + private static void ConfigureHostAppConfiguration(IHostBuilder builder) + { + builder.ConfigureAppConfiguration((hostContext, configurationBuilder) => + { + // 存储环境对象 + HostEnvironment = hostContext.HostingEnvironment; + + // 加载配置 + AddJsonFiles(configurationBuilder, hostContext.HostingEnvironment); + + // 加载自定义配置 + InjectOptions.AppConfigurationConfigure?.Invoke(hostContext, configurationBuilder); + }); + } + + /// + /// 加载自定义 .json 配置文件 + /// + /// + /// + internal static void AddJsonFiles(IConfigurationBuilder configurationBuilder, IHostEnvironment hostEnvironment) + { + // 获取根配置 + var configuration = configurationBuilder is ConfigurationManager + ? (configurationBuilder as ConfigurationManager) + : configurationBuilder.Build(); + + // 获取程序执行目录 + var executeDirectory = AppContext.BaseDirectory; + + // 获取自定义配置扫描目录 + var configurationScanDirectories = (configuration.GetSection("ConfigurationScanDirectories") + .Get() + ?? Array.Empty()).Select(u => Path.Combine(executeDirectory, u)); + + // 扫描执行目录及自定义配置目录下的 *.json 文件 + var jsonFiles = new[] { executeDirectory } + .Concat(configurationScanDirectories) + .Concat(InjectOptions.InternalConfigurationScanDirectories) + .SelectMany(u => + Directory.GetFiles(u, "*.json", SearchOption.TopDirectoryOnly)); + + // 如果没有配置文件,中止执行 + if (!jsonFiles.Any()) return; + + // 获取环境变量名,如果没找到,则读取 NETCORE_ENVIRONMENT 环境变量信息识别(用于非 Web 环境) + var envName = hostEnvironment?.EnvironmentName ?? Environment.GetEnvironmentVariable("NETCORE_ENVIRONMENT") ?? "Unknown"; + + // 读取忽略的配置文件 + var ignoreConfigurationFiles = (configuration.GetSection("IgnoreConfigurationFiles") + .Get() + ?? Array.Empty()).Concat(InjectOptions.InternalIgnoreConfigurationFiles); + + // 处理控制台应用程序 + var _excludeJsonPrefixs = hostEnvironment == default ? excludeJsonPrefixs.Where(u => !u.Equals("appsettings")) : excludeJsonPrefixs; + + // 将所有文件进行分组 + var jsonFilesGroups = SplitConfigFileNameToGroups(jsonFiles) + .Where(u => !_excludeJsonPrefixs.Contains(u.Key, StringComparer.OrdinalIgnoreCase) && !u.Any(c => runtimeJsonSuffixs.Any(z => c.EndsWith(z, StringComparison.OrdinalIgnoreCase)) || ignoreConfigurationFiles.Contains(Path.GetFileName(c), StringComparer.OrdinalIgnoreCase) || ignoreConfigurationFiles.Any(i => new Matcher().AddInclude(i).Match(Path.GetFileName(c)).HasMatches))); + + // 遍历所有配置分组 + foreach (var group in jsonFilesGroups) + { + // 限制查找的 json 文件组 + var limitFileNames = new[] { $"{group.Key}.json", $"{group.Key}.{envName}.json" }; + + // 查找默认配置和环境配置 + var files = group.Where(u => limitFileNames.Contains(Path.GetFileName(u), StringComparer.OrdinalIgnoreCase)) + .OrderBy(u => Path.GetFileName(u).Length); + + // 循环加载 + foreach (var jsonFile in files) + { + configurationBuilder.AddJsonFile(jsonFile, optional: true, reloadOnChange: true); + } + } + } + + /// + /// 排除的配置文件前缀 + /// + private static readonly string[] excludeJsonPrefixs = new[] { "appsettings", "bundleconfig", "compilerconfig" }; + + /// + /// 排除运行时 Json 后缀 + /// + private static readonly string[] runtimeJsonSuffixs = new[] + { + "deps.json", + "runtimeconfig.dev.json", + "runtimeconfig.prod.json", + "runtimeconfig.json", + "staticwebassets.runtime.json" + }; + + /// + /// 对配置文件名进行分组 + /// + /// + /// + private static IEnumerable> SplitConfigFileNameToGroups(IEnumerable configFiles) + { + // 分组 + return configFiles.GroupBy(Function); + + // 本地函数 + static string Function(string file) + { + // 根据 . 分隔 + var fileNameParts = Path.GetFileName(file).Split('.', StringSplitOptions.RemoveEmptyEntries); + if (fileNameParts.Length == 2) return fileNameParts[0]; + + return string.Join('.', fileNameParts.Take(fileNameParts.Length - 2)); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Native.cs b/src/Admin/ThingsGateway.Furion/App/Native.cs new file mode 100644 index 000000000..8680ab38b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Native.cs @@ -0,0 +1,152 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +using System.Net.NetworkInformation; +using System.Reflection; +using System.Security.Cryptography; + +using ThingsGateway; + +namespace System; + +/// +/// 用于原生应用(WinForm/WPF)创建窗口 +/// +[SuppressSniffer] +public static class Native +{ + /// + /// 创建原生应用(WinForm/WPF)窗口 + /// + /// + /// + /// + public static TWindow CreateInstance(params object[] parameters) + where TWindow : class + { + return CreateInstance(typeof(TWindow), parameters) as TWindow; + } + + /// + /// 创建原生应用(WinForm/WPF)组件窗口 + /// + /// + /// + /// + public static object CreateInstance(Type windowType, params object[] parameters) + { + // 获取构造函数 + var constructors = windowType.GetConstructors(BindingFlags.Public | BindingFlags.Instance); + + // 如果构造函数为空,则直接创建返回 + if (constructors.Length == 0) return Activator.CreateInstance(windowType); + + // 检查是否包含多个公开构造函数 + if (constructors.Length > 1) throw new InvalidOperationException($"Multiple constructors accepting all given argument types have been found in type '{windowType.Namespace}.{windowType.Name}'. There should only be one applicable constructor."); + + // 获取唯一构造函数参数 + var parameterInfos = constructors[0].GetParameters(); + + // 准备构造函数参数 + var ctorParameters = new List(); + + // 创建服务作用域 +#pragma warning disable CA2000 // 丢失范围之前释放对象 + var serviceScope = App.RootServices.CreateScope(); +#pragma warning restore CA2000 // 丢失范围之前释放对象 + + // 遍历构造函数参数 + for (var i = 0; i < parameterInfos.Length; i++) + { + var parameterInfo = parameterInfos[i]; + + var serviceType = parameterInfo.ParameterType; + object serviceInstance; + + // 获取服务注册生命周期 + var serviceLifetime = App.GetServiceLifetime(serviceType); + + // 如果构造函数不是服务类型,则直接跳出 + if (serviceLifetime == null) break; + + // 如果是单例,直接从根服务解析 + if (serviceLifetime == ServiceLifetime.Singleton) + { + serviceInstance = App.RootServices.GetService(serviceType); + } + // 否则通过作用域解析 + else + { + serviceInstance = serviceScope.ServiceProvider.GetService(serviceType); + } + + ctorParameters.Add(serviceInstance); + } + + // 创建窗口实例 + var windowInstance = Activator.CreateInstance(windowType, ctorParameters.Concat(parameters).ToArray()); + + // 获取 Owner 属性并绑定关闭事件 + var ownerProperty = windowType.GetProperty("Owner", BindingFlags.Instance | BindingFlags.Public); + if (ownerProperty != null + && (ownerProperty.PropertyType.FullName.StartsWith("System.Windows.Forms.Form") + || ownerProperty.PropertyType.FullName.StartsWith("System.Windows.Window"))) + { + var propertyType = ownerProperty.PropertyType; + + // 监听窗口关闭事件 + void ClosedHandler(object s, EventArgs e) + { + // 释放作用域 + serviceScope.Dispose(); + } + + var closedEventInfo = windowType.GetEvent("Closed", BindingFlags.Instance | BindingFlags.Public); + closedEventInfo.AddEventHandler(windowInstance, new EventHandler((Action)ClosedHandler)); + } + + return windowInstance; + } + + private static readonly object _portLock = new(); + + /// + /// 获取一个空闲端口 + /// + /// + public static int GetIdlePort() + { + const int fromPort = 10000; + const int toPort = 65535; + + do + { + lock (_portLock) + { + var randomPort = RandomNumberGenerator.GetInt32(fromPort, toPort + 1); + if (!IsPortInUse(randomPort)) + { + return randomPort; + } + } + + // 减少CPU资源消耗 + Thread.Sleep(10); + } while (true); + } + + private static bool IsPortInUse(int port) + { + return IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners().Any(p => p.Port == port); + } +} diff --git a/src/Admin/ThingsGateway.Furion/App/Options/AppSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/App/Options/AppSettingsOptions.cs new file mode 100644 index 000000000..fd9c11d90 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Options/AppSettingsOptions.cs @@ -0,0 +1,79 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway; + +/// +/// 应用全局配置 +/// +public sealed class AppSettingsOptions : IConfigurableOptions +{ + /// + /// 集成 MiniProfiler 组件 + /// + public bool? InjectMiniProfiler { get; set; } + + /// + /// 是否启用规范化文档 + /// + public bool? InjectSpecificationDocument { get; set; } + + /// + /// 是否启用引用程序集扫描 + /// + public bool? EnabledReferenceAssemblyScan { get; set; } + + /// + /// 外部程序集 + /// + /// 扫描 dll 文件,如果是单文件发布,需拷贝放在根目录下 + public string[] ExternalAssemblies { get; set; } + + /// + /// 排除扫描的程序集 + /// + public string[] ExcludeAssemblies { get; set; } + + /// + /// 配置支持的包前缀名 + /// + public string[] SupportPackageNamePrefixs { get; set; } + + /// + /// 【部署】二级虚拟目录 + /// + public string VirtualPath { get; set; } + + /// + /// 后期配置 + /// + /// + /// + public void PostConfigure(AppSettingsOptions options, IConfiguration configuration) + { + // 非 Web 环境总是 false,如果是生产环境且不配置 InjectMiniProfiler,默认总是false,MiniProfiler 生产环境耗内存 + if (App.WebHostEnvironment == default + || (App.HostEnvironment.IsProduction() && options.InjectMiniProfiler == null)) options.InjectMiniProfiler = false; + else options.InjectMiniProfiler ??= true; + + options.InjectSpecificationDocument ??= true; + options.EnabledReferenceAssemblyScan ??= false; + options.ExternalAssemblies ??= Array.Empty(); + options.ExcludeAssemblies ??= Array.Empty(); + options.SupportPackageNamePrefixs ??= Array.Empty(); + options.VirtualPath ??= string.Empty; + } +} diff --git a/src/Admin/ThingsGateway.Furion/App/Options/GenericRunOptions.cs b/src/Admin/ThingsGateway.Furion/App/Options/GenericRunOptions.cs new file mode 100644 index 000000000..b3be52dc7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Options/GenericRunOptions.cs @@ -0,0 +1,189 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using ThingsGateway; + +namespace System; + +/// +/// 泛型主机方式配置选项 +/// +[SuppressSniffer] +public class GenericRunOptions : IRunOptions +{ + /// + /// 内部构造函数 + /// + internal GenericRunOptions() + { + } + + /// + /// 默认配置 + /// + public static GenericRunOptions Default { get; } = new GenericRunOptions(); + + /// + /// 默认配置(带启动参数) + /// + public static GenericRunOptions Main(string[] args) + { + return Default.WithArgs(args); + } + + /// + /// 默认配置(静默启动) + /// + public static GenericRunOptions DefaultSilence { get; } = new GenericRunOptions().Silence(); + + /// + /// 默认配置(静默启动 + 启动参数) + /// + public static GenericRunOptions MainSilence(string[] args) + { + return DefaultSilence.WithArgs(args); + } + + /// + /// 配置 + /// + /// 配置委托 + /// + public virtual GenericRunOptions ConfigureBuilder(Func configureAction) + { + ActionBuilder = configureAction; + return this; + } + + /// + /// 配置 + /// + /// + /// + public virtual GenericRunOptions ConfigureServices(Action configureAction) + { + ActionServices = configureAction; + return this; + } + + /// + /// 配置 + /// + /// + /// + public virtual GenericRunOptions ConfigureInject(Action configureAction) + { + ActionInject = configureAction; + return this; + } + + /// + /// 添加应用服务组件 + /// + /// 组件类型 + /// + public virtual GenericRunOptions AddComponent() + where TComponent : class, IServiceComponent, new() + { + ServiceComponents.Add(typeof(TComponent), null); + return this; + } + + /// + /// 添加应用服务组件 + /// + /// 组件类型 + /// + /// 组件参数 + /// + public virtual GenericRunOptions AddComponent(TComponentOptions options) + where TComponent : class, IServiceComponent, new() + { + ServiceComponents.Add(typeof(TComponent), options); + return this; + } + + /// + /// 添加应用服务组件 + /// + /// 组件类型 + /// 组件参数 + /// + public virtual GenericRunOptions AddComponent(Type componentType, object options) + { + ServiceComponents.Add(componentType, options); + return this; + } + + /// + /// 标识主机静默启动 + /// + /// 不阻塞程序运行 + /// 静默启动 + /// 静默启动日志状态,默认 false + /// + public virtual GenericRunOptions Silence(bool silence = true, bool logging = false) + { + IsSilence = silence; + SilenceLogging = logging; + return this; + } + + /// + /// 设置进程启动参数 + /// + /// 启动参数 + /// + public virtual GenericRunOptions WithArgs(string[] args) + { + Args = args; + return this; + } + + /// + /// 自定义 委托 + /// + internal Func ActionBuilder { get; set; } + + /// + /// 自定义 委托 + /// + internal Action ActionServices { get; set; } + + /// + /// 自定义 委托 + /// + internal Action ActionInject { get; set; } + + /// + /// 应用服务组件 + /// + internal Dictionary ServiceComponents { get; set; } = new(); + + /// + /// 静默启动 + /// + /// 不阻塞程序运行 + internal bool IsSilence { get; private set; } + + /// + /// 启用静默启动日志 + /// + internal bool SilenceLogging { get; set; } + + /// + /// 命令行参数 + /// + internal string[] Args { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Options/IRunOptions.cs b/src/Admin/ThingsGateway.Furion/App/Options/IRunOptions.cs new file mode 100644 index 000000000..8bb13a527 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Options/IRunOptions.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace System; + +/// +/// Serve.Run 方式配置参数依赖接口 +/// +public interface IRunOptions +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Options/LegacyRunOptions.cs b/src/Admin/ThingsGateway.Furion/App/Options/LegacyRunOptions.cs new file mode 100644 index 000000000..67e7a69d8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Options/LegacyRunOptions.cs @@ -0,0 +1,264 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using ThingsGateway; + +namespace System; + +/// +/// Web 泛型主机方式配置选项 +/// +[SuppressSniffer] +public sealed class LegacyRunOptions : GenericRunOptions +{ + /// + /// 内部构造函数 + /// + internal LegacyRunOptions() + : base() + { + } + + /// + /// 默认配置 + /// + public static new LegacyRunOptions Default { get; } = new LegacyRunOptions(); + + /// + /// 默认配置(带启动参数) + /// + public static new LegacyRunOptions Main(string[] args) + { + return Default.WithArgs(args); + } + + /// + /// 默认配置(静默启动) + /// + public static new LegacyRunOptions DefaultSilence { get; } = new LegacyRunOptions().Silence(); + + /// + /// 默认配置(静默启动 + 启动参数) + /// + public static new LegacyRunOptions MainSilence(string[] args) + { + return DefaultSilence.WithArgs(args); + } + + /// + /// 配置 + /// + /// 配置委托 + /// + public LegacyRunOptions ConfigureWebDefaults(Func configureAction) + { + ActionWebDefaultsBuilder = configureAction; + return this; + } + + /// + /// 配置 + /// + /// 配置委托 + /// + public LegacyRunOptions ConfigureWebInject(Action configureAction) + { + ActionWebInject = configureAction; + return this; + } + + /// + /// 配置 + /// + /// 配置委托 + /// + public new LegacyRunOptions ConfigureBuilder(Func configureAction) + { + return base.ConfigureBuilder(configureAction) as LegacyRunOptions; + } + + /// + /// 配置 + /// + /// + /// + public new LegacyRunOptions ConfigureServices(Action configureAction) + { + return base.ConfigureServices(configureAction) as LegacyRunOptions; + } + + /// + /// 配置 + /// + /// 配置委托 + /// + public new LegacyRunOptions ConfigureInject(Action configureAction) + { + return base.ConfigureInject(configureAction) as LegacyRunOptions; + } + + /// + /// 添加应用服务组件 + /// + /// 组件类型 + /// + public new LegacyRunOptions AddComponent() + where TComponent : class, IServiceComponent, new() + { + return base.AddComponent() as LegacyRunOptions; + } + + /// + /// 添加应用服务组件 + /// + /// 组件类型 + /// + /// 组件参数 + /// + public new LegacyRunOptions AddComponent(TComponentOptions options) + where TComponent : class, IServiceComponent, new() + { + return base.AddComponent(options) as LegacyRunOptions; + } + + /// + /// 添加应用服务组件 + /// + /// 组件类型 + /// 组件参数 + /// + public new LegacyRunOptions AddComponent(Type componentType, object options) + { + return base.AddComponent(componentType, options) as LegacyRunOptions; + } + + /// + /// 添加应用中间件组件 + /// + /// 组件类型 + /// + public LegacyRunOptions UseComponent() + where TComponent : class, IApplicationComponent, new() + { + ApplicationComponents.Add(typeof(TComponent), null); + return this; + } + + /// + /// 添加应用中间件组件 + /// + /// 组件类型 + /// + /// 组件参数 + /// + public LegacyRunOptions UseComponent(TComponentOptions options) + where TComponent : class, IApplicationComponent, new() + { + ApplicationComponents.Add(typeof(TComponent), options); + return this; + } + + /// + /// 添加应用中间件组件 + /// + /// 组件类型 + /// 组件参数 + /// + public LegacyRunOptions UseComponent(Type componentType, object options) + { + ApplicationComponents.Add(componentType, options); + return this; + } + + /// + /// 添加 IWebHostBuilder 组件 + /// + /// 组件类型 + /// + public LegacyRunOptions AddWebComponent() + where TComponent : class, IWebComponent, new() + { + WebComponents.Add(typeof(TComponent), null); + return this; + } + + /// + /// 添加 IWebHostBuilder 组件 + /// + /// 组件类型 + /// + /// 组件参数 + /// + public LegacyRunOptions AddWebComponent(TComponentOptions options) + where TComponent : class, IWebComponent, new() + { + WebComponents.Add(typeof(TComponent), options); + return this; + } + + /// + /// 添加 IWebHostBuilder 组件 + /// + /// 组件类型 + /// 组件参数 + /// + public LegacyRunOptions AddWebComponent(Type componentType, object options) + { + WebComponents.Add(componentType, options); + return this; + } + + /// + /// 标识主机静默启动 + /// + /// 不阻塞程序运行 + /// 静默启动 + /// 静默启动日志状态,默认 false + /// + public new LegacyRunOptions Silence(bool silence = true, bool logging = false) + { + return base.Silence(silence, logging) as LegacyRunOptions; + } + + /// + /// 设置进程启动参数 + /// + /// 启动参数 + /// + public new LegacyRunOptions WithArgs(string[] args) + { + return base.WithArgs(args) as LegacyRunOptions; + } + + /// + /// 自定义 委托 + /// + internal Action ActionWebInject { get; set; } + + /// + /// 自定义 委托 + /// + internal Func ActionWebDefaultsBuilder { get; set; } + + /// + /// 应用中间件组件 + /// + internal Dictionary ApplicationComponents { get; set; } = new(); + + /// + /// IWebHostBuilder 组件 + /// + internal Dictionary WebComponents { get; set; } = new(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Options/RunOptions.cs b/src/Admin/ThingsGateway.Furion/App/Options/RunOptions.cs new file mode 100644 index 000000000..b3692c5f9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Options/RunOptions.cs @@ -0,0 +1,343 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using ThingsGateway; + +namespace System; + +/// +/// 方式配置选项 +/// +[SuppressSniffer] +public sealed class RunOptions : IRunOptions +{ + /// + /// 内部构造函数 + /// + internal RunOptions() + { + } + + + /// + /// 默认配置 + /// + public static RunOptions Default { get; } = new RunOptions(); + + /// + /// 默认配置(带启动参数) + /// + public static RunOptions Main(string[] args) + { + return Default.WithArgs(args); + } + + /// + /// 默认配置(静默启动) + /// + public static RunOptions DefaultSilence { get; } = new RunOptions().Silence(); + + /// + /// 默认配置(静默启动 + 启动参数) + /// + public static RunOptions MainSilence(string[] args) + { + return DefaultSilence.WithArgs(args); + } + + /// + /// 配置 + /// + /// + /// + public RunOptions ConfigureOptions(WebApplicationOptions options) + { + Options = options; + return this; + } + + /// + /// 配置 + /// + /// + /// + public RunOptions ConfigureBuilder(Action configureAction) + { + ActionBuilder = configureAction; + return this; + } + /// + /// 配置 + /// + /// + /// + public RunOptions ConfigureFirstActionBuilder(Action configureAction) + { + FirstActionBuilder = configureAction; + return this; + } + + + /// + /// 配置 + /// + /// + /// + public RunOptions ConfigureServices(Action configureAction) + { + ActionServices = configureAction; + return this; + } + + /// + /// 配置 + /// + /// + /// + public RunOptions ConfigureInject(Action configureAction) + { + ActionInject = configureAction; + return this; + } + + /// + /// 配置 + /// + /// 配置委托 + /// + public RunOptions Configure(Action configureAction) + { + ActionConfigure = configureAction; + return this; + } + + /// + /// 配置 + /// + /// 配置委托 + /// + public RunOptions ConfigureConfiguration(Action configureAction) + { + ActionConfigurationManager = configureAction; + return this; + } + + /// + /// 添加应用服务组件 + /// + /// 组件类型 + /// + public RunOptions AddComponent() + where TComponent : class, IServiceComponent, new() + { + ServiceComponents.Add(typeof(TComponent), null); + return this; + } + + /// + /// 添加应用服务组件 + /// + /// 组件类型 + /// + /// 组件参数 + /// + public RunOptions AddComponent(TComponentOptions options) + where TComponent : class, IServiceComponent, new() + { + ServiceComponents.Add(typeof(TComponent), options); + return this; + } + + /// + /// 添加应用服务组件 + /// + /// 组件类型 + /// 组件参数 + /// + public RunOptions AddComponent(Type componentType, object options) + { + ServiceComponents.Add(componentType, options); + return this; + } + + /// + /// 添加应用中间件组件 + /// + /// 组件类型 + /// + public RunOptions UseComponent() + where TComponent : class, IApplicationComponent, new() + { + ApplicationComponents.Add(typeof(TComponent), null); + return this; + } + + /// + /// 添加应用中间件组件 + /// + /// 组件类型 + /// + /// 组件参数 + /// + public RunOptions UseComponent(TComponentOptions options) + where TComponent : class, IApplicationComponent, new() + { + ApplicationComponents.Add(typeof(TComponent), options); + return this; + } + + /// + /// 添加应用中间件组件 + /// + /// 组件类型 + /// 组件参数 + /// + public RunOptions UseComponent(Type componentType, object options) + { + ApplicationComponents.Add(componentType, options); + return this; + } + + /// + /// 添加 WebApplicationBuilder 组件 + /// + /// 组件类型 + /// + public RunOptions AddWebComponent() + where TComponent : class, IWebComponent, new() + { + WebComponents.Add(typeof(TComponent), null); + return this; + } + + /// + /// 添加 WebApplicationBuilder 组件 + /// + /// 组件类型 + /// + /// 组件参数 + /// + public RunOptions AddWebComponent(TComponentOptions options) + where TComponent : class, IWebComponent, new() + { + WebComponents.Add(typeof(TComponent), options); + return this; + } + + /// + /// 添加 WebApplicationBuilder 组件 + /// + /// 组件类型 + /// 组件参数 + /// + public RunOptions AddWebComponent(Type componentType, object options) + { + WebComponents.Add(componentType, options); + return this; + } + + /// + /// 标识主机静默启动 + /// + /// 不阻塞程序运行 + /// 静默启动 + /// 静默启动日志状态,默认 false + /// + public RunOptions Silence(bool silence = true, bool logging = false) + { + IsSilence = silence; + SilenceLogging = logging; + return this; + } + + /// + /// 设置进程启动参数 + /// + /// 启动参数 + /// + public RunOptions WithArgs(string[] args) + { + Args = args; + return this; + } + + /// + /// + /// + internal WebApplicationOptions Options { get; set; } + + /// + /// 自定义 委托 + /// + internal Action ActionServices { get; set; } + + /// + /// 自定义 委托 + /// + internal Action FirstActionBuilder { get; set; } + + /// + /// 自定义 委托 + /// + internal Action ActionBuilder { get; set; } + + /// + /// 自定义 委托 + /// + internal Action ActionInject { get; set; } + + /// + /// 自定义 委托 + /// + internal Action ActionConfigure { get; set; } + + /// + /// 自定义 委托 + /// + internal Action ActionConfigurationManager { get; set; } + + /// + /// 应用服务组件 + /// + internal Dictionary ServiceComponents { get; set; } = new(); + + /// + /// WebApplicationBuilder 组件 + /// + internal Dictionary WebComponents { get; set; } = new(); + + /// + /// 应用中间件组件 + /// + internal Dictionary ApplicationComponents { get; set; } = new(); + + /// + /// 静默启动 + /// + /// 不阻塞程序运行 + internal bool IsSilence { get; private set; } + + /// + /// 静默启动日志状态 + /// + internal bool SilenceLogging { get; set; } + + /// + /// 命令行参数 + /// + internal string[] Args { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Serve.cs b/src/Admin/ThingsGateway.Furion/App/Serve.cs new file mode 100644 index 000000000..477e4727c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Serve.cs @@ -0,0 +1,1012 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using System.Reflection; +using System.Runtime.Loader; + +using ThingsGateway; +using ThingsGateway.Components; +using ThingsGateway.Extension.Generic; + +namespace System; + +/// +/// 主机静态类 +/// +[SuppressSniffer] +public static class Serve +{ + /// + /// 获取一个空闲 Web 主机地址(端口) + /// + public static (string Urls, int Port, string SSL_Urls) IdleHost => GetIdleHost(); + + /// + /// 获取一个空闲 Web 主机地址(端口) + /// + /// 主机地址 + public static (string Urls, int Port, string SSL_Urls) GetIdleHost(string host = default) + { + host = string.IsNullOrWhiteSpace(host) ? "localhost" : host.Trim(); + + var port = Native.GetIdlePort(); + var urls = $"http://{host}:{port}"; + var ssl_urls = $"https://{host}:{port}"; + return (urls, port, ssl_urls); + } + + /// + /// 静默启动排除日志分类名 + /// + private static readonly string[] SilenceExcludesOfLogCategoryName = new string[] + { + "Microsoft.Hosting" + , "Microsoft.AspNetCore" + , "Microsoft.Extensions.Hosting" + }; + + /// + /// 启动原生应用(WinForm/WPF)主机 + /// + /// + /// + /// + /// + /// + public static IHost RunNative(Action additional = default + , bool includeWeb = true + , string urls = default + , string[] args = default) + { + IRunOptions runOptions = includeWeb + // 迷你 Web 主机 + ? RunOptions.Default.WithArgs(args) + .ConfigureServices(additional) + .AddComponent() + .UseComponent() + // 泛型主机 + : GenericRunOptions.Default.WithArgs(args) + .ConfigureServices(additional); + + return RunNative(runOptions, urls); + } + + /// + /// 启动原生应用(WinForm/WPF)主机 + /// + /// + /// + /// + /// + /// + /// + public static async Task RunNativeAsync(Action additional = default + , bool includeWeb = true + , string urls = default + , string[] args = default + , CancellationToken cancellationToken = default) + { + IRunOptions runOptions = includeWeb + // 迷你 Web 主机 + ? RunOptions.Default.WithArgs(args) + .ConfigureServices(additional) + .AddComponent() + .UseComponent() + // 泛型主机 + : GenericRunOptions.Default.WithArgs(args) + .ConfigureServices(additional); + + return await RunNativeAsync(runOptions, urls, cancellationToken).ConfigureAwait(false); + } + + /// + /// 启动原生应用(WinForm/WPF)主机 + /// + /// + /// + /// + public static IHost RunNative(IRunOptions options, string urls = default) + { + dynamic dynamicOptions = options; + + // 动态配置静默参数 + bool isSilence = dynamicOptions.IsSilence; + IRunOptions runOptions = isSilence + ? options + : dynamicOptions.Silence(true, false); + + // 创建主机 + var host = Run(runOptions, urls); + + // 监听主机关闭 + AssemblyLoadContext.Default.Unloading += (ctx) => + { + host.StopAsync(); + host.Dispose(); + }; + + // 监听未知异常 + AppDomain.CurrentDomain.UnhandledException += (s, e) => + { + host.StopAsync(); + host.Dispose(); + }; + + return host; + } + + /// + /// 启动原生应用(WinForm/WPF)主机 + /// + /// + /// + /// + /// + public static async Task RunNativeAsync(IRunOptions options, string urls = default, CancellationToken cancellationToken = default) + { + dynamic dynamicOptions = options; + + // 动态配置静默参数 + bool isSilence = dynamicOptions.IsSilence; + IRunOptions runOptions = isSilence + ? options + : dynamicOptions.Silence(true, false); + + // 创建主机 + var host = await RunAsync(runOptions, urls, cancellationToken).ConfigureAwait(false); + + // 监听主机关闭 + AssemblyLoadContext.Default.Unloading += async (ctx) => + { + await host.StopAsync(cancellationToken).ConfigureAwait(false); + host.Dispose(); + }; + + // 监听未知异常 + AppDomain.CurrentDomain.UnhandledException += async (s, e) => + { + await host.StopAsync(cancellationToken).ConfigureAwait(false); + host.Dispose(); + }; + + return host; + } + + /// + /// 启动默认 Web 主机,含最基础的 Web 注册 + /// + /// 配置额外服务 + /// 默认 5000/5001 端口 + /// 静默启动 + /// 静默启动日志状态,默认 false + /// 启动参数 + /// + public static IHost Run(Action additional + , string urls = default + , bool silence = false + , bool logging = false + , string[] args = default) + { + return Run(urls, silence, logging, args, additional); + } + + /// + /// 启动默认 Web 主机,含最基础的 Web 注册 + /// + /// 配置额外服务 + /// 默认 5000/5001 端口 + /// 静默启动 + /// 静默启动日志状态,默认 false + /// 启动参数 + /// + /// + public static async Task RunAsync(Action additional + , string urls = default + , bool silence = false + , bool logging = false + , string[] args = default + , CancellationToken cancellationToken = default) + { + return await RunAsync(urls, silence, logging, args, additional, cancellationToken).ConfigureAwait(false); + } + + /// + /// 启动默认 Web 主机,含最基础的 Web 注册 + /// + /// 默认 5000/5001 端口 + /// 静默启动 + /// 静默启动日志状态,默认 false + /// 启动参数 + /// 配置额外服务 + /// + public static IHost Run(string urls = default + , bool silence = false + , bool logging = false + , string[] args = default + , Action additional = default) + { + return Run(RunOptions.Default + .WithArgs(args) + .Silence(silence, logging) + .ConfigureServices(additional) + .AddComponent() + .UseComponent(), urls); + } + + /// + /// 启动默认 Web 主机,含最基础的 Web 注册 + /// + /// 默认 5000/5001 端口 + /// 静默启动 + /// 静默启动日志状态,默认 false + /// 启动参数 + /// 配置额外服务 + /// + /// + public static async Task RunAsync(string urls = default + , bool silence = false + , bool logging = false + , string[] args = default + , Action additional = default + , CancellationToken cancellationToken = default) + { + return await RunAsync(RunOptions.Default + .WithArgs(args) + .Silence(silence, logging) + .ConfigureServices(additional) + .AddComponent() + .UseComponent(), urls, cancellationToken).ConfigureAwait(false); + } + + /// + /// 启动主机 + /// + /// 通用方法 + /// + /// + /// + public static IHost Run(IRunOptions options, string urls = default) + { + IHost host; + // .NET6+ 主机 + if (options is RunOptions runOptions) + { + host = Run(runOptions, urls); + } + // .NET6- 主机 + else if (options is LegacyRunOptions legacyRunOptions) + { + host = Run(legacyRunOptions, urls); + } + // 泛型主机 + else if (options is GenericRunOptions genericRunOptions) + { + host = Run(genericRunOptions); + } + else throw new InvalidCastException("Unsupported IRunOptions implementation type."); + + return host; + } + + /// + /// 启动主机 + /// + /// 通用方法 + /// + /// + /// + /// + public static async Task RunAsync(IRunOptions options, string urls = default, CancellationToken cancellationToken = default) + { + IHost host; + // .NET6+ 主机 + if (options is RunOptions runOptions) + { + host = await RunAsync(runOptions, urls, cancellationToken).ConfigureAwait(false); + } + // .NET6- 主机 + else if (options is LegacyRunOptions legacyRunOptions) + { + host = await RunAsync(legacyRunOptions, urls, cancellationToken).ConfigureAwait(false); + } + // 泛型主机 + else if (options is GenericRunOptions genericRunOptions) + { + host = await RunAsync(genericRunOptions, cancellationToken).ConfigureAwait(false); + } + else throw new InvalidCastException("Unsupported IRunOptions implementation type."); + + return host; + } + + /// + /// 启动通用泛型主机 + /// + /// 配置额外服务 + /// 静默启动 + /// 静默启动日志状态,默认 false + /// 启动参数 + /// + public static IHost RunGeneric(Action additional + , bool silence = false + , bool logging = false + , string[] args = default) + { + return RunGeneric(silence, logging, args, additional); + } + + /// + /// 启动通用泛型主机 + /// + /// 配置额外服务 + /// 静默启动 + /// 静默启动日志状态,默认 false + /// 启动参数 + /// + /// + public static async Task RunGenericAsync(Action additional + , bool silence = false + , bool logging = false + , string[] args = default + , CancellationToken cancellationToken = default) + { + return await RunGenericAsync(silence, logging, args, additional, cancellationToken).ConfigureAwait(false); + } + + /// + /// 启动通用泛型主机 + /// + /// 静默启动 + /// 静默启动日志状态,默认 false + /// 启动参数 + /// 配置额外服务 + /// + public static IHost RunGeneric(bool silence = false + , bool logging = false + , string[] args = default + , Action additional = default) + { + return Run(GenericRunOptions.Default + .WithArgs(args) + .Silence(silence, logging) + .ConfigureServices(services => + { + // 控制台日志美化 + services.AddConsoleFormatter(); + + // 调用自定义配置 + additional?.Invoke(services); + })); + } + + /// + /// 启动通用泛型主机 + /// + /// 静默启动 + /// 静默启动日志状态,默认 false + /// 启动参数 + /// 配置额外服务 + /// + /// + public static async Task RunGenericAsync(bool silence = false + , bool logging = false + , string[] args = default + , Action additional = default + , CancellationToken cancellationToken = default) + { + return await RunAsync(GenericRunOptions.Default + .WithArgs(args) + .Silence(silence, logging) + .ConfigureServices(services => + { + // 控制台日志美化 + services.AddConsoleFormatter(); + + // 调用自定义配置 + additional?.Invoke(services); + }), cancellationToken).ConfigureAwait(false); + } + + /// + /// 启动泛型 Web 主机 + /// + /// 未包含 Web 基础功能,需手动注册服务/中间件 + /// 配置选项 + /// 默认 5000/5001 端口 + /// + public static IHost Run(LegacyRunOptions options, string urls = default) + { + return Run(options, urls); + } + + /// + /// 启动泛型 Web 主机 + /// + /// 未包含 Web 基础功能,需手动注册服务/中间件 + /// 配置选项 + /// 默认 5000/5001 端口 + /// + /// + public static async Task RunAsync(LegacyRunOptions options, string urls = default, CancellationToken cancellationToken = default) + { + return await RunAsync(options, urls, cancellationToken).ConfigureAwait(false); + } + + /// + /// 启动泛型 Web 主机 + /// + /// 未包含 Web 基础功能,需手动注册服务/中间件 + /// 启动 Startup 类 + /// 配置选项 + /// 默认 5000/5001 端口 + /// + public static IHost Run(LegacyRunOptions options, string urls = default) + where TStartup : class + { + // 构建 IHost 对象 + BuildApplication(options, urls, out var app); + + // 是否静默启动 + if (!options.IsSilence) + { + app.Run(); + } + else + { + app.Start(); + } + + return app; + } + + /// + /// 启动泛型 Web 主机 + /// + /// 未包含 Web 基础功能,需手动注册服务/中间件 + /// 启动 Startup 类 + /// 配置选项 + /// 默认 5000/5001 端口 + /// + /// + public static async Task RunAsync(LegacyRunOptions options, string urls = default, CancellationToken cancellationToken = default) + where TStartup : class + { + // 构建 IHost 对象 + BuildApplication(options, urls, out var app); + + // 是否静默启动 + if (!options.IsSilence) + { + await app.RunAsync(cancellationToken).ConfigureAwait(false); + } + else + { + await app.StartAsync(cancellationToken).ConfigureAwait(false); + } + + return app; + } + + /// + /// 启动泛型通用主机 + /// + /// 配置选项 + /// + public static IHost Run(GenericRunOptions options) + { + // 构建 IHost 对象 + BuildApplication(options, out var app); + + // 是否静默启动 + if (!options.IsSilence) + { + app.Run(); + } + else + { + app.Start(); + } + + return app; + } + + /// + /// 启动泛型通用主机 + /// + /// 配置选项 + /// + /// + public static async Task RunAsync(GenericRunOptions options, CancellationToken cancellationToken = default) + { + // 构建 IHost 对象 + BuildApplication(options, out var app); + + // 是否静默启动 + if (!options.IsSilence) + { + await app.RunAsync(cancellationToken).ConfigureAwait(false); + } + else + { + await app.StartAsync(cancellationToken).ConfigureAwait(false); + } + + return app; + } + + /// + /// 启动 WebApplication 主机 + /// + /// 未包含 Web 基础功能,需手动注册服务/中间件 + /// 配置选项 + /// 默认 5000/5001 端口 + /// + public static IHost Run(RunOptions options, string urls = default) + { + // 构建 WebApplication 对象 + BuildApplication(options, urls, out var startUrls, out var app); + + // 是否静默启动 + if (!options.IsSilence) + { + // 配置启动地址和端口 + app.Run(string.IsNullOrWhiteSpace(urls) ? null : startUrls); + } + else + { + app.Start(); + } + + return app; + } + + /// + /// 启动 WebApplication 主机 + /// + /// 未包含 Web 基础功能,需手动注册服务/中间件 + /// 配置选项 + /// 默认 5000/5001 端口 + /// + /// + public static async Task RunAsync(RunOptions options, string urls = default, CancellationToken cancellationToken = default) + { + // 构建 WebApplication 对象 + BuildApplication(options, urls, out var startUrls, out var app); + + // 是否静默启动 + if (!options.IsSilence) + { + // 配置启动地址和端口 + await app.RunAsync(string.IsNullOrWhiteSpace(urls) ? null : startUrls).ConfigureAwait(false); + } + else + { + await app.StartAsync(cancellationToken).ConfigureAwait(false); + } + + return app; + } + + /// + /// 构建 WebApplication 对象 + /// + /// 配置选项 + /// 默认 5000/5001 端口 + /// Urls地址 + /// + public static void BuildApplication(RunOptions options, string urls, out string startUrls, out WebApplication app) + { + // 获取命令行参数 + var args = options.Args ?? Environment.GetCommandLineArgs().Skip(1).ToArray(); + + + + // 初始化 WebApplicationBuilder + var builder = (options.Options == null + ? WebApplication.CreateBuilder(args) + : WebApplication.CreateBuilder(options.Options)); + + + // 调用自定义配置服务 + options?.FirstActionBuilder?.Invoke(builder); + + + // 注册 WebApplicationBuilder 组件 + if (options.WebComponents.Count > 0) + { + foreach (var (componentType, opt) in options.WebComponents) + { + builder.AddWebComponent(componentType, opt); + } + } + + // 静默启动排除指定日志类名 + if (options.IsSilence && !options.SilenceLogging) + { + builder.Logging.AddFilter((provider, category, logLevel) => + { + return !SilenceExcludesOfLogCategoryName.Any(u => category.StartsWith(u)); + }); + } + + // 添加自定义配置 + options.ActionConfigurationManager?.Invoke(builder.Environment, builder.Configuration); + + // 初始化框架 + builder.Inject(options.ActionInject); + + // 注册服应用务组件 + if (options.ServiceComponents.Count > 0) + { + foreach (var (componentType, opt) in options.ServiceComponents) + { + builder.AddComponent(componentType, opt); + } + } + + // 解决部分主机不能正确读取 urls 参数命令问题 + startUrls = !string.IsNullOrWhiteSpace(urls) ? urls : builder.Configuration[nameof(urls)]; + + // 自定义启动端口(只有静默模式才这样做) + if (options.IsSilence && !string.IsNullOrWhiteSpace(startUrls)) + { + builder.WebHost.UseUrls(startUrls); + } + + // 调用自定义配置服务 + options?.ActionServices?.Invoke(builder.Services); + + // 调用自定义配置 + options?.ActionBuilder?.Invoke(builder); + + // 构建主机 + app = builder.Build(); + InternalApp.RootServices ??= app.Services; + + + + var applicationPartManager = app.Services.GetService(); + + applicationPartManager?.ApplicationParts?.RemoveWhere(p => App.BakImageNames.Any(b => b == p.Name)); + // 配置所有 Starup Configure + UseStartups(app); + UseStartups(app.Services); + + // 释放内存 + App.AppStartups.Clear(); + + // 注册应用中间件组件 + if (options.ApplicationComponents.Count > 0) + { + foreach (var (componentType, opt) in options.ApplicationComponents) + { + app.UseComponent(app.Environment, componentType, opt); + } + } + + // 调用自定义配置 + options?.ActionConfigure?.Invoke(app); + } + + /// + /// 构建 IHost 对象 + /// + /// 启动 Startup 类 + /// 配置选项 + /// 默认 5000/5001 端口 + /// + public static void BuildApplication(LegacyRunOptions options, string urls, out IHost app) + where TStartup : class + { + // 获取命令行参数 + var args = options.Args ?? Environment.GetCommandLineArgs().Skip(1).ToArray(); + + var builder = Host.CreateDefaultBuilder(args); + + // 静默启动排除指定日志类名 + if (options.IsSilence && !options.SilenceLogging) + { + builder = builder.ConfigureLogging(logging => + { + logging.AddFilter((provider, category, logLevel) => + { + return !SilenceExcludesOfLogCategoryName.Any(u => category.StartsWith(u)); + }); + }); + } + + // 配置 Web 主机 + builder = builder.ConfigureWebHostDefaults(webHostBuilder => + { + // 注册 IWebHostBuilder 组件 + if (options.WebComponents.Count > 0) + { + foreach (var (componentType, opt) in options.WebComponents) + { + webHostBuilder.AddWebComponent(componentType, opt); + } + } + + webHostBuilder = webHostBuilder.Inject(options.ActionWebInject); + + // 配置启动地址和端口 + var startUrls = !string.IsNullOrWhiteSpace(urls) ? urls : webHostBuilder.GetSetting(nameof(urls)); + + // 自定义启动端口 + if (!string.IsNullOrWhiteSpace(startUrls)) + { + webHostBuilder = webHostBuilder.UseUrls(startUrls); + } + + // 配置服务 + if (options.ServiceComponents.Count > 0) + { + webHostBuilder = webHostBuilder.ConfigureServices(services => + { + // 注册应用服务组件 + foreach (var (componentType, opt) in options.ServiceComponents) + { + services.AddComponent(componentType, opt); + } + }); + } + + // 配置中间件 + if (options.ApplicationComponents.Count > 0) + { + webHostBuilder = webHostBuilder.Configure((context, app) => + { + // 注册应用中间件组件 + foreach (var (componentType, opt) in options.ApplicationComponents) + { + app.UseComponent(context.HostingEnvironment, componentType, opt); + } + }); + } + + // 解决 .NET5 项目必须配置 Startup 问题 + if (typeof(TStartup) != typeof(FakeStartup)) + { + webHostBuilder = webHostBuilder.UseStartup(); + } + + // 调用自定义配置 + webHostBuilder = options?.ActionWebDefaultsBuilder?.Invoke(webHostBuilder) ?? webHostBuilder; + }); + + builder = builder.ConfigureServices(services => + { + // 调用自定义配置服务 + options?.ActionServices?.Invoke(services); + }); + + // 调用自定义配置 + builder = options?.ActionBuilder?.Invoke(builder) ?? builder; + + // 构建主机 + app = builder.Build(); + InternalApp.RootServices ??= app.Services; + + var applicationPartManager = app.Services.GetService(); + + applicationPartManager?.ApplicationParts?.RemoveWhere(p => App.BakImageNames.Any(b => b == p.Name)); + // 配置所有 Starup Configure + UseStartups(app.Services); + // 释放内存 + App.AppStartups.Clear(); + } + + /// + /// 构建 IHost 对象 + /// + /// 配置选项 + /// + public static void BuildApplication(GenericRunOptions options, out IHost app) + { + // 获取命令行参数 + var args = options.Args ?? Environment.GetCommandLineArgs().Skip(1).ToArray(); + + var builder = Host.CreateDefaultBuilder(args); + + // 静默启动排除指定日志类名 + if (options.IsSilence && !options.SilenceLogging) + { + builder = builder.ConfigureLogging(logging => + { + logging.AddFilter((provider, category, logLevel) => + { + return !SilenceExcludesOfLogCategoryName.Any(u => category.StartsWith(u)); + }); + }); + } + + // 初始化框架 + builder = builder.Inject(options.ActionInject); + + // 配置服务 + if (options.ServiceComponents.Count > 0) + { + builder = builder.ConfigureServices(services => + { + // 注册应用服务组件 + foreach (var (componentType, opt) in options.ServiceComponents) + { + services.AddComponent(componentType, opt); + } + }); + } + + builder = builder.ConfigureServices(services => + { + // 调用自定义配置服务 + options?.ActionServices?.Invoke(services); + }); + + // 调用自定义配置 + builder = options?.ActionBuilder?.Invoke(builder) ?? builder; + + // 构建主机 + app = builder.Build(); + InternalApp.RootServices ??= app.Services; + + + var applicationPartManager = app.Services.GetService(); + applicationPartManager?.ApplicationParts?.RemoveWhere(p => App.BakImageNames.Any(b => b == p.Name)); + + // 配置所有 Starup Configure + UseStartups(app.Services); + // 释放内存 + App.AppStartups.Clear(); + } + + + + /// + /// 配置 Startup 的 Configure + /// + /// 应用构建器 + private static void UseStartups(IServiceProvider serviceProvider) + { + // 反转,处理排序 + var startups = App.AppStartups.Reverse(); + if (!startups.Any()) return; + + UseStartups(startups, serviceProvider); + } + /// + /// 配置 Startup 的 Configure + /// + /// 应用构建器 + private static void UseStartups(IApplicationBuilder app) + { + + // 反转,处理排序 + var startups = App.AppStartups.Reverse(); + if (!startups.Any()) return; + + // 处理【部署】二级虚拟目录 + var virtualPath = App.Settings.VirtualPath; + if (!string.IsNullOrWhiteSpace(virtualPath) && virtualPath.StartsWith('/')) + { + app.Map(virtualPath, _app => UseStartups(startups, _app)); + return; + } + + UseStartups(startups, app); + } + /// + /// 批量将自定义 AppStartup 添加到 Startup.cs 的 Configure 中 + /// + /// + /// + private static void UseStartups(IEnumerable startups, IServiceProvider serviceProvider) + { + // 遍历所有 + foreach (var startup in startups) + { + var type = startup.GetType(); + + // 获取所有符合依赖注入格式的方法,如返回值 void,且第一个参数是 IServiceProvider 类型 + var configureMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(u => u.ReturnType == typeof(void) + && u.GetParameters().Length > 0 + && u.GetParameters().First().ParameterType == typeof(IServiceProvider)); + + if (!configureMethods.Any()) continue; + + // 自动安装属性调用 + foreach (var method in configureMethods) + { + method.Invoke(startup, ResolveMethodParameterInstances(serviceProvider, method)); + } + } + + } + /// + /// 批量将自定义 AppStartup 添加到 Startup.cs 的 Configure 中 + /// + /// + /// + private static void UseStartups(IEnumerable startups, IApplicationBuilder app) + { + // 遍历所有 + foreach (var startup in startups) + { + var type = startup.GetType(); + + // 获取所有符合依赖注入格式的方法,如返回值 void,且第一个参数是 IApplicationBuilder 类型 + var configureMethods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Where(u => u.ReturnType == typeof(void) + && u.GetParameters().Length > 0 + && u.GetParameters().First().ParameterType == typeof(IApplicationBuilder)); + + if (!configureMethods.Any()) continue; + + // 自动安装属性调用 + foreach (var method in configureMethods) + { + method.Invoke(startup, ResolveMethodParameterInstances(app, method)); + } + } + + } + /// + /// 解析方法参数实例 + /// + /// + /// + /// + private static object[] ResolveMethodParameterInstances(IServiceProvider serviceProvider, MethodInfo method) + { + // 获取方法所有参数 + var parameters = method.GetParameters(); + var parameterInstances = new object[parameters.Length]; + parameterInstances[0] = serviceProvider; + + // 解析服务 + for (var i = 1; i < parameters.Length; i++) + { + var parameter = parameters[i]; + parameterInstances[i] = serviceProvider.GetRequiredService(parameter.ParameterType); + } + + return parameterInstances; + } + /// + /// 解析方法参数实例 + /// + /// + /// + /// + private static object[] ResolveMethodParameterInstances(IApplicationBuilder app, MethodInfo method) + { + // 获取方法所有参数 + var parameters = method.GetParameters(); + var parameterInstances = new object[parameters.Length]; + parameterInstances[0] = app; + + // 解析服务 + for (var i = 1; i < parameters.Length; i++) + { + var parameter = parameters[i]; + parameterInstances[i] = app.ApplicationServices.GetRequiredService(parameter.ParameterType); + } + + return parameterInstances; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/ServeComponent.cs b/src/Admin/ThingsGateway.Furion/App/ServeComponent.cs new file mode 100644 index 000000000..c3b504a61 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/ServeComponent.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using System.Text.Encodings.Web; + +namespace ThingsGateway.Components; + +/// +/// Serve 组件应用服务组件 +/// +[SuppressSniffer] +public sealed class ServeServiceComponent : IServiceComponent +{ + /// + /// 装载服务 + /// + /// + /// + /// + public void Load(IServiceCollection services, ComponentContext componentContext) + { + // 控制台日志美化 + services.AddConsoleFormatter(); + + // 配置跨域 + services.AddCorsAccessor(); + + // 控制器和规范化结果 + services.AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping; + }) + .AddInjectWithUnifyResult(); + } +} + +/// +/// Serve 组件应用中间件组件 +/// +[SuppressSniffer] +public sealed class ServeApplicationComponent : IApplicationComponent +{ + /// + /// 装载中间件 + /// + /// + /// + /// + public void Load(IApplicationBuilder app, IWebHostEnvironment env, ComponentContext componentContext) + { + // 配置错误页 + if (env.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + + // 401,403 规范化结果 + app.UseUnifyResultStatusCodes(); + + // 配置静态 + app.UseStaticFiles(); + + // 注册定时任务 UI + app.UseScheduleUI(); + + // 配置路由 + app.UseRouting(); + + // 配置跨域 + app.UseCorsAccessor(); + + // 配置授权 + app.UseAuthentication(); + app.UseAuthorization(); + + // 框架基础配置 + app.UseInject(string.Empty); + + // 配置路由 + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/SingleFile/ISingleFilePublish.cs b/src/Admin/ThingsGateway.Furion/App/SingleFile/ISingleFilePublish.cs new file mode 100644 index 000000000..02fd9b533 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/SingleFile/ISingleFilePublish.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway; + +/// +/// 解决单文件发布程序集扫描问题 +/// +public interface ISingleFilePublish +{ + /// + /// 包含程序集数组 + /// + /// 配置单文件发布扫描程序集 + /// + Assembly[] IncludeAssemblies(); + + /// + /// 包含程序集名称数组 + /// + /// 配置单文件发布扫描程序集名称 + /// + string[] IncludeAssemblyNames(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Startups/AppStartup.cs b/src/Admin/ThingsGateway.Furion/App/Startups/AppStartup.cs new file mode 100644 index 000000000..4530d4bbd --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Startups/AppStartup.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +/// 依赖注入全局模块 +/// +[SuppressSniffer] +public abstract class AppStartup +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Startups/FakeStartup.cs b/src/Admin/ThingsGateway.Furion/App/Startups/FakeStartup.cs new file mode 100644 index 000000000..d2060f4e7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Startups/FakeStartup.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +#pragma warning disable CA1822 // 将成员标记为 static + +namespace ThingsGateway; + +/// +/// 模拟 Startup,解决 .NET5 下不设置 UseStartup 时出现异常问题 +/// +[SuppressSniffer] +public sealed class FakeStartup +{ + /// + /// 配置服务 + /// + public void ConfigureServices(IServiceCollection _) + { + } + + /// + /// 配置请求 + /// + public void Configure(IApplicationBuilder _) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Startups/GenericHostLifetimeEventsHostedService.cs b/src/Admin/ThingsGateway.Furion/App/Startups/GenericHostLifetimeEventsHostedService.cs new file mode 100644 index 000000000..765c36488 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Startups/GenericHostLifetimeEventsHostedService.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway; + +namespace Microsoft.Extensions.Hosting; + +/// +/// 监听泛型主机启动事件 +/// +internal sealed class GenericHostLifetimeEventsHostedService : IHostedService +{ + /// + /// 构造函数 + /// + /// + public GenericHostLifetimeEventsHostedService(IHost host) + { + // 存储根服务 + InternalApp.RootServices ??= host.Services; + } + + /// + /// 监听主机启动 + /// + /// + /// + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + /// + /// 监听主机停止 + /// + /// + /// + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/App/Startups/HostingStartup.cs b/src/Admin/ThingsGateway.Furion/App/Startups/HostingStartup.cs new file mode 100644 index 000000000..e4fe0b7f0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/App/Startups/HostingStartup.cs @@ -0,0 +1,32 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Hosting; + +[assembly: HostingStartup(typeof(ThingsGateway.HostingStartup))] + +namespace ThingsGateway; + +/// +/// 配置程序启动时自动注入 +/// +[SuppressSniffer] +public sealed class HostingStartup : IHostingStartup +{ + /// + /// 配置应用启动 + /// + /// + public void Configure(IWebHostBuilder builder) + { + InternalApp.ConfigureApplication(builder); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/AspNetCoreBuilderServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/AspNetCoreBuilderServiceCollectionExtensions.cs new file mode 100644 index 000000000..66eeb2537 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/AspNetCoreBuilderServiceCollectionExtensions.cs @@ -0,0 +1,132 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +using System.Collections.Concurrent; + +using ThingsGateway; +using ThingsGateway.AspNetCore; +using ThingsGateway.SensitiveDetection; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// ASP.NET Core 服务拓展类 +/// +[SuppressSniffer] +public static class AspNetCoreBuilderServiceCollectionExtensions +{ + /// + /// 注册 Mvc 过滤器 + /// + /// + /// + /// + /// + public static IMvcBuilder AddMvcFilter(this IMvcBuilder mvcBuilder, Action configure = default) + where TFilter : IFilterMetadata + { + mvcBuilder.Services.AddMvcFilter(configure); + + return mvcBuilder; + } + + /// + /// 注册 Mvc 过滤器 + /// + /// + /// + /// + /// + public static IServiceCollection AddMvcFilter(this IServiceCollection services, Action configure = default) + where TFilter : IFilterMetadata + { + // 非 Web 环境跳过注册 + if (App.WebHostEnvironment == default) return services; + + services.Configure(options => + { + options.Filters.Add(); + + // 其他额外配置 + configure?.Invoke(options); + }); + + return services; + } + + /// + /// 注册 Mvc 过滤器 + /// + /// + /// + /// + /// + public static IServiceCollection AddMvcFilter(this IServiceCollection services, IFilterMetadata filter, Action configure = default) + { + // 非 Web 环境跳过注册 + if (App.WebHostEnvironment == default) return services; + + services.Configure(options => + { + options.Filters.Add(filter); + + // 其他额外配置 + configure?.Invoke(options); + }); + + return services; + } + + /// + /// 添加 [FromConvert] 模型绑定 + /// + /// + /// + /// + public static IMvcBuilder AddFromConvertBinding(this IMvcBuilder mvcBuilder, Action> configure = default) + { + mvcBuilder.Services.AddFromConvertBinding(configure); + + return mvcBuilder; + } + + /// + /// 添加 [FromConvert] 模型绑定 + /// + /// + /// + /// + public static IServiceCollection AddFromConvertBinding(this IServiceCollection services, Action> configure = default) + { + // 非 Web 环境跳过注册 + if (App.WebHostEnvironment == default) return services; + + // 定义模型绑定转换器集合 + var modelBinderConverts = new ConcurrentDictionary(); + modelBinderConverts.TryAdd(typeof(DateTime), typeof(DateTimeModelConvertBinder)); + modelBinderConverts.TryAdd(typeof(DateTimeOffset), typeof(DateTimeOffsetModelConvertBinder)); + + // 配置 Mvc 选项 + services.Configure(options => + { + // 添加模型绑定器 + options.ModelBinderProviders.Insert(0, new FromConvertBinderProvider(modelBinderConverts)); + }); + + // 调用外部方法 + configure?.Invoke(modelBinderConverts); + + return services; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/HttpContextExtensions.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/HttpContextExtensions.cs new file mode 100644 index 000000000..d0b7f4645 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/HttpContextExtensions.cs @@ -0,0 +1,211 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.Controllers; + +using System.Text; + +using ThingsGateway.FriendlyException; + +namespace Microsoft.AspNetCore.Http; + +/// +/// Http 拓展类 +/// +[SuppressSniffer] +public static class HttpContextExtensions +{ + /// + /// 获取 Action 特性 + /// + /// + /// + /// + public static TAttribute GetMetadata(this HttpContext httpContext) + where TAttribute : class + { + return httpContext.GetEndpoint()?.Metadata?.GetMetadata(); + } + + /// + /// 获取 控制器/Action 描述器 + /// + /// + /// + public static ControllerActionDescriptor GetControllerActionDescriptor(this HttpContext httpContext) + { + return httpContext.GetEndpoint()?.Metadata?.FirstOrDefault(u => u is ControllerActionDescriptor) as ControllerActionDescriptor; + } + + /// + /// 设置规范化文档自动登录 + /// + /// + /// + public static void SigninToSwagger(this HttpContext httpContext, string accessToken) + { + // 设置 Swagger 刷新自动授权 + httpContext.Response.Headers["access-token"] = accessToken; + } + + /// + /// 设置规范化文档退出登录 + /// + /// + public static void SignoutToSwagger(this HttpContext httpContext) + { + httpContext.Response.Headers["access-token"] = "invalid_token"; + } + + /// + /// 设置响应头 Tokens + /// + /// + /// + /// + public static void SetTokensOfResponseHeaders(this HttpContext httpContext, string accessToken, string refreshToken = null) + { + httpContext.Response.Headers["access-token"] = accessToken; + if (!string.IsNullOrWhiteSpace(refreshToken)) + { + httpContext.Response.Headers["x-access-token"] = refreshToken; + } + } + + /// + /// 获取本机 IPv4地址 + /// + /// + /// + public static string GetLocalIpAddressToIPv4(this HttpContext context) + { + return context.Connection.LocalIpAddress?.MapToIPv4()?.ToString(); + } + + /// + /// 获取本机 IPv6地址 + /// + /// + /// + public static string GetLocalIpAddressToIPv6(this HttpContext context) + { + return context.Connection.LocalIpAddress?.MapToIPv6()?.ToString(); + } + + /// + /// 获取远程 IPv4地址 + /// + /// + /// 是否优先取 X-Forwarded-For + /// + public static string GetRemoteIpAddressToIPv4(this HttpContext context, bool xff = false) + { + var ipv4 = context.Connection.RemoteIpAddress?.MapToIPv4()?.ToString(); + + if (xff) + { + var xForwardedFor = context.Request.Headers["X-Forwarded-For"]; + return !string.IsNullOrWhiteSpace(xForwardedFor) ? xForwardedFor : ipv4; + } + + return ipv4; + } + + /// + /// 获取远程 IPv6地址 + /// + /// + /// + public static string GetRemoteIpAddressToIPv6(this HttpContext context) + { + return context.Connection.RemoteIpAddress?.MapToIPv6()?.ToString(); + } + + /// + /// 获取完整请求地址 + /// + /// + /// + public static string GetRequestUrlAddress(this HttpRequest request) + { + return new StringBuilder() + .Append(request.Scheme) + .Append("://") + .Append(request.Host.Value) + .Append(request.PathBase) + .Append(request.Path) + .Append(request.QueryString) + .ToString(); + } + + /// + /// 获取来源地址 + /// + /// + /// + /// + public static string GetRefererUrlAddress(this HttpRequest request, string refererHeaderKey = "Referer") + { + return request.Headers[refererHeaderKey].ToString(); + } + + /// + /// 读取 Body 内容 + /// + /// + /// 需先在 Startup 的 Configure 中注册 app.EnableBuffering() + /// + public static async Task ReadBodyContentAsync(this HttpContext httpContext) + { + if (httpContext == null) return default; + return await httpContext.Request.ReadBodyContentAsync().ConfigureAwait(false); + } + + /// + /// 读取 Body 内容 + /// + /// + /// 需先在 Startup 的 Configure 中注册 app.EnableBuffering() + /// + public static async Task ReadBodyContentAsync(this HttpRequest request) + { + request.Body.Seek(0, SeekOrigin.Begin); + + using var reader = new StreamReader(request.Body, Encoding.UTF8, true, 1024, true); + var body = await reader.ReadToEndAsync().ConfigureAwait(false); + + // 回到顶部,解决此类问题 https://gitee.com/dotnetchina/Furion/issues/I6NX9E + request.Body.Seek(0, SeekOrigin.Begin); + return body; + } + + /// + /// 将 写入响应流中 + /// + /// + /// + /// + /// + public static async ValueTask WriteAsync(this HttpResponse httpResponse, BadPageResult badPageResult, CancellationToken cancellationToken = default) + { + await httpResponse.Body.WriteAsync(badPageResult.ToByteArray(), cancellationToken).ConfigureAwait(false); + } + + /// + /// 判断是否是 WebSocket 请求 + /// + /// + /// + public static bool IsWebSocketRequest(this HttpContext context) + { + return context.WebSockets.IsWebSocketRequest || context.Request.Path == "/ws"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/IHostExtensions.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/IHostExtensions.cs new file mode 100644 index 000000000..8343a9188 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/IHostExtensions.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Hosting; + +/// +/// IHost 主机拓展类 +/// +public static class IHostExtensions +{ + /// + /// 获取主机启动地址 + /// + /// + /// + public static IEnumerable GetServerAddresses(this IHost host) + { + var server = host.Services.GetRequiredService(); + var addressesFeature = server.Features.Get(); + return addressesFeature?.Addresses; + } + + /// + /// 获取主机启动地址 + /// + /// + /// + public static string GetServerAddress(this IHost host) + { + return host.GetServerAddresses()?.FirstOrDefault(); + } + + /// + /// 获取主机启动地址 + /// + /// + /// + public static IEnumerable GetServerAddresses(this IServer server) + { + var addressesFeature = server.Features.Get(); + return addressesFeature?.Addresses; + } + + /// + /// 获取主机启动地址 + /// + /// + /// + public static string GetServerAddress(this IServer server) + { + return server.GetServerAddresses()?.FirstOrDefault(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/ModelBindingContextExtensions.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/ModelBindingContextExtensions.cs new file mode 100644 index 000000000..e40b4a097 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/Extensions/ModelBindingContextExtensions.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding; + +/// +/// 拓展 +/// +[SuppressSniffer] +public static class ModelBindingContextExtensions +{ + /// + /// 解析默认模型绑定 + /// + /// + /// + /// + public static async Task DefaultAsync(this ModelBindingContext bindingContext, Action configure = default) + { + // 判断模型是否已经设置 + if (bindingContext.Result.IsModelSet) return; + + // 获取绑定信息 + var bindingInfo = bindingContext.ActionContext.ActionDescriptor.Parameters.First(u => u.Name == bindingContext.OriginalModelName).BindingInfo; + + // 创建模型元数据 + var modelMetadata = bindingContext.ModelMetadata.GetMetadataForType(bindingContext.ModelType); + + // 获取模型绑定工厂对象 + var modelBinderFactory = bindingContext.HttpContext.RequestServices.GetRequiredService(); + + // 创建默认模型绑定器 + var modelBinder = modelBinderFactory.CreateBinder(new ModelBinderFactoryContext + { + BindingInfo = bindingInfo, + Metadata = modelMetadata + }); + + // 调用默认模型绑定器 + await modelBinder.BindModelAsync(bindingContext).ConfigureAwait(false); + + // 处理回调 + configure?.Invoke(bindingContext); + + // 确保数据验证正常运行 + bindingContext.ValidationState[bindingContext.Result.Model] = new ValidationStateEntry + { + Metadata = bindingContext.ModelMetadata, + }; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Attributes/FlexibleArrayAttribute.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Attributes/FlexibleArrayAttribute.cs new file mode 100644 index 000000000..4954ae9bf --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Attributes/FlexibleArrayAttribute.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; + +namespace ThingsGateway.AspNetCore; + +/// +/// 数组 URL 地址参数模型绑定特性 +/// +[SuppressSniffer] +public sealed class FlexibleArrayAttribute : ModelBinderAttribute +{ + /// + /// 构造函数 + /// + public FlexibleArrayAttribute() + : base(typeof(FlexibleArrayModelBinder)) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Attributes/FromConvertAttribute.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Attributes/FromConvertAttribute.cs new file mode 100644 index 000000000..bb9ab1a26 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Attributes/FromConvertAttribute.cs @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 自定义参数绑定转换特性 +/// +/// 供模型绑定使用 +[SuppressSniffer, AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] +public sealed class FromConvertAttribute : Attribute +{ + /// + /// 是否允许空字符串 + /// + public bool AllowStringEmpty { get; set; } = false; + + /// + /// 模型转换绑定器 + /// + public Type ModelConvertBinder { get; set; } + + /// + /// 额外数据 + /// + public object Extras { get; set; } + + /// + /// 完全自定义 + /// + /// 框架内部不做任何处理 + public bool Customize { get; set; } = false; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/FlexibleArrayModelBinder.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/FlexibleArrayModelBinder.cs new file mode 100644 index 000000000..1c6ef3e8b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/FlexibleArrayModelBinder.cs @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.ModelBinding; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.AspNetCore; + +/// +/// 数组 URL 地址参数模型绑定 +/// +internal sealed class FlexibleArrayModelBinder : IModelBinder +{ + /// + public Task BindModelAsync(ModelBindingContext bindingContext) + { + // 空检查 + if (bindingContext == null) throw new ArgumentNullException(nameof(bindingContext)); + + // 获取模型名和类型 + var modelName = bindingContext.ModelName; + var modelType = bindingContext.ModelType; + + // 获取 URL 参数集合 + var queryCollection = bindingContext.HttpContext.Request.Query; + + // 尝试从 status[] 获取值 + if (queryCollection.ContainsKey(modelName + "[]")) + { + var values = ConvertValues(queryCollection[modelName + "[]"], modelType); + bindingContext.Result = ModelBindingResult.Success(values); + + return Task.CompletedTask; + } + + // 尝试从 status 获取逗号分隔的值 + var commaSeparatedValue = queryCollection[modelName]; + if (!string.IsNullOrEmpty(commaSeparatedValue)) + { + var values = ConvertValues(commaSeparatedValue.ToString().Split(',').Where(s => !string.IsNullOrWhiteSpace(s)), modelType); + bindingContext.Result = ModelBindingResult.Success(values); + + return Task.CompletedTask; + } + + // 如果以上两种情况都不满足,尝试将多个 status 参数合并 + var individualValues = queryCollection[modelName]; + if (individualValues.Count > 0) + { + var values = ConvertValues(individualValues, modelType); + bindingContext.Result = ModelBindingResult.Success(values); + + return Task.CompletedTask; + } + + return Task.CompletedTask; + } + + /// + /// 转换集合类型值为模型类型值 + /// + /// + /// + /// + private static object ConvertValues(IEnumerable values, Type modelType) + { + // 处理数组类型 + if (modelType.IsArray) + { + return values.Select(u => u.ChangeType()).ToArray(); + } + // 处理 List 类型 + if (modelType.IsGenericType && modelType.GetGenericTypeDefinition() == typeof(List<>)) + { + return values.Select(u => u.ChangeType()).ToList(); + } + + // IEnumerable 类型 + return values.Select(u => u.ChangeType()); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/FromConvertBinder.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/FromConvertBinder.cs new file mode 100644 index 000000000..818382e7a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/FromConvertBinder.cs @@ -0,0 +1,144 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.Extensions.DependencyInjection; + +using System.Collections.Concurrent; + +namespace ThingsGateway.AspNetCore; + +/// +/// [FromConvert] 模型绑定器 +/// +[SuppressSniffer] +public class FromConvertBinder : IModelBinder +{ + /// + /// 定义模型绑定转换器集合 + /// + private readonly ConcurrentDictionary _modelBinderConverts; + + /// + /// 构造函数 + /// + /// 定义模型绑定转换器集合 + public FromConvertBinder(ConcurrentDictionary modelBinderConverts) + { + _modelBinderConverts = modelBinderConverts; + } + + /// + /// 绑定模型处理 + /// + /// + /// + /// + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw new ArgumentNullException(nameof(bindingContext)); + } + + // 获取参数名称 + var modelName = bindingContext.ModelName; + + // 模型绑定元数据 + var metadata = (bindingContext.ModelMetadata as DefaultModelMetadata); + + // 获取 [FromConvert] 特性 + var fromConvertAttribute = metadata.Attributes.ParameterAttributes.FirstOrDefault(u => u.GetType() == typeof(FromConvertAttribute)) as FromConvertAttribute; + + // 获取初始参数值提供器 + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + + // 是否完全自定义 + if (fromConvertAttribute.Customize != true) + { + // 如果未提供,则直接返回 + if (valueProviderResult == ValueProviderResult.None) return bindingContext.DefaultAsync(); + + // 设置模型验证信息 + bindingContext.ModelState.SetModelValue(modelName, valueProviderResult); + + // 处理空字符串问题 + if (fromConvertAttribute.AllowStringEmpty == false && string.IsNullOrEmpty(valueProviderResult.FirstValue)) return bindingContext.DefaultAsync(); + } + + // 获取转换后的值 + var (Value, ConvertBinder) = GetConvertValue(bindingContext + , metadata + , valueProviderResult + , fromConvertAttribute + , bindingContext.HttpContext.RequestServices); + if (ConvertBinder == null) return bindingContext.DefaultAsync(); + + // 如果已经自定义了 ModelBindingResult 则不再执行 + if (bindingContext.Result.IsModelSet) return Task.CompletedTask; + + // 替换模型绑定为最后值 + bindingContext.Result = ModelBindingResult.Success(Value); + + // 默认返回(必须) + return Task.CompletedTask; + } + + /// + /// 创建模型转换绑定器 + /// + /// + /// + /// + /// + private IModelConvertBinder CreateConvertBinder(Type valueType, FromConvertAttribute fromConvertAttribute, IServiceProvider serviceProvider) + { + // 解析模型绑定器 + var modelConvertBinder = fromConvertAttribute.ModelConvertBinder; + if (modelConvertBinder == null) + { + var canGet = _modelBinderConverts.TryGetValue(valueType, out var convert); + if (canGet) modelConvertBinder = convert; + } + + if (modelConvertBinder == null) return default; + + // 创建转换绑定器对象 + return ActivatorUtilities.CreateInstance(serviceProvider, modelConvertBinder) as IModelConvertBinder; + } + + /// + /// 获取转换后的值 + /// + /// + /// + /// + /// + /// + /// + private (object Value, IModelConvertBinder ConvertBinder) GetConvertValue(ModelBindingContext bindingContext + , DefaultModelMetadata metadata + , ValueProviderResult valueProviderResult + , FromConvertAttribute fromConvertAttribute + , IServiceProvider serviceProvider) + { + // 创建转换绑定器对象 + var convertBinder = CreateConvertBinder(bindingContext.ModelType, fromConvertAttribute, serviceProvider); + if (convertBinder == null) return (default, default); + + // 调用转换器 + var newValue = convertBinder.ConvertTo(bindingContext, metadata, valueProviderResult, fromConvertAttribute.Extras); + + return (newValue, convertBinder); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/FromConvertBinderProvider.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/FromConvertBinderProvider.cs new file mode 100644 index 000000000..da2dfc34b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/FromConvertBinderProvider.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +using System.Collections.Concurrent; + +using ThingsGateway.AspNetCore; + +namespace ThingsGateway.SensitiveDetection; + +/// +/// [FromConvert] 模型绑定提供器 +/// +[SuppressSniffer] +public class FromConvertBinderProvider : IModelBinderProvider +{ + /// + /// 定义模型绑定转换器集合 + /// + private readonly ConcurrentDictionary _modelBinderConverts; + + /// + /// 构造函数 + /// + /// 定义模型绑定转换器集合 + public FromConvertBinderProvider(ConcurrentDictionary modelBinderConverts) + { + _modelBinderConverts = modelBinderConverts; + } + + /// + /// 返回自定义绑定器 + /// + /// + /// + /// + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + // 判断是否定义 [FromConvert] 特性 + if (context.Metadata is DefaultModelMetadata actMetadata + && actMetadata.Attributes.ParameterAttributes != null + && actMetadata.Attributes.ParameterAttributes.Count > 0 + && actMetadata.Attributes.ParameterAttributes.Any(u => u.GetType() == typeof(FromConvertAttribute))) + { + return new FromConvertBinder(_modelBinderConverts); + } + + return null; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/TimestampToDateTimeModelBinder.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/TimestampToDateTimeModelBinder.cs new file mode 100644 index 000000000..df0f7adb0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Binders/TimestampToDateTimeModelBinder.cs @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.ModelBinding; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.AspNetCore; + +/// +/// 时间戳转 DateTime 类型模型绑定 +/// +[SuppressSniffer] +public sealed class TimestampToDateTimeModelBinder : IModelBinder +{ + /// + /// 绑定模型处理 + /// + /// + /// + /// + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw new ArgumentNullException(nameof(bindingContext)); + } + + // 获取模型名称(参数/属性/类名) + var modelName = bindingContext.ModelName; + + var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName); + if (valueProviderResult == ValueProviderResult.None) + { + return Task.CompletedTask; + } + + bindingContext.ModelState.SetModelValue(modelName, valueProviderResult); + + // 获取值 + var value = valueProviderResult.FirstValue; + + var modelType = bindingContext.ModelType; + var actType = modelType.IsGenericType && modelType.GetGenericTypeDefinition() == typeof(Nullable<>) + ? modelType.GenericTypeArguments[0] + : modelType; + + DateTime dateTime; + + try + { + // 处理时间戳 + if (long.TryParse(value, out var timestamp)) + { + dateTime = timestamp.ConvertToDateTime(); + } + else + { + dateTime = Convert.ToDateTime(value); + } + + if (actType == typeof(DateTime)) + { + bindingContext.Result = ModelBindingResult.Success(dateTime); + } + else if (actType == typeof(DateTimeOffset)) + { + bindingContext.Result = ModelBindingResult.Success(DateTime.SpecifyKind(dateTime, DateTimeKind.Utc)); + } + } + catch + { + bindingContext.ModelState.TryAddModelError(modelName, $"The value '{value}' is not valid for {modelName}."); + } + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Converts/DateTimeModelConvertBinder.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Converts/DateTimeModelConvertBinder.cs new file mode 100644 index 000000000..32905cfb1 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Converts/DateTimeModelConvertBinder.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +namespace ThingsGateway.AspNetCore; + +/// +/// 类型模型转换绑定器 +/// +[SuppressSniffer] +public sealed class DateTimeModelConvertBinder : IModelConvertBinder +{ + /// + /// 转换时间 + /// + /// + /// + /// + /// + /// + public object ConvertTo(ModelBindingContext bindingContext, DefaultModelMetadata metadata, ValueProviderResult valueProviderResult, object extras = default) + { + var value = valueProviderResult.FirstValue; + return Convert.ToDateTime(Uri.UnescapeDataString(value)); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Converts/DateTimeOffsetModelConvertBinder.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Converts/DateTimeOffsetModelConvertBinder.cs new file mode 100644 index 000000000..ec03efa45 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Converts/DateTimeOffsetModelConvertBinder.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.AspNetCore; + +/// +/// 类型模型转换绑定器 +/// +[SuppressSniffer] +public sealed class DateTimeOffsetModelConvertBinder : IModelConvertBinder +{ + /// + /// 转换时间 + /// + /// + /// + /// + /// + /// + public object ConvertTo(ModelBindingContext bindingContext, DefaultModelMetadata metadata, ValueProviderResult valueProviderResult, object extras = default) + { + var value = valueProviderResult.FirstValue; + return Convert.ToDateTime(Uri.UnescapeDataString(value)).ConvertToDateTimeOffset(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Converts/IModelConvertBinder.cs b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Converts/IModelConvertBinder.cs new file mode 100644 index 000000000..b6ae2ce9a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/AspNetCore/ModelBinders/Converts/IModelConvertBinder.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; + +namespace ThingsGateway.AspNetCore; + +/// +/// 模型转换绑定器接口 +/// +public interface IModelConvertBinder +{ + /// + /// 模型绑定转换方法 + /// + /// + /// + /// + /// + /// + object ConvertTo(ModelBindingContext bindingContext, DefaultModelMetadata metadata, ValueProviderResult valueProviderResult, object extras = default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Authorization/Attributes/AppAuthorizeAttribute.cs b/src/Admin/ThingsGateway.Furion/Authorization/Attributes/AppAuthorizeAttribute.cs new file mode 100644 index 000000000..424263ef2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Authorization/Attributes/AppAuthorizeAttribute.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Authorization; + +namespace Microsoft.AspNetCore.Authorization; + +/// +/// 策略授权特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class | AttributeTargets.Method)] +public sealed class AppAuthorizeAttribute : AuthorizeAttribute +{ + /// + /// 构造函数 + /// + /// 多个策略 + public AppAuthorizeAttribute(params string[] policies) + { + if (policies != null && policies.Length > 0) Policies = policies; + } + + /// + /// 策略 + /// + public string[] Policies + { + get + { + if (string.IsNullOrWhiteSpace(Policy)) return Array.Empty(); + + return Policy[Penetrates.AppAuthorizePrefix.Length..].Split(',', StringSplitOptions.RemoveEmptyEntries); + } + internal set => Policy = $"{Penetrates.AppAuthorizePrefix}{string.Join(',', value)}"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Authorization/Attributes/SecurityDefineAttribute.cs b/src/Admin/ThingsGateway.Furion/Authorization/Attributes/SecurityDefineAttribute.cs new file mode 100644 index 000000000..313fbd3c0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Authorization/Attributes/SecurityDefineAttribute.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.Authorization; + +/// +/// 安全定义特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public sealed class SecurityDefineAttribute : Attribute +{ + /// + /// 构造函数 + /// + public SecurityDefineAttribute() + { + } + + /// + /// 构造函数 + /// + /// + public SecurityDefineAttribute(string resourceId) + { + ResourceId = resourceId; + } + + /// + /// 资源Id,必须是唯一的 + /// + public string ResourceId { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Authorization/Extensions/AuthorizationHandlerContextExtensions.cs b/src/Admin/ThingsGateway.Furion/Authorization/Extensions/AuthorizationHandlerContextExtensions.cs new file mode 100644 index 000000000..ada3547d3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Authorization/Extensions/AuthorizationHandlerContextExtensions.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Microsoft.AspNetCore.Authorization; + +/// +/// 授权处理上下文拓展类 +/// +[SuppressSniffer] +public static class AuthorizationHandlerContextExtensions +{ + internal const string FAIL_STATUSCODE_KEY = $"{nameof(AuthorizationHandlerContext)}_FAIL_STATUSCODE"; + + /// + /// 获取当前 HttpContext 上下文 + /// + /// + /// + public static DefaultHttpContext GetCurrentHttpContext(this AuthorizationHandlerContext context) + { + DefaultHttpContext httpContext; + + // 获取 httpContext 对象 + if (context.Resource is AuthorizationFilterContext filterContext) httpContext = (DefaultHttpContext)filterContext.HttpContext; + else if (context.Resource is DefaultHttpContext defaultHttpContext) httpContext = defaultHttpContext; + else httpContext = null; + + return httpContext; + } + + /// + /// 设置授权状态码 + /// + /// + /// + public static void StatusCode(this AuthorizationHandlerContext context, int statusCode) + { + var httpContext = context.GetCurrentHttpContext(); + if (httpContext != null) + { + httpContext.Items[FAIL_STATUSCODE_KEY] = statusCode; + } + } + + /// + /// 标记授权失败并设置状态码 + /// + /// + /// + public static void Fail(this AuthorizationHandlerContext context, int statusCode) + { + context.Fail(); + context.StatusCode(statusCode); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs new file mode 100644 index 000000000..9b0df5502 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Authorization/Extensions/AuthorizationServiceCollectionExtensions.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using ThingsGateway.Authorization; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 策略授权服务拓展类 +/// +[SuppressSniffer] +public static class AuthorizationServiceCollectionExtensions +{ + /// + /// 添加策略授权服务 + /// + /// 策略授权处理程序 + /// 服务集合 + /// 自定义配置 + /// 是否启用全局授权 + /// 服务集合 + public static IServiceCollection AddAppAuthorization(this IServiceCollection services, Action configure = null, bool enableGlobalAuthorize = false) + where TAuthorizationHandler : class, IAuthorizationHandler + { + // 注册授权策略提供器 + services.TryAddSingleton(); + + // 注册策略授权处理程序 + services.TryAddSingleton(); + + //启用全局授权 + if (enableGlobalAuthorize) + { + services.Configure(options => + { + options.Filters.Add(new AuthorizeFilter()); + }); + } + + configure?.Invoke(services); + return services; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Authorization/Handlers/AppAuthorizeHandler.cs b/src/Admin/ThingsGateway.Furion/Authorization/Handlers/AppAuthorizeHandler.cs new file mode 100644 index 000000000..38ad09685 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Authorization/Handlers/AppAuthorizeHandler.cs @@ -0,0 +1,161 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using ThingsGateway.FriendlyException; +using ThingsGateway.UnifyResult; + +namespace ThingsGateway.Authorization; + +/// +/// 授权策略执行程序 +/// +[SuppressSniffer] +public abstract class AppAuthorizeHandler : IAuthorizationHandler +{ + /// + /// 刷新 Token 身份标识 + /// + private readonly string[] _refreshTokenClaims = new[] { "f", "e", "s", "l", "k" }; + + /// + /// 授权验证核心方法 + /// + /// + /// + public async Task HandleAsync(AuthorizationHandlerContext context) + { + // 获取 HttpContext 上下文 + var httpContext = context.GetCurrentHttpContext(); + + try + { + await HandleAsync(context, httpContext).ConfigureAwait(false); + } + catch (Exception exception) + { + context.Fail(); + + // 处理规范化结果 + await UnifyWrapper(httpContext, exception).ConfigureAwait(false); + } + } + + /// + /// 授权验证核心方法(可重写) + /// + /// + /// + /// + public virtual async Task HandleAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext) + { + // 判断是否授权 + var isAuthenticated = context.User.Identity.IsAuthenticated; + if (isAuthenticated) + { + // 禁止使用刷新 Token 进行单独校验 + if (_refreshTokenClaims.All(k => context.User.Claims.Any(c => c.Type == k))) + { + context.Fail(); + return; + } + + await AuthorizeHandleAsync(context).ConfigureAwait(false); + } + else context.GetCurrentHttpContext()?.SignoutToSwagger(); // 退出 Swagger 登录 + } + + /// + /// 验证管道 + /// + /// + /// + /// + public virtual Task PipelineAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext) + { + return Task.FromResult(true); + } + + /// + /// 策略验证管道 + /// + /// + /// + /// + /// + public virtual Task PolicyPipelineAsync(AuthorizationHandlerContext context, DefaultHttpContext httpContext, IAuthorizationRequirement requirement) + { + return Task.FromResult(true); + } + + /// + /// 授权处理 + /// + /// + /// + protected async Task AuthorizeHandleAsync(AuthorizationHandlerContext context) + { + // 获取所有未成功验证的需求 + var pendingRequirements = context.PendingRequirements; + + // 获取 HttpContext 上下文 + var httpContext = context.GetCurrentHttpContext(); + + // 调用子类管道 + var pipeline = await PipelineAsync(context, httpContext).ConfigureAwait(false); + if (pipeline) + { + // 通过授权验证 + foreach (var requirement in pendingRequirements) + { + // 验证策略管道 + var policyPipeline = await PolicyPipelineAsync(context, httpContext, requirement).ConfigureAwait(false); + if (policyPipeline) context.Succeed(requirement); + else context.Fail(); + } + } + else context.Fail(); + } + + /// + /// 处理规范化结果 + /// + /// + /// + /// + private static async Task UnifyWrapper(DefaultHttpContext httpContext, Exception exception) + { + // 尝试解析为友好异常 + var friendlyException = exception as AppFriendlyException; + + // 处理规范化结果 + if (!UnifyContext.CheckExceptionHttpContextNonUnify(httpContext, out var unifyRes)) + { + _ = UnifyContext.CheckVaildResult(unifyRes.OnAuthorizeException(httpContext, new ExceptionMetadata + { + StatusCode = friendlyException?.StatusCode ?? StatusCodes.Status500InternalServerError, + Errors = friendlyException?.ErrorMessage ?? exception.Message, + Data = friendlyException?.Data, + ErrorCode = friendlyException?.ErrorCode, + OriginErrorCode = friendlyException?.OriginErrorCode, + Exception = exception + }), out var data); + + // 终止返回 + httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError; + await httpContext.Response.WriteAsJsonAsync(data, App.GetOptions()?.JsonSerializerOptions).ConfigureAwait(false); + } + else throw exception; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Authorization/Internal/Penetrates.cs b/src/Admin/ThingsGateway.Furion/Authorization/Internal/Penetrates.cs new file mode 100644 index 000000000..6f70fccdc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Authorization/Internal/Penetrates.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Authorization; + +/// +/// 常量、公共方法配置类 +/// +internal static class Penetrates +{ + /// + /// 授权策略前缀 + /// + internal const string AppAuthorizePrefix = ""; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Authorization/Providers/AppAuthorizationPolicyProvider.cs b/src/Admin/ThingsGateway.Furion/Authorization/Providers/AppAuthorizationPolicyProvider.cs new file mode 100644 index 000000000..9f64e0a9e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Authorization/Providers/AppAuthorizationPolicyProvider.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace ThingsGateway.Authorization; + +/// +/// 授权策略提供器 +/// +internal sealed class AppAuthorizationPolicyProvider : IAuthorizationPolicyProvider +{ + /// + /// 默认回退策略 + /// + public DefaultAuthorizationPolicyProvider FallbackPolicyProvider { get; } + + /// + /// 构造函数 + /// + /// + public AppAuthorizationPolicyProvider(IOptions options) + { + FallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options); + } + + /// + /// 获取默认策略 + /// + /// + public Task GetDefaultPolicyAsync() + { + return FallbackPolicyProvider.GetDefaultPolicyAsync(); + } + + /// + /// 获取回退策略 + /// + /// + public Task GetFallbackPolicyAsync() + { + return FallbackPolicyProvider.GetFallbackPolicyAsync(); + } + + /// + /// 获取策略 + /// + /// + /// + public Task GetPolicyAsync(string policyName) + { + // 判断是否是包含授权策略前缀 + if (policyName.StartsWith(Penetrates.AppAuthorizePrefix)) + { + // 解析策略名并获取策略参数 + var policies = policyName[Penetrates.AppAuthorizePrefix.Length..].Split(',', StringSplitOptions.RemoveEmptyEntries); + + // 添加策略需求 + var policy = new AuthorizationPolicyBuilder(); + policy.AddRequirements(new AppAuthorizeRequirement(policies)); + + return Task.FromResult(policy.Build()); + } + + // 如果策略不匹配,则返回回退策略 + return FallbackPolicyProvider.GetPolicyAsync(policyName); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Authorization/Requirements/AppAuthorizeRequirement.cs b/src/Admin/ThingsGateway.Furion/Authorization/Requirements/AppAuthorizeRequirement.cs new file mode 100644 index 000000000..f37c2f481 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Authorization/Requirements/AppAuthorizeRequirement.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; + +namespace ThingsGateway.Authorization; + +/// +/// 策略对应的需求 +/// +[SuppressSniffer] +public sealed class AppAuthorizeRequirement : IAuthorizationRequirement +{ + /// + /// 构造函数 + /// + /// + public AppAuthorizeRequirement(params string[] policies) + { + Policies = policies; + } + + /// + /// 策略 + /// + public string[] Policies { get; private set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Components/Attributes/DependsOnAttribute.cs b/src/Admin/ThingsGateway.Furion/Components/Attributes/DependsOnAttribute.cs new file mode 100644 index 000000000..276ee3834 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Components/Attributes/DependsOnAttribute.cs @@ -0,0 +1,147 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Components; +using ThingsGateway.Reflection; + +namespace System; + +/// +/// 组件依赖配置特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)] +public sealed class DependsOnAttribute : Attribute +{ + /// + /// 依赖组件列表 + /// + private Type[] _dependComponents = Array.Empty(); + + /// + /// 连接组件列表 + /// + private Type[] _links = Array.Empty(); + + /// + /// 构造函数 + /// + public DependsOnAttribute() + { + } + + /// + /// 构造函数 + /// + /// 依赖组件列表 + /// 支持字符串类型程序集/类型配置 + public DependsOnAttribute(params object[] dependComponents) + { + var components = new List(); + + // 遍历所有依赖组件 + if (dependComponents != null && dependComponents.Length > 0) + { + foreach (var component in dependComponents) + { + // 如果是类型自动载入 + if (component is Type componentType) + { + components.Add(componentType); + } + // 处理字符串配置模式 + else if (component is string typeString) + { + components.Add(Reflect.GetStringType(typeString)); + } + else throw new InvalidOperationException("Component type can only be `Type` or `String` type of specific format."); + } + } + + DependComponents = components.ToArray(); + } + + /// + /// 依赖组件列表 + /// + public Type[] DependComponents + { + get => _dependComponents; + set + { + var components = value ?? Array.Empty(); + + // 检查类型是否实现 IComponent 接口 + foreach (var type in components) + { + if (!typeof(IComponent).IsAssignableFrom(type)) + { + throw new InvalidOperationException($"The type of `{type.FullName}` must be assignable from `{nameof(IComponent)}`."); + } + } + + _dependComponents = components; + } + } + + /// + /// 链接组件列表 + /// + public object[] Links + { + get => _links; + set + { + var components = new List(); + + // 遍历所有依赖组件 + if (value != null && value.Length > 0) + { + foreach (var component in value) + { + // 如果是类型自动载入 + if (component is Type componentType) + { + components.Add(componentType); + } + // 处理字符串配置模式 + else if (component is string typeString) + { + components.Add(Reflect.GetStringType(typeString)); + } + else throw new InvalidOperationException("Component type can only be `Type` or `String` type of specific format."); + } + } + + LinkComponents = _links = components.ToArray(); + } + } + + /// + /// 内部链接组件 + /// + internal Type[] LinkComponents + { + get => _links; + set + { + var components = value ?? Array.Empty(); + + // 检查类型是否实现 IComponent 接口 + foreach (var type in components) + { + if (!typeof(IComponent).IsAssignableFrom(type)) + { + throw new InvalidOperationException($"The type of `{type.FullName}` must be assignable from `{nameof(IComponent)}`."); + } + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Components/Contexts/ComponentContext.cs b/src/Admin/ThingsGateway.Furion/Components/Contexts/ComponentContext.cs new file mode 100644 index 000000000..d942b7066 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Components/Contexts/ComponentContext.cs @@ -0,0 +1,151 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace System; + +/// +/// 组件上下文 +/// +[SuppressSniffer] +public sealed class ComponentContext +{ + /// + /// 组件类型 + /// + public Type ComponentType { get; internal set; } + + /// + /// 上级组件上下文 + /// + public ComponentContext CalledContext { get; internal set; } + + /// + /// 根组件上下文 + /// + public ComponentContext RootContext { get; internal set; } + + /// + /// 依赖组件列表 + /// + public Type[] DependComponents { get; internal set; } + + /// + /// 链接组件列表 + /// + public Type[] LinkComponents { get; internal set; } + + /// + /// 上下文数据 + /// + private Dictionary Properties { get; set; } = new(); + + /// + /// 是否是根组件 + /// + internal bool IsRoot { get; set; } = false; + + /// + /// 设置组件属性参数 + /// + /// 派生自 + /// 组件参数 + /// + public Dictionary SetProperty(object value) + where TComponent : class, IComponent, new() + { + return SetProperty(typeof(TComponent), value); + } + + /// + /// 设置组件属性参数 + /// + /// 组件类型 + /// 组件参数 + /// + public Dictionary SetProperty(Type componentType, object value) + { + return SetProperty(componentType.FullName, value); + } + + /// + /// 设置组件属性参数 + /// + /// 键 + /// 组件参数 + /// + public Dictionary SetProperty(string key, object value) + { + if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key)); + + var properties = RootContext == null ? Properties : RootContext.Properties; + + if (!properties.TryAdd(key, value)) + { + properties[key] = value; + } + + return properties; + } + + /// + /// 获取组件属性参数 + /// + /// 派生自 + /// 组件参数类型 + /// + public TComponentOptions GetProperty() + where TComponent : class, IComponent, new() + { + return GetProperty(typeof(TComponent)); + } + + /// + /// 获取组件属性参数 + /// + /// 组件参数类型 + /// 组件类型 + /// + public TComponentOptions GetProperty(Type componentType) + { + return GetProperty(componentType.FullName); + } + + /// + /// 获取组件属性参数 + /// + /// 组件参数类型 + /// 键 + /// + public TComponentOptions GetProperty(string key) + { + if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key)); + + var properties = RootContext == null ? Properties : RootContext.Properties; + + if (properties.TryGetValue(key, out var value)) + { + return (TComponentOptions)value; + } + else + return default; + } + + /// + /// 获取组件所有依赖参数 + /// + /// + public Dictionary GetProperties() + { + return RootContext == null ? Properties : RootContext.Properties; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Components/Dependencies/IComponent.cs b/src/Admin/ThingsGateway.Furion/Components/Dependencies/IComponent.cs new file mode 100644 index 000000000..5e0016ec1 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Components/Dependencies/IComponent.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Components; + +/// +/// 组件依赖接口 +/// +public interface IComponent +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Components/Extensions/ComponentApplicationBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/Components/Extensions/ComponentApplicationBuilderExtensions.cs new file mode 100644 index 000000000..f64a3f4cb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Components/Extensions/ComponentApplicationBuilderExtensions.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Hosting; + +using ThingsGateway.Components; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// 组件应用中间件拓展类 +/// +[SuppressSniffer] +public static class ComponentApplicationBuilderExtensions +{ + /// + /// 注册依赖组件 + /// + /// 派生自 + /// + /// + /// 组件参数 + /// + public static IApplicationBuilder UseComponent(this IApplicationBuilder app, IWebHostEnvironment env, object options = default) + where TComponent : class, IApplicationComponent, new() + { + return app.UseComponent(env, options); + } + + /// + /// 注册依赖组件 + /// + /// 派生自 + /// 组件参数 + /// + /// + /// 组件参数 + /// + public static IApplicationBuilder UseComponent(this IApplicationBuilder app, IWebHostEnvironment env, TComponentOptions options = default) + where TComponent : class, IApplicationComponent, new() + { + return app.UseComponent(env, typeof(TComponent), options); + } + + /// + /// 注册依赖组件 + /// + /// + /// + /// 组件类型 + /// 组件参数 + /// + public static IApplicationBuilder UseComponent(this IApplicationBuilder app, IWebHostEnvironment env, Type componentType, object options = default) + { + // 创建组件依赖链 + var componentContextLinkList = Penetrates.CreateDependLinkList(componentType, options); + + // 逐条创建组件实例并调用 + foreach (var componentContext in componentContextLinkList) + { + // 创建组件实例 + var component = Activator.CreateInstance(componentContext.ComponentType) as IApplicationComponent; + + // 调用 + component.Load(app, env, componentContext); + } + + return app; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Components/Extensions/ComponentServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/Components/Extensions/ComponentServiceCollectionExtensions.cs new file mode 100644 index 000000000..23a4e19af --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Components/Extensions/ComponentServiceCollectionExtensions.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Components; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 组件应用服务集合拓展类 +/// +[SuppressSniffer] +public static class ComponentServiceCollectionExtensions +{ + /// + /// 注册单个组件 + /// + /// + /// + /// + /// + public static IServiceCollection AddComponent(this IServiceCollection services, object options = default) + where TComponent : class, IServiceComponent, new() + { + return services.AddComponent(options); + } + + /// + /// 注册依赖组件 + /// + /// 派生自 + /// 组件参数 + /// + /// 组件参数 + /// + public static IServiceCollection AddComponent(this IServiceCollection services, TComponentOptions options = default) + where TComponent : class, IServiceComponent, new() + { + return services.AddComponent(typeof(TComponent), options); + } + + /// + /// 注册依赖组件 + /// + /// + /// 组件类型 + /// 组件参数 + /// + public static IServiceCollection AddComponent(this IServiceCollection services, Type componentType, object options = default) + { + // 创建组件依赖链 + var componentContextLinkList = Penetrates.CreateDependLinkList(componentType, options); + + // 逐条创建组件实例并调用 + foreach (var context in componentContextLinkList) + { + // 创建组件实例 + var component = Activator.CreateInstance(context.ComponentType) as IServiceComponent; + + // 调用 + component.Load(services, context); + } + + return services; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Components/IApplicationComponent.cs b/src/Admin/ThingsGateway.Furion/Components/IApplicationComponent.cs new file mode 100644 index 000000000..98d0da7d1 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Components/IApplicationComponent.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; + +using ThingsGateway.Components; + +namespace System; + +/// +/// 应用中间件接口 +/// +public interface IApplicationComponent : IComponent +{ + /// + /// 装置中间件 + /// + /// + /// + /// 组件上下文 + void Load(IApplicationBuilder app, IWebHostEnvironment env, ComponentContext componentContext); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Components/IServiceComponent.cs b/src/Admin/ThingsGateway.Furion/Components/IServiceComponent.cs new file mode 100644 index 000000000..d4e6ca93a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Components/IServiceComponent.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +using ThingsGateway.Components; + +namespace System; + +/// +/// 服务组件依赖接口 +/// +public interface IServiceComponent : IComponent +{ + /// + /// 装载服务 + /// + /// + /// 组件上下文 + void Load(IServiceCollection services, ComponentContext componentContext); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Components/IWebComponent.cs b/src/Admin/ThingsGateway.Furion/Components/IWebComponent.cs new file mode 100644 index 000000000..001b44616 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Components/IWebComponent.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; + +using ThingsGateway.Components; + +namespace System; + +/// +/// Web 组件依赖接口 +/// +/// 注意,此时 App 还未载入 +public interface IWebComponent : IComponent +{ + /// + /// 装置 Web 应用构建器 + /// + /// 注意,此时 App 还未载入 + /// + /// 组件上下文 + void Load(WebApplicationBuilder builder, ComponentContext componentContext); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Components/Internal/Penetrates.cs b/src/Admin/ThingsGateway.Furion/Components/Internal/Penetrates.cs new file mode 100644 index 000000000..f72442a56 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Components/Internal/Penetrates.cs @@ -0,0 +1,153 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Components; + +/// +/// 常量、公共方法配置类 +/// +internal static class Penetrates +{ + /// + /// 创建组件依赖链表 + /// + /// 组件类型 + /// 组件参数 + /// + internal static List CreateDependLinkList(Type componentType, object options = default) + { + // 根组件上下文 + var rootComponentContext = new ComponentContext + { + ComponentType = componentType, + IsRoot = true + }; + rootComponentContext.SetProperty(componentType, options); + + // 初始化组件依赖链 + var dependLinkList = new List { componentType }; + var componentContextLinkList = new List + { + rootComponentContext + }; + + // 创建组件依赖链 + CreateDependLinkList(componentType, ref dependLinkList, ref componentContextLinkList); + + return componentContextLinkList; + } + + /// + /// 创建组件依赖链表 + /// + /// 组件类型 + /// 依赖链表 + /// 组件上下文链表 + /// + internal static void CreateDependLinkList(Type componentType, ref List dependLinkList, ref List componentContextLinkList) + { + // 获取 [DependsOn] 特性 + var dependsOnAttribute = componentType.GetCustomAttribute(true); + + // 获取依赖组件列表 + var dependComponents = dependsOnAttribute?.DependComponents?.Distinct()?.ToArray() ?? Array.Empty(); + + // 获取链接组件列表 + var linkComponents = dependsOnAttribute?.LinkComponents?.Distinct()?.ToArray() ?? Array.Empty(); + + // 检查自引用 + if (dependComponents.Contains(componentType) || linkComponents.Contains(componentType)) + { + throw new InvalidOperationException("A component cannot reference itself."); + } + + // 找出当前组件的序号 + var index = dependLinkList.IndexOf(componentType); + + // 获取根组件上下文并设置依赖 + var rootComponentContext = componentContextLinkList.First(u => u.IsRoot); + + // 设置当前组件依赖 + var calledContext = index > -1 ? componentContextLinkList[index] : rootComponentContext; + calledContext.DependComponents = dependComponents; + calledContext.LinkComponents = linkComponents; + + // 处理链接组件 + if (index == -1) + { + index = dependLinkList.Count; + + // 将链接的依赖的组件插入链表指定位置中 + dependLinkList.Add(componentType); + + // 记录组件上下文调用链 + componentContextLinkList.Add(new ComponentContext + { + CalledContext = calledContext, + RootContext = rootComponentContext, + ComponentType = componentType, + DependComponents = dependComponents, + LinkComponents = linkComponents + }); + } + + // 遍历当前组件依赖的组件集合 + foreach (var dependComponent in dependComponents) + { + // 如果还未插入组件链,则插入 + if (!dependLinkList.Contains(dependComponent)) + { + // 将被依赖的组件插入链表指定位置中 + dependLinkList.Insert(index, dependComponent); + + // 记录组件上下文调用链 + componentContextLinkList.Insert(index, new ComponentContext + { + CalledContext = calledContext, + RootContext = rootComponentContext, + ComponentType = dependComponent, + DependComponents = dependComponents, + LinkComponents = linkComponents + }); + + // 递增序号 + index++; + } + // 处理组件循环引用情况 + else + { + if (dependLinkList.IndexOf(dependComponent) > index) + { + throw new InvalidOperationException("There is a circular reference problem between components."); + } + } + + // 进行下一层依赖递归链查找 + CreateDependLinkList(dependComponent, ref dependLinkList, ref componentContextLinkList); + } + + if (linkComponents == null || linkComponents.Length == 0) return; + + // 遍历链接组件 + foreach (var linkComponent in linkComponents) + { + // 不能链接根节点 + if (linkComponent == rootComponentContext.ComponentType) + { + throw new InvalidOperationException("There is a circular reference problem between components."); + } + + CreateDependLinkList(linkComponent, ref dependLinkList, ref componentContextLinkList); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Attributes/MapSettingsAttribute.cs b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Attributes/MapSettingsAttribute.cs new file mode 100644 index 000000000..fba1b99ac --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Attributes/MapSettingsAttribute.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + + +namespace ThingsGateway.ConfigurableOptions; + +/// +/// 重新映射属性配置 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)] +public sealed class MapSettingsAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// appsetting.json 对应键 + public MapSettingsAttribute(string path) + { + Path = path; + } + + /// + /// 对应配置文件中的路径 + /// + public string Path { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Attributes/OptionsSettingsAttribute.cs b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Attributes/OptionsSettingsAttribute.cs new file mode 100644 index 000000000..db927130a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Attributes/OptionsSettingsAttribute.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.ConfigurableOptions; + +/// +/// 选项配置特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class)] +public sealed class OptionsSettingsAttribute : Attribute +{ + /// + /// 构造函数 + /// + public OptionsSettingsAttribute() + { + } + + /// + /// 构造函数 + /// + /// appsetting.json 对应键 + public OptionsSettingsAttribute(string path) + { + Path = path; + } + + /// + /// 构造函数 + /// + /// 启动所有实例进行后期配置 + public OptionsSettingsAttribute(bool postConfigureAll) + { + PostConfigureAll = postConfigureAll; + } + + /// + /// 构造函数 + /// + /// appsetting.json 对应键 + /// 启动所有实例进行后期配置 + public OptionsSettingsAttribute(string path, bool postConfigureAll) + { + Path = path; + PostConfigureAll = postConfigureAll; + } + + /// + /// 对应配置文件中的路径 + /// + public string Path { get; set; } + + /// + /// 对所有配置实例进行后期配置 + /// + public bool PostConfigureAll { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Extensions/ConfigurableOptionsServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Extensions/ConfigurableOptionsServiceCollectionExtensions.cs new file mode 100644 index 000000000..b9cd5eddd --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Extensions/ConfigurableOptionsServiceCollectionExtensions.cs @@ -0,0 +1,115 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +using System.Reflection; + +using ThingsGateway; +using ThingsGateway.ConfigurableOptions; +using ThingsGateway.Extensions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 可变选项服务拓展类 +/// +[SuppressSniffer] +public static class ConfigurableOptionsServiceCollectionExtensions +{ + /// + /// 添加选项配置 + /// + /// 选项类型 + /// 服务集合 + /// 服务集合 + public static IServiceCollection AddConfigurableOptions(this IServiceCollection services) + where TOptions : class, IConfigurableOptions + { + var optionsType = typeof(TOptions); + + // 获取选项配置 + var (optionsSettings, path) = Penetrates.GetOptionsConfiguration(optionsType); + + // 配置选项(含验证信息) + var configurationRoot = App.Configuration; + var optionsConfiguration = configurationRoot.GetSection(path); + + // 配置选项监听 + if (typeof(IConfigurableOptionsListener).IsAssignableFrom(optionsType)) + { + var onListenerMethod = optionsType.GetMethod(nameof(IConfigurableOptionsListener.OnListener)); + if (onListenerMethod != null) + { + // 监听全局配置改变,目前该方式存在触发两次的 bug:https://github.com/dotnet/aspnetcore/issues/2542 + ChangeToken.OnChange(() => configurationRoot.GetReloadToken(), ((Action)(() => + { + var options = optionsConfiguration.Get(); + if (options != null) onListenerMethod.Invoke(options, new object[] { options, optionsConfiguration }); + })).Debounce()); + } + } + + var optionsConfigure = services.AddOptions() + .Bind(optionsConfiguration, options => + { + options.BindNonPublicProperties = true; // 绑定私有变量 + }) + .ValidateDataAnnotations() + .ValidateOnStart(); + + // 实现 Key 映射 + services.PostConfigureAll(options => + { + // 查找所有贴了 MapSettings 的键值对 + var remapKeys = optionsType.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(u => u.IsDefined(typeof(MapSettingsAttribute), true)); + if (!remapKeys.Any()) return; + + foreach (var prop in remapKeys) + { + var propType = prop.PropertyType; + var realKey = prop.GetCustomAttribute(true).Path; + var realValue = configurationRoot.GetValue(propType, $"{path}:{realKey}"); + prop.SetValue(options, realValue); + } + }); + + // 配置复杂验证后后期配置 + var validateInterface = optionsType.GetInterfaces() + .FirstOrDefault(u => u.IsGenericType && typeof(IConfigurableOptions).IsAssignableFrom(u.GetGenericTypeDefinition())); + if (validateInterface != null) + { + var genericArguments = validateInterface.GenericTypeArguments; + + // 配置复杂验证 + if (genericArguments.Length > 1) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IValidateOptions), genericArguments.Last())); + } + + // 配置后期配置 + var postConfigureMethod = optionsType.GetMethod(nameof(IConfigurableOptions.PostConfigure)); + if (postConfigureMethod != null) + { + if (optionsSettings?.PostConfigureAll != true) + optionsConfigure.PostConfigure(options => postConfigureMethod.Invoke(options, new object[] { options, optionsConfiguration })); + else + services.PostConfigureAll(options => postConfigureMethod.Invoke(options, new object[] { options, optionsConfiguration })); + } + } + + return services; + } +} diff --git a/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Internal/Penetrates.cs b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Internal/Penetrates.cs new file mode 100644 index 000000000..3a4e29c7e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Internal/Penetrates.cs @@ -0,0 +1,59 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.ConfigurableOptions; + +/// +/// 常量、公共方法配置类 +/// +internal static class Penetrates +{ + /// + /// 获取选项配置 + /// + /// 选项类型 + /// + internal static (OptionsSettingsAttribute, string) GetOptionsConfiguration(Type optionsType) + { + var optionsSettings = optionsType.GetCustomAttribute(false); + + // 默认后缀 + var defaultStuffx = nameof(Options); + + return (optionsSettings, optionsSettings switch + { + // // 没有贴 [OptionsSettings],如果选项类以 `Options` 结尾,则移除,否则返回类名称 + null => optionsType.Name.EndsWith(defaultStuffx) ? optionsType.Name[0..^defaultStuffx.Length] : optionsType.Name, + // 如果贴有 [OptionsSettings] 特性,但未指定 Path 参数,则直接返回类名,否则返回 Path + _ => optionsSettings != null && string.IsNullOrWhiteSpace(optionsSettings.Path) ? optionsType.Name : optionsSettings.Path, + }); + } + + /// + /// 在主机启动时获取选项 + /// + /// 解决 v4.5.2+ 历史版本升级问题 + /// + /// + internal static TOptions GetOptionsOnStarting() + where TOptions : class, new() + { + if (App.RootServices == null && typeof(IConfigurableOptions).IsAssignableFrom(typeof(TOptions))) + { + var (_, path) = GetOptionsConfiguration(typeof(TOptions)); + return App.GetConfig(path, true); + } + + return null; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Options/IConfigurableOptions.cs b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Options/IConfigurableOptions.cs new file mode 100644 index 000000000..5f3225255 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/ConfigurableOptions/Options/IConfigurableOptions.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace ThingsGateway.ConfigurableOptions; + +/// +/// 应用选项依赖接口 +/// +public partial interface IConfigurableOptions +{ } + +/// +/// 选项后期配置 +/// +/// +public partial interface IConfigurableOptions : IConfigurableOptions + where TOptions : class, IConfigurableOptions +{ + /// + /// 选项后期配置 + /// + /// + /// + void PostConfigure(TOptions options, IConfiguration configuration); +} + +/// +/// 带验证的应用选项依赖接口 +/// +/// +/// +public partial interface IConfigurableOptions : IConfigurableOptions + where TOptions : class, IConfigurableOptions + where TOptionsValidation : class, IValidateOptions +{ +} + +/// +/// 带监听的应用选项依赖接口 +/// +/// +public partial interface IConfigurableOptionsListener : IConfigurableOptions + where TOptions : class, IConfigurableOptions +{ + /// + /// 监听 + /// + /// + /// + void OnListener(TOptions options, IConfiguration configuration); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Configuration/Constants/Constants.cs b/src/Admin/ThingsGateway.Furion/Configuration/Constants/Constants.cs new file mode 100644 index 000000000..482b3d9f1 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Configuration/Constants/Constants.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Configuration; + +/// +/// Configuration 模块常量 +/// +internal static class Constants +{ + /// + /// 正则表达式常量 + /// + internal static class Patterns + { + /// + /// 配置文件名 + /// + internal const string ConfigurationFileName = @"(?(?.+?)(\.(?\w+))?(?\.(json|xml|ini)))"; + + /// + /// 配置文件参数 + /// + internal const string ConfigurationFileParameter = @"\s+(?\b\w+\b)\s*=\s*(?\btrue\b|\bfalse\b)"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Configuration/Extensions/IConfigurationBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/Configuration/Extensions/IConfigurationBuilderExtensions.cs new file mode 100644 index 000000000..de1f5d504 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Configuration/Extensions/IConfigurationBuilderExtensions.cs @@ -0,0 +1,218 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration.Ini; +using Microsoft.Extensions.Configuration.Json; +using Microsoft.Extensions.Configuration.Xml; +using Microsoft.Extensions.Hosting; + +using System.Text.RegularExpressions; + +using ThingsGateway.Configuration; + +namespace Microsoft.Extensions.Configuration; + +/// +/// IConfigurationBuilder 接口拓展 +/// +[SuppressSniffer] +public static class IConfigurationBuilderExtensions +{ + /// + /// 添加配置文件 + /// + /// 配置构建对象 + /// 文件名 + /// 环境对象 + /// 可选文件,设置 true 跳过文件存在检查 + /// 是否监听文件更改 + /// 是否包含环境文件格式注册 + /// 配置构建对象 + /// + /// + public static IConfigurationBuilder AddFile(this IConfigurationBuilder configurationBuilder + , string fileName + , IHostEnvironment environment = default + , bool optional = true + , bool reloadOnChange = false + , bool includeEnvironment = false) + { + // 检查文件名格式 + CheckFileNamePattern(fileName + , out var fileNamePart + , out var environmentNamePart + , out var fileNameWithEnvironmentPart + , out var parameterPart); + + // 获取文件名绝对路径 + var filePath = ResolveRealAbsolutePath(fileNamePart); + + // 填充配置参数 + if (parameterPart.Count > 0) + { + TrySetParameter(parameterPart + , nameof(optional) + , ref optional); + TrySetParameter(parameterPart + , nameof(reloadOnChange) + , ref reloadOnChange); + TrySetParameter(parameterPart + , nameof(includeEnvironment) + , ref includeEnvironment); + } + + // 添加配置文件 + configurationBuilder.Add(CreateFileConfigurationSource(filePath + , optional + , reloadOnChange)); + + // 处理包含环境标识的文件 + if (environment is not null + && includeEnvironment + && !environment.EnvironmentName.Equals(environmentNamePart, StringComparison.OrdinalIgnoreCase)) + { + // 取得带环境文件名绝对路径 + var fileNameWithEnvironmentPath = ResolveRealAbsolutePath(fileNameWithEnvironmentPart.Replace("{env}", environment.EnvironmentName)); + + // 添加带环境配置文件 + configurationBuilder.Add(CreateFileConfigurationSource(fileNameWithEnvironmentPath + , optional + , reloadOnChange)); + } + + return configurationBuilder; + } + + /// + /// 检查文件名格式是否是受支持的格式 + /// + /// 文件名 + /// 返回文件名匹配部分 + /// 环境名匹配部分 + /// 带环境标识的文件名 + /// 参数匹配部分 + /// + /// + private static void CheckFileNamePattern(string fileName + , out string fileNamePart + , out string environmentNamePart + , out string fileNameWithEnvironmentPart + , out IDictionary parameterPart) + { + // 空检查 + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentNullException(nameof(fileName)); + } + + // 检查文件名格式 + if (!Regex.IsMatch(fileName + , Constants.Patterns.ConfigurationFileName + , RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace)) + { + throw new InvalidOperationException($"The <{fileName}> is not a valid supported file name format."); + } + + // 匹配文件名部分 + var fileNameMatch = Regex.Match(fileName + , Constants.Patterns.ConfigurationFileName + , RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace); + fileNamePart = fileNameMatch.Groups["fileName"].Value; + // 取环境名 + environmentNamePart = fileNameMatch.Groups["environmentName"].Value; + + // 生成带环境标识的文件名 + var realName = fileNameMatch.Groups["realName"].Value; + var extension = fileNameMatch.Groups["extension"].Value; + fileNameWithEnvironmentPart = $"{realName}.{{env}}{extension}"; + + // 匹配文件名参数部分 + parameterPart = Regex.Matches(fileName + , Constants.Patterns.ConfigurationFileParameter + , RegexOptions.IgnoreCase | RegexOptions.IgnorePatternWhitespace) + .ToDictionary(u => u.Groups["parameter"].Value, u => bool.Parse(u.Groups["value"].Value)); + } + + /// + /// 分析配置文件名并返回真实绝对路径 + /// + /// 文件名 + /// 返回文件绝对路径 + private static string ResolveRealAbsolutePath(string fileName) + { + // 获取文件名首个字符 + var firstChar = fileName[0]; + + // 如果文件名包含 : 符号,则认为是一个绝对路径,针对 windows 系统路径 + if (fileName.IndexOf(':') > -1 + && firstChar != '/' + && firstChar != '!') + { + return fileName; + } + + // 拼接绝对路径 + return firstChar switch + { + '&' or '.' => Path.Combine(AppContext.BaseDirectory, fileName[1..]), + '/' or '!' => fileName[1..], + '@' or '~' => Path.Combine(Directory.GetCurrentDirectory(), fileName[1..]), + _ => Path.Combine(Directory.GetCurrentDirectory(), fileName) + }; + } + + /// + /// 根据文件路径创建文件配置源 + /// + /// 文件路径 + /// 可选文件,设置 true 跳过文件存在检查 + /// 是否监听文件更改 + /// 实例 + /// + private static FileConfigurationSource CreateFileConfigurationSource(string filePath + , bool optional = true + , bool reloadOnChange = false) + { + // 获取文件拓展名 + var fileExtension = Path.GetExtension(filePath).ToLower(); + + // 创建受支持的文件配置源实例,仅支持 .json/.xml/.ini 拓展名 + FileConfigurationSource fileConfigurationSource = fileExtension switch + { + ".json" => new JsonConfigurationSource { Path = filePath, Optional = optional, ReloadOnChange = reloadOnChange }, + ".xml" => new XmlConfigurationSource { Path = filePath, Optional = optional, ReloadOnChange = reloadOnChange }, + ".ini" => new IniConfigurationSource { Path = filePath, Optional = optional, ReloadOnChange = reloadOnChange }, + _ => throw new InvalidOperationException($"Cannot create a file <{fileExtension}> configuration source for this file type.") + }; + + // 根据文件配置源解析对应文件配置提供程序 + fileConfigurationSource.ResolveFileProvider(); + + return fileConfigurationSource; + } + + /// + /// 设置 FileConfigurationSouce 参数 + /// + /// 字典参数结合 + /// 参数名 + /// 参数值 + private static void TrySetParameter(IDictionary parameters + , string parameterName + , ref bool value) + { + // 只有包含该参数才改变值 + if (parameters.TryGetValue(parameterName, out var parameterValue)) + { + value = parameterValue; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Configuration/Extensions/IConfigurationExtensions.cs b/src/Admin/ThingsGateway.Furion/Configuration/Extensions/IConfigurationExtensions.cs new file mode 100644 index 000000000..c00013421 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Configuration/Extensions/IConfigurationExtensions.cs @@ -0,0 +1,87 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace Microsoft.Extensions.Configuration; + +/// +/// IConfiguration 接口拓展 +/// +[SuppressSniffer] +public static class IConfigurationExtensions +{ + /// + /// 判断配置节点是否存在 + /// + /// 配置对象 + /// 节点路径 + /// 是否存在 + public static bool Exists(this IConfiguration configuration, string key) + { + return configuration.GetSection(key).Exists(); + } + + /// + /// 获取配置节点并转换成指定类型 + /// + /// 节点类型 + /// 配置对象 + /// 节点路径 + /// 节点类型实例 + public static T Get(this IConfiguration configuration, string key) + { + return configuration.GetSection(key).Get(); + } + + /// + /// 获取配置节点并转换成指定类型 + /// + /// 节点类型 + /// 配置对象 + /// 节点路径 + /// 配置值绑定到指定类型额外配置 + /// 节点类型实例 + public static T Get(this IConfiguration configuration + , string key + , Action configureOptions) + { + return configuration.GetSection(key).Get(configureOptions); + } + + /// + /// 获取节点配置 + /// + /// 配置对象 + /// 节点路径 + /// 节点类型 + /// 实例 + public static object Get(this IConfiguration configuration + , string key + , Type type) + { + return configuration.GetSection(key).Get(type); + } + + /// + /// 获取节点配置 + /// + /// 配置对象 + /// 节点路径 + /// 节点类型 + /// 配置值绑定到指定类型额外配置 + /// 实例 + public static object Get(this IConfiguration configuration + , string key + , Type type + , Action configureOptions) + { + return configuration.GetSection(key).Get(type, configureOptions); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/CorsAccessor/Extensions/CorsAccessorApplicationBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/CorsAccessor/Extensions/CorsAccessorApplicationBuilderExtensions.cs new file mode 100644 index 000000000..33f0fb3f3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/CorsAccessor/Extensions/CorsAccessorApplicationBuilderExtensions.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using ThingsGateway.CorsAccessor; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// 跨域中间件拓展 +/// +[SuppressSniffer] +public static class CorsAccessorApplicationBuilderExtensions +{ + /// + /// 添加跨域中间件 + /// + /// + /// + /// + public static IApplicationBuilder UseCorsAccessor(this IApplicationBuilder app, Action corsPolicyBuilderHandler = default) + { + // 获取选项 + var corsAccessorSettings = app.ApplicationServices.GetService>().Value; + + // 判断是否启用 SignalR 跨域支持 + if (corsAccessorSettings.SignalRSupport == false) + { + // 配置跨域中间件 + _ = corsPolicyBuilderHandler == null + ? app.UseCors(corsAccessorSettings.PolicyName) + : app.UseCors(corsPolicyBuilderHandler); + } + else + { + // 配置跨域中间件 + app.UseCors(builder => + { + // 设置跨域策略 + Penetrates.SetCorsPolicy(builder, corsAccessorSettings, true); + + // 添加自定义配置 + corsPolicyBuilderHandler?.Invoke(builder); + }); + } + + return app; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/CorsAccessor/Extensions/CorsAccessorServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/CorsAccessor/Extensions/CorsAccessorServiceCollectionExtensions.cs new file mode 100644 index 000000000..3468b76cc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/CorsAccessor/Extensions/CorsAccessorServiceCollectionExtensions.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.Extensions.Options; + +using ThingsGateway; +using ThingsGateway.CorsAccessor; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 跨域访问服务拓展类 +/// +[SuppressSniffer] +public static class CorsAccessorServiceCollectionExtensions +{ + /// + /// 配置跨域 + /// + /// 服务集合 + /// + /// + /// 服务集合 + public static IServiceCollection AddCorsAccessor(this IServiceCollection services, Action corsOptionsHandler = default, Action corsPolicyBuilderHandler = default) + { + // 解决服务重复注册问题 + if (services.Any(u => u.ServiceType == typeof(IConfigureOptions))) + { + return services; + } + + // 添加跨域配置选项 + services.AddConfigurableOptions(); + + // 获取选项 + var corsAccessorSettings = App.GetConfig("CorsAccessorSettings", true); + + // 添加跨域服务 + services.AddCors(options => + { + // 添加策略跨域 + options.AddPolicy(corsAccessorSettings.PolicyName, builder => + { + // 设置跨域策略 + Penetrates.SetCorsPolicy(builder, corsAccessorSettings); + + // 添加自定义配置 + corsPolicyBuilderHandler?.Invoke(builder); + }); + + // 添加自定义配置 + corsOptionsHandler?.Invoke(options); + }); + + return services; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/CorsAccessor/Internal/Penetrates.cs b/src/Admin/ThingsGateway.Furion/CorsAccessor/Internal/Penetrates.cs new file mode 100644 index 000000000..87376a99e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/CorsAccessor/Internal/Penetrates.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Cors.Infrastructure; + +namespace ThingsGateway.CorsAccessor; + +/// +/// 常量、公共方法配置类 +/// +internal static class Penetrates +{ + private static readonly string[] _defaultExposedHeaders = new[] + { + "access-token", + "x-access-token", + "Content-Disposition" + }; + + private static readonly string[] _defaultSignalRMethods = new[] { "GET", "POST" }; + + /// + /// 设置跨域策略 + /// + /// + /// + /// + internal static void SetCorsPolicy(CorsPolicyBuilder builder, CorsAccessorSettingsOptions corsAccessorSettings, bool isMiddleware = false) + { + var isNotSetOrigins = corsAccessorSettings.WithOrigins == null || corsAccessorSettings.WithOrigins.Length == 0; + var isSupportSignarlR = isMiddleware && corsAccessorSettings.SignalRSupport == true; + + builder.SetIsOriginAllowed(_ => true); + + if (isNotSetOrigins) + { + if (!isSupportSignarlR) builder.AllowAnyOrigin(); + } + else builder.WithOrigins(corsAccessorSettings.WithOrigins) + .SetIsOriginAllowedToAllowWildcardSubdomains(); + + if ((corsAccessorSettings.WithHeaders == null || corsAccessorSettings.WithHeaders.Length == 0) || isSupportSignarlR) builder.AllowAnyHeader(); + else builder.WithHeaders(corsAccessorSettings.WithHeaders); + + if (corsAccessorSettings.WithMethods == null || corsAccessorSettings.WithMethods.Length == 0) builder.AllowAnyMethod(); + else + { + if (isSupportSignarlR) + { + builder.WithMethods(corsAccessorSettings.WithMethods.Concat(_defaultSignalRMethods).Distinct(StringComparer.OrdinalIgnoreCase).ToArray()); + } + else builder.WithMethods(corsAccessorSettings.WithMethods); + } + + if ((corsAccessorSettings.AllowCredentials == true && !isNotSetOrigins) || isSupportSignarlR) builder.AllowCredentials(); + + IEnumerable exposedHeaders = corsAccessorSettings.FixedClientToken == true + ? _defaultExposedHeaders + : Array.Empty(); + if (corsAccessorSettings.WithExposedHeaders != null && corsAccessorSettings.WithExposedHeaders.Length > 0) + { + exposedHeaders = exposedHeaders.Concat(corsAccessorSettings.WithExposedHeaders).Distinct(StringComparer.OrdinalIgnoreCase); + } + + if (exposedHeaders.Any()) builder.WithExposedHeaders(exposedHeaders.ToArray()); + + builder.SetPreflightMaxAge(TimeSpan.FromSeconds(corsAccessorSettings.SetPreflightMaxAge ?? 24 * 60 * 60)); + } +} diff --git a/src/Admin/ThingsGateway.Furion/CorsAccessor/Options/CorsAccessorSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/CorsAccessor/Options/CorsAccessorSettingsOptions.cs new file mode 100644 index 000000000..dcadb833f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/CorsAccessor/Options/CorsAccessorSettingsOptions.cs @@ -0,0 +1,84 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; + +using System.ComponentModel.DataAnnotations; + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.CorsAccessor; + +/// +/// 跨域配置选项 +/// +public sealed class CorsAccessorSettingsOptions : IConfigurableOptions +{ + /// + /// 策略名称 + /// + [Required] + public string PolicyName { get; set; } + + /// + /// 允许来源域名,没有配置则允许所有来源 + /// + public string[] WithOrigins { get; set; } + + /// + /// 请求表头,没有配置则允许所有表头 + /// + public string[] WithHeaders { get; set; } + + /// + /// 设置客户端可获取的响应标头 + /// + public string[] WithExposedHeaders { get; set; } + + /// + /// 设置跨域允许请求谓词,没有配置则允许所有 + /// + public string[] WithMethods { get; set; } + + /// + /// 是否允许跨域请求中的凭据 + /// + public bool? AllowCredentials { get; set; } + + /// + /// 设置预检过期时间 + /// + public int? SetPreflightMaxAge { get; set; } + + /// + /// 修正前端无法获取 Token 问题 + /// + public bool? FixedClientToken { get; set; } + + /// + /// 启用 SignalR 跨域支持 + /// + public bool? SignalRSupport { get; set; } + + /// + /// 后期配置 + /// + /// + /// + public void PostConfigure(CorsAccessorSettingsOptions options, IConfiguration configuration) + { + PolicyName ??= "App.Cors.Policy"; + WithOrigins ??= Array.Empty(); + AllowCredentials ??= true; + FixedClientToken ??= true; + SignalRSupport ??= false; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/AESEncryption.cs b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/AESEncryption.cs new file mode 100644 index 000000000..e5c870af8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/AESEncryption.cs @@ -0,0 +1,163 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Buffers.Text; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; + +namespace ThingsGateway.DataEncryption; + +/// +/// AES 加解密 +/// +[SuppressSniffer] +public class AESEncryption +{ + /// + /// 加密 + /// + /// 加密文本 + /// 密钥 + /// 偏移量 + /// 模式 + /// 填充 + /// + public static string Encrypt(string text, string skey, byte[] iv = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + var bKey = Encoding.UTF8.GetBytes(skey); + + using var aesAlg = Aes.Create(); + aesAlg.IV = iv ?? aesAlg.IV; + aesAlg.Mode = mode; + aesAlg.Padding = padding; + + using var encryptor = aesAlg.CreateEncryptor(bKey, aesAlg.IV); + using var msEncrypt = new MemoryStream(); + using (var csEncrypt = new CryptoStream(msEncrypt, encryptor, CryptoStreamMode.Write)) + using (var swEncrypt = new StreamWriter(csEncrypt)) + { + swEncrypt.Write(text); + } + + var encryptedContent = msEncrypt.ToArray(); + + var bVector = aesAlg.IV; + var dataLength = bVector.Length + encryptedContent.Length; + var base64Length = Base64.GetMaxEncodedToUtf8Length(dataLength); + var result = new byte[base64Length]; + + Unsafe.CopyBlock(ref result[0], ref bVector[0], (uint)bVector.Length); + Unsafe.CopyBlock(ref result[bVector.Length], ref encryptedContent[0], (uint)encryptedContent.Length); + Base64.EncodeToUtf8InPlace(result, dataLength, out base64Length); + + return Encoding.ASCII.GetString(result.AsSpan()[..base64Length]); + } + + /// + /// 解密 + /// + /// 加密后字符串 + /// 密钥 + /// 偏移量 + /// 模式 + /// 填充 + /// + public static string Decrypt(string hash, string skey, byte[] iv = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + var fullCipher = Convert.FromBase64String(hash); + + var bVector = new byte[16]; + var cipher = new byte[fullCipher.Length - bVector.Length]; + + Unsafe.CopyBlock(ref bVector[0], ref fullCipher[0], (uint)bVector.Length); + Unsafe.CopyBlock(ref cipher[0], ref fullCipher[bVector.Length], (uint)(fullCipher.Length - bVector.Length)); + var bKey = Encoding.UTF8.GetBytes(skey); + + using var aesAlg = Aes.Create(); + aesAlg.IV = iv ?? bVector; + aesAlg.Mode = mode; + aesAlg.Padding = padding; + + using var decryptor = aesAlg.CreateDecryptor(bKey, aesAlg.IV); + using var msDecrypt = new MemoryStream(cipher); + using var csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read); + using var srDecrypt = new StreamReader(csDecrypt); + + return srDecrypt.ReadToEnd(); + } + + /// + /// 加密 + /// + /// 源文件 字节数组 + /// 密钥 + /// 偏移量 + /// 模式 + /// 填充 + /// 加密后的字节数组 + public static byte[] Encrypt(byte[] bytes, string skey, byte[] iv = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + var bKey = new byte[32]; + Array.Copy(Encoding.UTF8.GetBytes(skey.PadRight(bKey.Length)), bKey, bKey.Length); + + iv ??= MD5Encryption.Encrypt(skey, false, is16: true).PadRight(16).Take(16).Select(c => (byte)c).ToArray(); + + using var aesAlg = Aes.Create(); + aesAlg.IV = iv; + aesAlg.Mode = mode; + aesAlg.Padding = padding; + + using var memoryStream = new MemoryStream(); + using var cryptoStream = new CryptoStream(memoryStream, aesAlg.CreateEncryptor(bKey, aesAlg.IV), CryptoStreamMode.Write); + + cryptoStream.Write(bytes, 0, bytes.Length); + cryptoStream.FlushFinalBlock(); + + return memoryStream.ToArray(); + } + + /// + /// 解密 + /// + /// 加密后文件 字节数组 + /// 密钥 + /// 偏移量 + /// 模式 + /// 填充 + /// + public static byte[] Decrypt(byte[] bytes, string skey, byte[] iv = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + var bKey = new byte[32]; + Array.Copy(Encoding.UTF8.GetBytes(skey.PadRight(bKey.Length)), bKey, bKey.Length); + + iv ??= MD5Encryption.Encrypt(skey, false, is16: true).PadRight(16).Take(16).Select(c => (byte)c).ToArray(); + + using var aesAlg = Aes.Create(); + aesAlg.IV = iv; + aesAlg.Mode = mode; + aesAlg.Padding = padding; + + using var memoryStream = new MemoryStream(bytes); + using var cryptoStream = new CryptoStream(memoryStream, aesAlg.CreateDecryptor(bKey, aesAlg.IV), CryptoStreamMode.Read); + using var originalStream = new MemoryStream(); + + var buffer = new byte[1024]; + var readBytes = 0; + + while ((readBytes = cryptoStream.Read(buffer, 0, buffer.Length)) > 0) + { + originalStream.Write(buffer, 0, readBytes); + } + + return originalStream.ToArray(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/DESEncryption.cs b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/DESEncryption.cs new file mode 100644 index 000000000..ed34e864b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/DESEncryption.cs @@ -0,0 +1,91 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Security.Cryptography; +using System.Text; + +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.DataEncryption; + +/// +/// DES 加解密 +/// +[SuppressSniffer] +public class DESEncryption +{ + /// + /// 加密 + /// + /// 加密文本 + /// 密钥 + /// 是否输出大写加密,默认 false + /// + public static string Encrypt(string text, string skey = "ThingsGateway", bool uppercase = false) + { + if (text.IsNullOrWhiteSpace()) return text; + + using var des = DES.Create(); + var inputByteArray = Encoding.Default.GetBytes(text); + + var md5Bytes = Encoding.ASCII.GetBytes(MD5Encryption.Encrypt(skey, uppercase)[..8]); + des.Key = md5Bytes; + des.IV = md5Bytes; + + using var ms = new MemoryStream(); + using var cs = new CryptoStream(ms, des.CreateEncryptor(), CryptoStreamMode.Write); + + cs.Write(inputByteArray, 0, inputByteArray.Length); + cs.FlushFinalBlock(); + + var ret = new StringBuilder(); + foreach (var b in ms.ToArray()) + { + ret.AppendFormat("{0:X2}", b); + } + + return ret.ToString(); + } + + /// + /// 解密 + /// + /// 加密后字符串 + /// 密钥 + /// 是否输出大写加密,默认 false + /// + public static string Decrypt(string hash, string skey = "ThingsGateway", bool uppercase = false) + { + if (hash.IsNullOrWhiteSpace()) return hash; + using var des = DES.Create(); + var len = hash.Length / 2; + var inputByteArray = new byte[len]; + int x, i; + + for (x = 0; x < len; x++) + { + i = Convert.ToInt32(hash.Substring(x * 2, 2), 16); + inputByteArray[x] = (byte)i; + } + + var md5Bytes = Encoding.ASCII.GetBytes(MD5Encryption.Encrypt(skey, uppercase)[..8]); + des.Key = md5Bytes; + des.IV = md5Bytes; + + using var ms = new MemoryStream(); + using var cs = new CryptoStream(ms, des.CreateDecryptor(), CryptoStreamMode.Write); + + cs.Write(inputByteArray, 0, inputByteArray.Length); + cs.FlushFinalBlock(); + + return Encoding.Default.GetString(ms.ToArray()); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/KSortEncryption.cs b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/KSortEncryption.cs new file mode 100644 index 000000000..99d17c7eb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/KSortEncryption.cs @@ -0,0 +1,172 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Data; +using System.Text.Json; + +namespace ThingsGateway.DataEncryption; + +/// +/// KSort 加密(数据签名) +/// +[SuppressSniffer] +public class KSortEncryption +{ + private static DateTime _timeStampStartTime = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// 数据加密(签名) + /// + /// APP_ID + /// APP_KEY + /// 命令 + /// 序列化后的字符串 + /// 时间戳 + /// + public static KSortSignature Encrypt(string appId, string appKey, string command, string data, long? timestamp = null) + { + ArgumentNullException.ThrowIfNull(appId); + ArgumentNullException.ThrowIfNull(appKey); + ArgumentNullException.ThrowIfNull(command); + + timestamp ??= (long)(DateTime.Now.ToUniversalTime() - _timeStampStartTime).TotalMilliseconds; + + var dic = new Dictionary + { + {"app_id", appId }, + {"app_key", appKey }, + {"command", command }, + {"data", data }, + {"timestamp", timestamp }, + }; + + // ksort 排序 + var sortedDic = dic.OrderBy(kvp => kvp.Key); + + // 半角逗号连接 + var output = string.Join(",", sortedDic.Select(kvp => $"{kvp.Key}={kvp.Value}")); + + // utf8 编码,32位小写 + var signature = MD5Encryption.Encrypt(output); + + return new KSortSignature + { + app_id = appId, + app_key = appKey, + command = command, + data = data, + timestamp = timestamp.Value, + signature = signature + }; + } + + /// + /// 比较数据签名 + /// + /// + /// + public static bool Compare(KSortSignature kSortSignature) + { + ArgumentNullException.ThrowIfNull(kSortSignature); + + return Encrypt(kSortSignature.app_id, kSortSignature.app_key, kSortSignature.command, kSortSignature.data, kSortSignature.timestamp) == kSortSignature; + } + + /// + /// 比较数据签名 + /// + /// 签名数据 + /// 新的 APP_ID + /// 新的 APP_KEY + /// + public static bool Compare(string signatureData, string appId = null, string appKey = null) + { + var kSortSignature = JsonSerializer.Deserialize(signatureData); + ArgumentNullException.ThrowIfNull(kSortSignature); + + return Encrypt(appId ?? kSortSignature.app_id, appKey ?? kSortSignature.app_key, kSortSignature.command, kSortSignature.data, kSortSignature.timestamp) == kSortSignature; + } +} + +/// +/// KSort 签名类 +/// +public class KSortSignature : IEquatable +{ + /// + /// APP_ID + /// + public string app_id { get; set; } + + /// + /// APP_KEY + /// + public string app_key { get; set; } + + /// + /// 命令 + /// + public string command { get; set; } + + /// + /// 序列化的字符串 + /// + public string data { get; set; } + + /// + /// 时间戳 + /// + public long timestamp { get; set; } + + /// + /// 签名 + /// + public string signature { get; set; } + + /// + public bool Equals(KSortSignature other) + { + if (other == null) return false; + + return app_id == other.app_id && + app_key == other.app_key && + command == other.command && + data == other.data && + timestamp == other.timestamp && + signature == other.signature; + } + + /// + public override bool Equals(object obj) + { + return Equals(obj as KSortSignature); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(app_id, app_key, command, data, timestamp, signature); + } + + /// + public static bool operator ==(KSortSignature lhs, KSortSignature rhs) + { + if (ReferenceEquals(lhs, rhs)) return true; + if (lhs is null || rhs is null) return false; + return lhs.Equals(rhs); + } + + /// + public static bool operator !=(KSortSignature lhs, KSortSignature rhs) + { + return !(lhs == rhs); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/MD5Encryption.cs b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/MD5Encryption.cs new file mode 100644 index 000000000..9baf87f38 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/MD5Encryption.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Security.Cryptography; +using System.Text; + +namespace ThingsGateway.DataEncryption; + +/// +/// MD5 加密 +/// +[SuppressSniffer] +public static unsafe class MD5Encryption +{ + /// + /// MD5 比较 + /// + /// 加密文本 + /// MD5 字符串 + /// 是否输出大写加密,默认 false + /// 是否输出 16 位 + /// bool + public static bool Compare(string text, string hash, bool uppercase = false, bool is16 = false) + { + return Compare(Encoding.UTF8.GetBytes(text), hash, uppercase, is16); + } + + /// + /// MD5 加密 + /// + /// 加密文本 + /// 是否输出大写加密,默认 false + /// 是否输出 16 位 + /// + public static string Encrypt(string text, bool uppercase = false, bool is16 = false) + { + return Encrypt(Encoding.UTF8.GetBytes(text), uppercase, is16); + } + + /// + /// MD5 加密 + /// + /// 字节数组 + /// 是否输出大写加密,默认 false + /// 是否输出 16 位 + /// + public static string Encrypt(byte[] bytes, bool uppercase = false, bool is16 = false) + { + var data = MD5.HashData(bytes); + + var stringBuilder = new StringBuilder(); + for (var i = 0; i < data.Length; i++) + { + stringBuilder.Append(data[i].ToString("x2")); + } + + var md5String = stringBuilder.ToString(); + var hash = !is16 ? md5String : md5String.Substring(8, 16); + return !uppercase ? hash : hash.ToUpper(); + } + + /// + /// MD5 比较 + /// + /// 字节数组 + /// MD5 字符串 + /// 是否输出大写加密,默认 false + /// 是否输出 16 位 + /// bool + public static bool Compare(byte[] bytes, string hash, bool uppercase = false, bool is16 = false) + { + var hashOfInput = Encrypt(bytes, uppercase, is16); + return hash.Equals(hashOfInput, StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/PBKDF2Encryption.cs b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/PBKDF2Encryption.cs new file mode 100644 index 000000000..38f007549 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/PBKDF2Encryption.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Security.Cryptography; + +namespace ThingsGateway.DataEncryption; + +/// +/// PBKDF2 加密 +/// +[SuppressSniffer] +public class PBKDF2Encryption +{ + private const string SaltHashSeparator = ":"; + + /// + /// PBKDF2 加密 + /// + /// 加密文本 + /// 随机 salt 大小 + /// 迭代次数 + /// 密钥长度 + /// + public static string Encrypt(string text, int saltSize = 16, int iterationCount = 10000, int derivedKeyLength = 32) + { + using var rng = RandomNumberGenerator.Create(); + var salt = new byte[saltSize]; + rng.GetBytes(salt); + + using var pbkdf2 = new Rfc2898DeriveBytes(text, salt, iterationCount, HashAlgorithmName.SHA256); + var hash = pbkdf2.GetBytes(derivedKeyLength); + + // 分别编码盐和哈希,并用分隔符拼接 + return Convert.ToBase64String(salt) + SaltHashSeparator + Convert.ToBase64String(hash); + } + + /// + /// PBKDF2 比较 + /// + /// 加密文本 + /// PBKDF2 字符串 + /// 随机 salt 大小 + /// 迭代次数 + /// 密钥长度 + /// + public static bool Compare(string text, string hash, int saltSize = 16, int iterationCount = 10000, int derivedKeyLength = 32) + { + try + { + var parts = hash.Split(new[] { SaltHashSeparator }, StringSplitOptions.None); + if (parts.Length != 2) + return false; + + var saltBytes = Convert.FromBase64String(parts[0]); + var storedHashBytes = Convert.FromBase64String(parts[1]); + + if (saltBytes.Length != saltSize || storedHashBytes.Length != derivedKeyLength) + return false; + + using var pbkdf2 = new Rfc2898DeriveBytes(text, saltBytes, iterationCount, HashAlgorithmName.SHA256); + var computedHash = pbkdf2.GetBytes(derivedKeyLength); + + return computedHash.SequenceEqual(storedHashBytes); + } + catch + { + return false; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/RSAEncryption.cs b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/RSAEncryption.cs new file mode 100644 index 000000000..05550c4a0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/RSAEncryption.cs @@ -0,0 +1,147 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Security.Cryptography; +using System.Text; + +namespace ThingsGateway.DataEncryption; + +/// +/// RSA 加密 +/// +[SuppressSniffer] +public static class RSAEncryption +{ + /// + /// 生成 RSA 秘钥 + /// + /// 大小必须为 2048 到 16384 之间,且必须能被 8 整除 + /// + public static (string publicKey, string privateKey) GenerateSecretKey(int keySize = 2048) + { + CheckRSAKeySize(keySize); + + using var rsa = new RSACryptoServiceProvider(keySize); + return (rsa.ToXmlString(false), rsa.ToXmlString(true)); + } + + /// + /// 加密 + /// + /// 明文内容 + /// 公钥 + /// + /// + public static string Encrypt(string text, string publicKey, int keySize = 2048) + { + CheckRSAKeySize(keySize); + + using var rsa = new RSACryptoServiceProvider(keySize); + rsa.FromXmlString(publicKey); + + var originalData = Encoding.Default.GetBytes(text); + byte[] encryptedData; + + // 密钥可加密数据长度 + var bufferSize = (rsa.KeySize / 8) - 11; + + // RSA 算法规定:https://gitee.com/dotnetchina/Furion/pulls/788 + // 待加密的字节数不能超过密钥的长度值除以 8 再减去 11(即:RSACryptoServiceProvider.KeySize / 8 - 11), + // 而加密后得到密文的字节数,正好是密钥的长度值除以 8(即:RSACryptoServiceProvider.KeySize / 8) + if (originalData.Length > bufferSize) + { + // 分段加密 + var buffer = new byte[bufferSize]; + using var input = new MemoryStream(originalData); + using var output = new MemoryStream(); + + while (true) + { + var readLine = input.Read(buffer, 0, bufferSize); + if (readLine <= 0) + { + break; + } + + var temp = new byte[readLine]; + Array.Copy(buffer, 0, temp, 0, readLine); + + var encrypt = rsa.Encrypt(temp, false); + output.Write(encrypt, 0, encrypt.Length); + } + encryptedData = output.ToArray(); + } + else encryptedData = rsa.Encrypt(originalData, false); + + return Convert.ToBase64String(encryptedData); + } + + /// + /// 解密 + /// + /// 密文内容 + /// 私钥 + /// + /// + public static string Decrypt(string text, string privateKey, int keySize = 2048) + { + CheckRSAKeySize(keySize); + + using var rsa = new RSACryptoServiceProvider(keySize); + rsa.FromXmlString(privateKey); + + var encryptData = Convert.FromBase64String(text); + byte[] decryptedData; + + // 可解密密文最大长度 + var bufferSize = rsa.KeySize / 8; + + // RSA 算法规定:https://gitee.com/dotnetchina/Furion/pulls/788 + // 待加密的字节数不能超过密钥的长度值除以 8 再减去 11(即:RSACryptoServiceProvider.KeySize / 8 - 11), + // 而加密后得到密文的字节数,正好是密钥的长度值除以 8(即:RSACryptoServiceProvider.KeySize / 8) + if (encryptData.Length > bufferSize) + { + // 分段解密 + var buffer = new byte[bufferSize]; + using var input = new MemoryStream(encryptData); + using var output = new MemoryStream(); + + while (true) + { + var readLine = input.Read(buffer, 0, bufferSize); + if (readLine <= 0) + { + break; + } + + var temp = new byte[readLine]; + Array.Copy(buffer, 0, temp, 0, readLine); + + var decrypt = rsa.Decrypt(temp, false); + output.Write(decrypt, 0, decrypt.Length); + } + decryptedData = output.ToArray(); + } + else decryptedData = rsa.Decrypt(encryptData, false); + + return Encoding.Default.GetString(decryptedData); + } + + /// + /// 检查 RSA 长度 + /// + /// + private static void CheckRSAKeySize(int keySize) + { + if (keySize < 2048 || keySize > 16384 || keySize % 8 != 0) + throw new ArgumentException("The keySize must be between 2048 and 16384 in size and must be divisible by 8.", nameof(keySize)); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/SHA1Encryption.cs b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/SHA1Encryption.cs new file mode 100644 index 000000000..9ef9a64f4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataEncryption/Encryptions/SHA1Encryption.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Security.Cryptography; +using System.Text; + +namespace ThingsGateway.DataEncryption; + +/// +/// SHA1 加密 +/// +[SuppressSniffer] +public class SHA1Encryption +{ + /// + /// SHA1 加密 + /// + /// 加密文本 + /// 是否输出大写加密,默认 false + /// + public static string Encrypt(string text, bool uppercase = false) + { + return Encrypt(Encoding.UTF8.GetBytes(text), uppercase); + } + + /// + /// SHA1 比较 + /// + /// 加密文本 + /// SHA1 字符串 + /// 是否输出大写加密,默认 false + /// bool + public static bool Compare(string text, string hash, bool uppercase = false) + { + return Compare(Encoding.UTF8.GetBytes(text), hash, uppercase); + } + + /// + /// SHA1 加密 + /// + /// 字节数组 + /// 是否输出大写加密,默认 false + /// + public static string Encrypt(byte[] bytes, bool uppercase = false) + { + var data = SHA1.HashData(bytes); + + var stringBuilder = new StringBuilder(); + for (var i = 0; i < data.Length; i++) + { + stringBuilder.Append(data[i].ToString("x2")); + } + + var sha1String = stringBuilder.ToString(); + return !uppercase ? sha1String : sha1String.ToUpper(); + } + + /// + /// SHA1 比较 + /// + /// 字节数组 + /// SHA1 字符串 + /// 是否输出大写加密,默认 false + /// bool + public static bool Compare(byte[] bytes, string hash, bool uppercase = false) + { + var hashOfInput = Encrypt(bytes, uppercase); + return hash.Equals(hashOfInput, StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataEncryption/Extensions/StringEncryptionExtensions.cs b/src/Admin/ThingsGateway.Furion/DataEncryption/Extensions/StringEncryptionExtensions.cs new file mode 100644 index 000000000..bb5251a1d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataEncryption/Extensions/StringEncryptionExtensions.cs @@ -0,0 +1,246 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Security.Cryptography; + +namespace ThingsGateway.DataEncryption.Extensions; + +/// +/// DataEncryption 字符串加密拓展 +/// +[SuppressSniffer] +public static class StringEncryptionExtensions +{ + /// + /// 字符串的 MD5 加密 + /// + /// + /// 是否输出大写加密,默认 false + /// 是否输出 16 位 + /// string + public static string ToMD5Encrypt(this string text, bool uppercase = false, bool is16 = false) + { + return MD5Encryption.Encrypt(text, uppercase, is16); + } + + /// + /// 字符串的 MD5 对比 + /// + /// + /// + /// 是否输出大写加密,默认 false + /// 是否输出 16 位 + /// string + public static bool ToMD5Compare(this string text, string hash, bool uppercase = false, bool is16 = false) + { + return MD5Encryption.Compare(text, hash, uppercase, is16); + } + + /// + /// 字节数组的 MD5 加密 + /// + /// + /// 是否输出大写加密,默认 false + /// 是否输出 16 位 + /// string + public static string ToMD5Encrypt(this byte[] bytes, bool uppercase = false, bool is16 = false) + { + return MD5Encryption.Encrypt(bytes, uppercase, is16); + } + + /// + /// 字节数组的 MD5 对比 + /// + /// + /// + /// 是否输出大写加密,默认 false + /// 是否输出 16 位 + /// string + public static bool ToMD5Compare(this byte[] bytes, string hash, bool uppercase = false, bool is16 = false) + { + return MD5Encryption.Compare(bytes, hash, uppercase, is16); + } + + /// + /// 字符串 AES 加密 + /// + /// 加密文本 + /// 密钥 + /// 偏移量 + /// 模式 + /// 填充 + /// string + public static string ToAESEncrypt(this string text, string skey, byte[] iv = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + return AESEncryption.Encrypt(text, skey, iv, mode, padding); + } + + /// + /// 字符串 AES 解密 + /// + /// 加密文本 + /// 密钥 + /// 偏移量 + /// 模式 + /// 填充 + /// string + public static string ToAESDecrypt(this string text, string skey, byte[] iv = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + return AESEncryption.Decrypt(text, skey, iv, mode, padding); + } + + /// + /// 字节数组(文件) AES 加密 + /// + /// 源文件 字节数组 + /// 密钥 + /// 偏移量 + /// 模式 + /// 填充 + /// string + public static byte[] ToAESEncrypt(this byte[] bytes, string skey, byte[] iv = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + return AESEncryption.Encrypt(bytes, skey, iv, mode, padding); + } + + /// + /// 字节数组(文件) AES 解密 + /// + /// 加密后文件 字节数组 + /// 密钥 + /// 偏移量 + /// 模式 + /// 填充 + /// string + public static byte[] ToAESDecrypt(this byte[] bytes, string skey, byte[] iv = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + return AESEncryption.Decrypt(bytes, skey, iv, mode, padding); + } + + /// + /// 字符串 DES 加密 + /// + /// 需要加密的字符串 + /// 密钥 + /// 是否输出大写加密,默认 false + /// string + public static string ToDESEncrypt(this string text, string skey, bool uppercase = false) + { + return DESEncryption.Encrypt(text, skey, uppercase); + } + + /// + /// 字符串 DES 解密 + /// + /// + /// 密钥 + /// 是否输出大写加密,默认 false + /// string + public static string ToDESDecrypt(this string text, string skey, bool uppercase = false) + { + return DESEncryption.Decrypt(text, skey, uppercase); + } + + /// + /// 字符串 RSA 加密 + /// + /// 需要加密的文本 + /// 公钥 + /// + public static string ToRSAEncrpyt(this string text, string publicKey) + { + return RSAEncryption.Encrypt(text, publicKey); + } + + /// + /// 字符串 RSA 解密 + /// + /// 需要解密的文本 + /// 私钥 + /// + public static string ToRSADecrypt(this string text, string privateKey) + { + return RSAEncryption.Decrypt(text, privateKey); + } + + /// + /// 字符串 SHA1 加密 + /// + /// 需要加密的文本 + /// 是否输出大写加密,默认 false + /// + public static string ToSHA1Encrypt(this string text, bool uppercase = false) + { + return SHA1Encryption.Encrypt(text, uppercase); + } + + /// + /// 字节数组的 SHA1 加密 + /// + /// 字节数组 + /// 是否输出大写加密,默认 false + /// + public static string ToSHA1Encrypt(this byte[] bytes, bool uppercase = false) + { + return SHA1Encryption.Encrypt(bytes, uppercase); + } + + /// + /// 字符串的 SHA1 对比 + /// + /// + /// + /// 是否输出大写加密,默认 false + /// string + public static bool ToSHA1Compare(this string text, string hash, bool uppercase = false) + { + return SHA1Encryption.Compare(text, hash, uppercase); + } + + /// + /// 字节数组的 SHA1 对比 + /// + /// + /// + /// 是否输出大写加密,默认 false + /// string + public static bool ToSHA1Compare(this byte[] bytes, string hash, bool uppercase = false) + { + return SHA1Encryption.Compare(bytes, hash, uppercase); + } + + /// + /// 字符串的 PBKDF2 加密 + /// + /// 加密文本 + /// 随机 salt 大小 + /// 迭代次数 + /// 密钥长度 + /// + public static string ToPBKDF2Encrypt(this string text, int saltSize = 16, int iterationCount = 10000, int derivedKeyLength = 32) + { + return PBKDF2Encryption.Encrypt(text, saltSize, iterationCount, derivedKeyLength); + } + + /// + /// 字符串的 PBKDF2 比较 + /// + /// 加密文本 + /// PBKDF2 字符串 + /// 随机 salt 大小 + /// 迭代次数 + /// 密钥长度 + /// + public static bool ToPBKDF2Compare(this string text, string hash, int saltSize = 16, int iterationCount = 10000, int derivedKeyLength = 32) + { + return PBKDF2Encryption.Compare(text, hash, saltSize, iterationCount, derivedKeyLength); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/DataValidationAttribute.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/DataValidationAttribute.cs new file mode 100644 index 000000000..c5908fc6f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/DataValidationAttribute.cs @@ -0,0 +1,97 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway; +using ThingsGateway.DataValidation; + +namespace System.ComponentModel.DataAnnotations; + +/// +/// 数据类型验证特性 +/// +[SuppressSniffer] +public sealed class DataValidationAttribute : ValidationAttribute +{ + /// + /// 构造函数 + /// + /// 验证逻辑 + /// + public DataValidationAttribute(ValidationPattern validationPattern, params object[] validationTypes) + { + ValidationPattern = validationPattern; + ValidationTypes = validationTypes; + } + + /// + /// 构造函数 + /// + /// + public DataValidationAttribute(params object[] validationTypes) + { + ValidationPattern = ValidationPattern.AllOfThem; + ValidationTypes = validationTypes; + } + + /// + /// 验证逻辑 + /// + /// + /// + /// + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + // 判断是否允许 空值 + if (AllowNullValue && value == null) return ValidationResult.Success; + + // 是否忽略空字符串 + if (AllowEmptyStrings && value is string && string.IsNullOrEmpty(value?.ToString())) return ValidationResult.Success; + + // 执行值验证 + var dataValidationResult = value.TryValidate(ValidationPattern, ValidationTypes); + dataValidationResult.MemberOrValue = validationContext.DisplayName ?? validationContext.MemberName; + + // 验证失败 + if (!dataValidationResult.IsValid) + { + var resultMessage = dataValidationResult.ValidationResults.FirstOrDefault().ErrorMessage; + + // 进行多语言处理 + var errorMessage = !string.IsNullOrWhiteSpace(ErrorMessage) ? ErrorMessage : resultMessage; + + //TODO: 修改为类型本地化 + return new ValidationResult(string.Format(App.StringLocalizerFactory == null ? errorMessage : App.CreateLocalizerByType(validationContext.ObjectType)[errorMessage], validationContext.DisplayName ?? validationContext.MemberName)); + } + + // 验证成功 + return ValidationResult.Success; + } + + /// + /// 验证类型 + /// + public object[] ValidationTypes { get; set; } + + /// + /// 验证逻辑 + /// + public ValidationPattern ValidationPattern { get; set; } + + /// + ///是否允许空字符串 + /// + public bool AllowEmptyStrings { get; set; } = false; + + /// + /// 允许空值,有值才验证,默认 false + /// + public bool AllowNullValue { get; set; } = false; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/NonValidationAttribute.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/NonValidationAttribute.cs new file mode 100644 index 000000000..e66d969a5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/NonValidationAttribute.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 跳过验证 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class NonValidationAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationItemMetadataAttribute.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationItemMetadataAttribute.cs new file mode 100644 index 000000000..ad50d831b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationItemMetadataAttribute.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.RegularExpressions; + +namespace ThingsGateway.DataValidation; + +/// +/// 验证项元数据 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Field)] +public sealed class ValidationItemMetadataAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// 正则表达式 + /// 失败提示默认消息 + /// 正则表达式匹配选项 + public ValidationItemMetadataAttribute(string regularExpression, string defaultErrorMessage, RegexOptions regexOptions = RegexOptions.None) + { + RegularExpression = regularExpression; + DefaultErrorMessage = defaultErrorMessage; + RegexOptions = regexOptions; + } + + /// + /// 正则表达式 + /// + public string RegularExpression { get; set; } + + /// + /// 默认验证失败类型 + /// + public string DefaultErrorMessage { get; set; } + + /// + /// 正则表达式选项 + /// + public RegexOptions RegexOptions { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationMessageAttribute.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationMessageAttribute.cs new file mode 100644 index 000000000..1fc7467a3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationMessageAttribute.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DataValidation; + +/// +/// 验证消息特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Field)] +public sealed class ValidationMessageAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// + public ValidationMessageAttribute(string errorMessage) + { + ErrorMessage = errorMessage; + } + + /// + /// 错误消息 + /// + public string ErrorMessage { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationMessageTypeAttribute.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationMessageTypeAttribute.cs new file mode 100644 index 000000000..dd083d3fe --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationMessageTypeAttribute.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DataValidation; + +/// +/// 验证消息类型特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Enum)] +public sealed class ValidationMessageTypeAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationTypeAttribute.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationTypeAttribute.cs new file mode 100644 index 000000000..0fd2916f4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Attributes/ValidationTypeAttribute.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DataValidation; + +/// +/// 验证类型特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Enum)] +public sealed class ValidationTypeAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Enums/ValidationPattern.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Enums/ValidationPattern.cs new file mode 100644 index 000000000..02bfd632d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Enums/ValidationPattern.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace System.ComponentModel.DataAnnotations; + +/// +/// 验证逻辑 +/// +[SuppressSniffer] +public enum ValidationPattern +{ + /// + /// 全部都要验证通过 + /// + [Description("全部验证通过才为真")] + AllOfThem, + + /// + /// 至少一个验证通过 + /// + [Description("有一个通过就为真")] + AtLeastOne +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Enums/ValidationTypes.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Enums/ValidationTypes.cs new file mode 100644 index 000000000..fee3b985d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Enums/ValidationTypes.cs @@ -0,0 +1,352 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel; +using System.Text.RegularExpressions; + +namespace ThingsGateway.DataValidation; + +/// +/// 验证类型 +/// +[ValidationType] +public enum ValidationTypes +{ + /// + /// 数值类型 + /// + /// 表达式:^\+?(:?(:?\d+\.\d+)?$|(:?\d+))?$|(-?\d+)(\.\d+)?$ + /// + /// + [Description("数值类型"), ValidationItemMetadata(@"^\+?(:?(:?\d+\.\d+)?$|(:?\d+))?$|(-?\d+)(\.\d+)?$", "The value is not a numeric type.")] + Numeric, + + /// + /// 正数 + /// + /// 表达式:^(0\.0*[1-9]+[0-9]*$|[1-9]+[0-9]*\.[0-9]*[0-9]$|[1-9]+[0-9]*$) + /// + /// + [Description("正数"), ValidationItemMetadata(@"^(0\.0*[1-9]+[0-9]*$|[1-9]+[0-9]*\.[0-9]*[0-9]$|[1-9]+[0-9]*$)", "The value is not a positive number type.")] + PositiveNumber, + + /// + /// 负数 + /// + /// 表达式:^-(0\.0*[1-9]+[0-9]*$|[1-9]+[0-9]*\.[0-9]*[0-9]$|[1-9]+[0-9]*$) + /// + /// + [Description("负数"), ValidationItemMetadata(@"^-(0\.0*[1-9]+[0-9]*$|[1-9]+[0-9]*\.[0-9]*[0-9]$|[1-9]+[0-9]*$)", "The value is not a negative number type.")] + NegativeNumber, + + /// + /// 整数 + /// + /// 表达式:^-?[1-9]+[0-9]*$|^0$ + /// + /// + [Description("整数"), ValidationItemMetadata(@"^-?[1-9]+[0-9]*$|^0$", "The value is not a integer type.")] + Integer, + + /// + /// 金钱类型 + /// + /// 表达式:^(([0-9]|([1-9][0-9]{0,9}))((\.[0-9]{1,2})?))$ + /// + /// + [Description("金钱类型"), ValidationItemMetadata(@"^(([0-9]|([1-9][0-9]{0,9}))((\.[0-9]{1,2})?))$", "The value is not a money type.")] + Money, + + /// + /// 日期类型 + /// + /// 表达式:^(?:(?:1[6-9]|[2-9][0-9])[0-9]{2}([-/.]?)(?:(?:0?[1-9]|1[0-2])\1(?:0?[1-9]|1[0-9]|2[0-8])|(?:0?[13-9]|1[0-2])\1(?:29|30)|(?:0?[13578]|1[02])\1(?:31))|(?:(?:1[6-9]|[2-9][0-9])(?:0[48]|[2468][048]|[13579][26])|(?:16|[2468][048]|[3579][26])00)([-/.]?)0?2\2(?:29))(\s+([01][0-9]:|2[0-3]:)?[0-5][0-9]:[0-5][0-9])?$ + /// + /// + [Description("日期类型"), ValidationItemMetadata(@"^(?:(?:1[6-9]|[2-9][0-9])[0-9]{2}([-/.]?)(?:(?:0?[1-9]|1[0-2])\1(?:0?[1-9]|1[0-9]|2[0-8])|(?:0?[13-9]|1[0-2])\1(?:29|30)|(?:0?[13578]|1[02])\1(?:31))|(?:(?:1[6-9]|[2-9][0-9])(?:0[48]|[2468][048]|[13579][26])|(?:16|[2468][048]|[3579][26])00)([-/.]?)0?2\2(?:29))(\s+([01][0-9]?:|2[0-3]:)?[0-5][0-9]:[0-5][0-9])?$", "The value is not a date type.")] + Date, + + /// + /// 时间类型 + /// + /// 表达式:^(\d{1,2})(:)?(\d{1,2})\2(\d{1,2})$ + /// + /// + [Description("时间类型"), ValidationItemMetadata(@"^(\d{1,2})(:)?(\d{1,2})\2(\d{1,2})$", "The value is not a time type.")] + Time, + + /// + /// 身份证号码 + /// + /// 表达式:(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$) + /// + /// + [Description("身份证号码"), ValidationItemMetadata(@"(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)", "The value is not a idcard type.")] + IDCard, + + /// + /// 邮政编码 + /// + /// 表达式:^[0-9]{6}$ + /// + /// + [Description("邮政编码"), ValidationItemMetadata(@"^[0-9]{6}$", "The value is not a postcode type.")] + PostCode, + + /// + /// 手机号码 + /// + /// 表达式:^1[3456789][0-9]{9}$ + /// + /// + [Description("手机号码"), ValidationItemMetadata(@"^1[3456789][0-9]{9}$", "The value is not a phone number type.")] + PhoneNumber, + + /// + /// 固话格式 + /// + /// 表达式:(^[0-9]{3,4}\-[0-9]{3,8}$)|(^[0-9]{3,8}$)|(^\([0-9]{3,4}\)[0-9]{3,8}$)|(^0{0,1}13[0-9]{9}$) + /// + /// + [Description("固话格式"), ValidationItemMetadata(@"(^[0-9]{3,4}\-[0-9]{3,8}$)|(^[0-9]{3,8}$)|(^\([0-9]{3,4}\)[0-9]{3,8}$)|(^0{0,1}13[0-9]{9}$)", "The value is not a telephone type.")] + Telephone, + + /// + /// 手机或固话类型 + /// + /// 表达式:(^1[3456789][0-9]{9}$)|((^[0-9]{3,4}\-[0-9]{3,8}$)|(^[0-9]{3,8}$)|(^\([0-9]{3,4}\)[0-9]{3,8}$)|(^0{0,1}13[0-9]{9}$)) + /// + /// + [Description("手机或固话类型"), ValidationItemMetadata(@"(^1[3456789][0-9]{9}$)|((^[0-9]{3,4}\-[0-9]{3,8}$)|(^[0-9]{3,8}$)|(^\([0-9]{3,4}\)[0-9]{3,8}$)|(^0{0,1}13[0-9]{9}$))", "The value is not a phone number or telephone type.", RegexOptions.IgnoreCase)] + PhoneOrTelNumber, + + /// + /// 邮件类型 + /// + /// 表达式:^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$ + /// + /// + [Description("邮件类型"), ValidationItemMetadata(@"^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$", "The value is not a email address type.")] + EmailAddress, + + /// + /// 网址类型 + /// + /// 表达式:^(((ht|f)tps?):\/\/)?([^!@#$%^与*?.\s-]([^!@#$%^与*?.\s]{0,63}[^!@#$%^与*?.\s])?\.)+[a-z]{2,6}\/? + /// + /// + [Description("网址类型"), ValidationItemMetadata(@"^(((ht|f)tps?):\/\/)?([^!@#$%^&*?.\s-]([^!@#$%^&*?.\s]{0,63}[^!@#$%^&*?.\s])?\.)+[a-z]{2,6}\/?", "The value is not a url address type")] + Url, + + /// + /// 颜色类型 + /// + /// 表达式:^(?:#(?:(?:[0-9a-fA-F]{3}){1,2}|[0-9a-fA-F]{8})|rgba?\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*(?:,\s*[0-9.]+\s*)?)?\)|hsla?\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*(?:,\s*[0-9.]+\s*)?)?\)|hwb\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*)?\)|lch\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*)?\)|oklch\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*)?\)|lab\((?:\s*[-+]?\d+\%?\s*,){2}\s*[-+]?\d+\%?\s*\)|oklab\((?:\s*[-+]?\d+\%?\s*,){2}\s*[-+]?\d+\%?\s*\))$ + /// + /// + [Description("颜色类型"), ValidationItemMetadata(@"^(?:#(?:(?:[0-9a-fA-F]{3}){1,2}|[0-9a-fA-F]{8})|rgba?\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*(?:,\s*[0-9.]+\s*)?)?\)|hsla?\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*(?:,\s*[0-9.]+\s*)?)?\)|hwb\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*)?\)|lch\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*)?\)|oklch\((?:\s*\d+\%?\s*,){2}\s*(?:\d+\%?\s*)?\)|lab\((?:\s*[-+]?\d+\%?\s*,){2}\s*[-+]?\d+\%?\s*\)|oklab\((?:\s*[-+]?\d+\%?\s*,){2}\s*[-+]?\d+\%?\s*\))$", "The value is not a color type.", RegexOptions.IgnoreCase)] + Color, + + /// + /// 中文 + /// + /// 表达式:^[\u4e00-\u9fa5]+$ + /// + /// + [Description("中文"), ValidationItemMetadata(@"^[\u4e00-\u9fa5]+$", "The value is not a chinese type.")] + Chinese, + + /// + /// IPv4 类型 + /// + /// 表达式:^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})$ + /// + /// + [Description("IPv4 类型"), ValidationItemMetadata(@"^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[0-9]{1,2})$", "The value is not a IPv4 type.")] + IPv4, + + /// + /// IPv6 类型 + /// + /// 表达式:/^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$ + /// + /// + [Description("IPv6 类型"), ValidationItemMetadata(@"/^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$", "The value is not a IPv6 type.")] + IPv6, + + /// + /// 年龄 + /// + /// 表达式:^[1-99]?\d*$ + /// + /// + [Description("年龄"), ValidationItemMetadata(@"^[1-99]?\d*$", "The value is not a age type.")] + Age, + + /// + /// 中文名 + /// + /// 表达式:^[\u0391-\uFFE5]{2,15}$ + /// + /// + [Description("中文名"), ValidationItemMetadata(@"^[\u0391-\uFFE5]{2,15}$", "The value is not a chinese name type.")] + ChineseName, + + /// + /// 英文名 + /// + /// 表达式:^[A-Za-z]{1,161}$ + /// + /// + [Description("英文名"), ValidationItemMetadata(@"^[A-Za-z]{1,161}$", "The value is not a english name type.")] + EnglishName, + + /// + /// 纯大写 + /// + /// 表达式:^[A-Z]+$ + /// + /// + [Description("纯大写"), ValidationItemMetadata(@"^[A-Z]+$", "The value is not a capital type.")] + Capital, + + /// + /// 纯小写 + /// + /// 表达式:^[a-z]+$ + /// + /// + [Description("纯小写"), ValidationItemMetadata(@"^[a-z]+$", "The value is not a lowercase type.")] + Lowercase, + + /// + /// ASCII 编码 + /// + /// 表达式:^[\x00-\xFF]+$ + /// + /// + [Description("ASCII 编码"), ValidationItemMetadata(@"^[\x00-\xFF]+$", "The value is not a ascii type.")] + Ascii, + + /// + /// MD5 加密字符串 + /// + /// 表达式:^([a-fA-F0-9]{32})$ + /// + /// + [Description("MD5 加密字符串"), ValidationItemMetadata(@"^([a-fA-F0-9]{32})$", "The value is not a md5 type.")] + Md5, + + /// + /// 压缩文件格式 + /// + /// 表达式:(.*)\.(rar|zip|7zip|tgz)$ + /// + /// + [Description("压缩文件格式"), ValidationItemMetadata(@"(.*)\.(rar|zip|7zip|tgz)$", "The value is not a zip type.")] + Zip, + + /// + /// 图片格式 + /// + /// 表达式:(.*)\.(jpg|gif|ico|jpeg|png)$ + /// + /// + [Description("图片格式"), ValidationItemMetadata(@"(.*)\.(jpg|gif|ico|jpeg|png)$", "The value is not a image type.")] + Image, + + /// + /// 文档格式 + /// + /// 表达式:(.*)\.(doc|xls|docx|xlsx|pdf|md)$ + /// + /// + [Description("文档格式"), ValidationItemMetadata(@"(.*)\.(doc|xls|docx|xlsx|pdf|md)$", "The value is not a document type.")] + Document, + + /// + /// MP3 格式 + /// + /// 表达式:(.*)\.(mp3)$ + /// + /// + [Description("MP3 格式"), ValidationItemMetadata(@"(.*)\.(mp3)$", "The value is not a mp3 type.")] + Mp3, + + /// + /// Flash 格式 + /// + /// 表达式:(.*)\.(swf|fla|flv)$ + /// + /// + [Description("Flash 格式"), ValidationItemMetadata(@"(.*)\.(swf|fla|flv)$", "The value is not a flash type.")] + Flash, + + /// + /// 视频文件格式 + /// + /// 表达式:(.*)\.(rm|rmvb|wmv|avi|mp4|3gp|mkv)$ + /// + /// + [Description("视频文件格式"), ValidationItemMetadata(@"(.*)\.(rm|rmvb|wmv|avi|mp4|3gp|mkv)$", "The value is not a video type.")] + Video, + + /// + /// 字母加数字组合 + /// + /// 表达式:^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]*$ + /// + /// + [Description("字母和数字组合"), ValidationItemMetadata(@"^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]*$", "The value is not a combination of letters and numbers.")] + WordWithNumber, + + /// + /// Html 标签格式 + /// + /// 表达式:lt(\w+)[^gt]*>(.*?lt\/\1gt)? + /// + /// + [Description("Html 标签格式"), ValidationItemMetadata(@"<(\w+)[^>]*>(.*?<\/\1>)?", "The value is not a html tag.")] + Html, + + /// + /// 手机机身码 + /// + [Description("手机机身码"), ValidationItemMetadata(@"^\d{15,17}$", "The value is not a IMEI type.")] + IMEI, + + /// + /// 统一社会信用代码 + /// + [Description("统一社会信用代码"), ValidationItemMetadata(@"^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$", "The value is not a social credit code type.")] + SocialCreditCode, + + /// + /// GUID 或者 UUID + /// + [Description("GUID 或者 UUID"), ValidationItemMetadata(@"^[a-fA-F\d]{4}(?:[a-fA-F\d]{4}-){4}[a-fA-F\d]{12}$", "The value is not a GUID or UUID type.")] + GUID_OR_UUID, + + /// + /// base64 格式 + /// + [Description("base64 格式"), ValidationItemMetadata(@"^\s*data:(?:[a-z]+\/[a-z0-9-+.]+(?:;[a-z-]+=[a-z0-9-]+)?)?(?:;base64)?,([a-z0-9!$&',()*+;=\-._~:@/?%\s]*?)\s*$", "The value is not a base64 type.")] + Base64, + + /// + /// 用户名 + /// + /// 表达式:^[a-zA-Z][a-zA-Z0-9_]{3,18}[a-zA-Z0-9]$ + /// + /// + [Description("用户名"), ValidationItemMetadata(@"^[a-zA-Z][a-zA-Z0-9_]{3,18}[a-zA-Z0-9]$", "The value is not a username type.")] + Username, +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Extensions/DataValidationExtensions.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Extensions/DataValidationExtensions.cs new file mode 100644 index 000000000..eea3a3048 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Extensions/DataValidationExtensions.cs @@ -0,0 +1,154 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; + +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.DataValidation; + +/// +/// 数据验证拓展类 +/// +[SuppressSniffer] +public static class DataValidationExtensions +{ + /// + /// 拓展方法,验证类类型对象 + /// + /// 对象实例 + /// 是否验证所有属性 + /// 验证结果 + public static DataValidationResult TryValidate(this object obj, bool validateAllProperties = true) + { + return DataValidator.TryValidateObject(obj, validateAllProperties); + } + + /// + /// 拓展方法,验证单个值 + /// + /// 单个值 + /// 验证特性 + /// + public static DataValidationResult TryValidate(this object value, params ValidationAttribute[] validationAttributes) + { + return DataValidator.TryValidateValue(value, validationAttributes); + } + + /// + /// 拓展方法,验证单个值 + /// + /// 单个值 + /// 验证类型 + /// + public static DataValidationResult TryValidate(this object value, params object[] validationTypes) + { + return DataValidator.TryValidateValue(value, validationTypes); + } + + /// + /// 拓展方法,验证单个值 + /// + /// 单个值 + /// 验证逻辑 + /// 验证类型 + /// + public static DataValidationResult TryValidate(this object value, ValidationPattern validationOptionss, params object[] validationTypes) + { + return DataValidator.TryValidateValue(value, validationOptionss, validationTypes); + } + + /// + /// 拓展方法,验证类类型对象 + /// + /// 对象实例 + /// 是否验证所有属性 + public static void Validate(this object obj, bool validateAllProperties = true) + { + DataValidator.TryValidateObject(obj, validateAllProperties).ThrowValidateFailedModel(); + } + + /// + /// 拓展方法,验证单个值 + /// + /// 单个值 + /// 验证特性 + public static void Validate(this object value, params ValidationAttribute[] validationAttributes) + { + DataValidator.TryValidateValue(value, validationAttributes).ThrowValidateFailedModel(); + } + + /// + /// 拓展方法,验证单个值 + /// + /// 单个值 + /// 验证类型 + public static void Validate(this object value, params object[] validationTypes) + { + DataValidator.TryValidateValue(value, validationTypes).ThrowValidateFailedModel(); + } + + /// + /// 拓展方法,验证单个值 + /// + /// 单个值 + /// 验证逻辑 + /// 验证类型 + public static void Validate(this object value, ValidationPattern validationOptionss, params object[] validationTypes) + { + DataValidator.TryValidateValue(value, validationOptionss, validationTypes).ThrowValidateFailedModel(); + } + + /// + /// 拓展方法,验证单个值 + /// + /// 单个值 + /// 正则表达式 + /// 正则表达式选项 + /// + public static bool TryValidate(this object value, string regexPattern, RegexOptions regexOptions = RegexOptions.None) + { + return DataValidator.TryValidateValue(value, regexPattern, regexOptions); + } + + /// + /// 直接抛出异常信息 + /// + /// + public static void ThrowValidateFailedModel(this DataValidationResult dataValidationResult) + { + if (!dataValidationResult.IsValid) + { + // 解析验证失败消息,输出统一格式 + var validationFailMessage = + dataValidationResult.ValidationResults + .Select(u => new + { + MemberNames = u.MemberNames.Any() ? u.MemberNames : new[] { $"{dataValidationResult.MemberOrValue}" }, + u.ErrorMessage + }) + .OrderBy(u => u.MemberNames.First()) + .GroupBy(u => u.MemberNames.First()) + .ToDictionary(x => x.Key, u => u.Select(c => c.ErrorMessage).ToArray()); + + // 抛出验证失败异常 + throw new AppFriendlyException(default, default, new ValidationException()) + { + StatusCode = StatusCodes.Status400BadRequest, + ValidationException = true, + ErrorMessage = validationFailMessage, + }; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Extensions/DataValidationServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Extensions/DataValidationServiceCollectionExtensions.cs new file mode 100644 index 000000000..ae92a99ec --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Extensions/DataValidationServiceCollectionExtensions.cs @@ -0,0 +1,122 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +using ThingsGateway.DataValidation; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 友好异常服务拓展类 +/// +[SuppressSniffer] +public static class DataValidationServiceCollectionExtensions +{ + /// + /// 添加全局数据验证 + /// + /// 验证类型消息提供器 + /// + /// + /// + public static IMvcBuilder AddDataValidation(this IMvcBuilder mvcBuilder, Action configure = null) + where TValidationMessageTypeProvider : class, IValidationMessageTypeProvider + { + // 添加全局数据验证 + mvcBuilder.Services.AddDataValidation(configure); + + return mvcBuilder; + } + + /// + /// 添加全局数据验证 + /// + /// 验证类型消息提供器 + /// + /// + /// + public static IServiceCollection AddDataValidation(this IServiceCollection services, Action configure = null) + where TValidationMessageTypeProvider : class, IValidationMessageTypeProvider + { + // 添加全局数据验证 + services.AddDataValidation(configure); + + // 单例注册验证消息提供器 + services.TryAddSingleton(); + + return services; + } + + /// + /// 添加全局数据验证 + /// + /// + /// + /// + public static IMvcBuilder AddDataValidation(this IMvcBuilder mvcBuilder, Action configure = null) + { + mvcBuilder.Services.AddDataValidation(configure); + + return mvcBuilder; + } + + /// + /// 添加全局数据验证 + /// + /// + /// + /// + public static IServiceCollection AddDataValidation(this IServiceCollection services, Action configure = null) + { + // 解决服务重复注册问题 + if (services.Any(u => u.ServiceType == typeof(IConfigureOptions))) + { + return services; + } + + // 添加验证配置文件支持 + services.AddConfigurableOptions(); + + // 载入服务配置选项 + var configureOptions = new DataValidationOptions(); + configure?.Invoke(configureOptions); + + // 判断是否启用全局 + if (configureOptions.GlobalEnabled) + { + // 启用了全局验证,则默认关闭原生 ModelStateInvalidFilter 验证 + services.Configure(options => + { + options.SuppressMapClientErrors = configureOptions.SuppressMapClientErrors; + options.SuppressModelStateInvalidFilter = configureOptions.SuppressModelStateInvalidFilter; + }); + + // 添加全局数据验证 + services.AddMvcFilter(options => + { + // 关闭空引用对象验证 + options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = configureOptions.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes; + }); + + // 添加全局数据验证(Razor Pages) + services.AddMvcFilter(options => + { + // 关闭空引用对象验证 + options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = configureOptions.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes; + }); + } + + return services; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Extensions/Options/DataValidationOptions.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Extensions/Options/DataValidationOptions.cs new file mode 100644 index 000000000..3869f83b8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Extensions/Options/DataValidationOptions.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DataValidation; + +/// +/// AddInject 数据验证配置选项 +/// +public sealed class DataValidationOptions +{ + /// + /// 启用全局数据验证 + /// + public bool GlobalEnabled { get; set; } = true; + + /// + /// 禁止C# 8.0 验证非可空引用类型 + /// + public bool SuppressImplicitRequiredAttributeForNonNullableReferenceTypes { get; set; } = true; + + /// + /// 是否禁用模型验证过滤器 + /// + /// 只会改变启用全局验证的情况,也就是 为 true 的情况 + public bool SuppressModelStateInvalidFilter { get; set; } = true; + + /// + /// 是否禁用映射异常 + /// + public bool SuppressMapClientErrors { get; set; } = false; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Filters/DataValidationFilter.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Filters/DataValidationFilter.cs new file mode 100644 index 000000000..ce5d6c8b2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Filters/DataValidationFilter.cs @@ -0,0 +1,208 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; + +using System.Reflection; + +using ThingsGateway.DynamicApiController; +using ThingsGateway.FriendlyException; +using ThingsGateway.UnifyResult; + +namespace ThingsGateway.DataValidation; + +/// +/// 数据验证拦截器 +/// +[SuppressSniffer] +public sealed class DataValidationFilter : IAsyncActionFilter, IOrderedFilter +{ + /// + /// Api 行为配置选项 + /// + private readonly ApiBehaviorOptions _apiBehaviorOptions; + + /// + /// 规范化配置选项 + /// + private readonly UnifyResultSettingsOptions _unifyResultSettingsOptions; + + /// + /// 构造函数 + /// + /// + /// + public DataValidationFilter(IOptions options + , IOptions unifyResultSettingsOptions) + { + _apiBehaviorOptions = options.Value; + _unifyResultSettingsOptions = unifyResultSettingsOptions.Value; + } + + /// + /// 过滤器排序 + /// + private const int FilterOrder = -1000; + + /// + /// 排序属性 + /// + public int Order => FilterOrder; + + /// + /// 是否是可重复使用的 + /// + public static bool IsReusable => true; + + /// + /// 拦截请求 + /// + /// 动作方法上下文 + /// 中间件委托 + /// + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + // 排除 WebSocket 请求处理 + if (context.HttpContext.IsWebSocketRequest()) + { + await next().ConfigureAwait(false); + return; + } + + // 获取控制器/方法信息 + var actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; + + // 跳过验证类型 + var nonValidationAttributeType = typeof(NonValidationAttribute); + var method = actionDescriptor.MethodInfo; + + // 获取验证状态 + var modelState = context.ModelState; + + // 如果参数数量为 0 或贴了 [NonValidation] 特性 或所在类型贴了 [NonValidation] 特性或验证成功或已经设置了结果,则跳过验证 + if (actionDescriptor.Parameters.Count == 0 || + method.IsDefined(nonValidationAttributeType, true) || + method.DeclaringType.IsDefined(nonValidationAttributeType, true) || + modelState.IsValid || + method.DeclaringType.Assembly.GetName().Name.StartsWith("Microsoft.AspNetCore.OData") || + context.Result != null) + { + await CallUnHandleResult(context, next, actionDescriptor, method).ConfigureAwait(false); + return; + } + + // 处理执行前验证信息 + var handledResult = HandleValidation(context, method, actionDescriptor, modelState); + + // 处理 Mvc 未处理结果情况 + if (!handledResult) + { + await CallUnHandleResult(context, next, actionDescriptor, method).ConfigureAwait(false); + } + } + + /// + /// 调用未处理的结果类型 + /// + /// + /// + /// + /// + /// + private async Task CallUnHandleResult(ActionExecutingContext context, ActionExecutionDelegate next, ControllerActionDescriptor actionDescriptor, MethodInfo method) + { + // 处理执行后验证信息 + var resultContext = await next().ConfigureAwait(false); + + // 如果异常不为空且属于友好验证异常 + if (resultContext.Exception != null && resultContext.Exception is AppFriendlyException friendlyException && friendlyException.ValidationException) + { + // 存储验证执行结果 + context.HttpContext.Items[nameof(DataValidationFilter) + nameof(AppFriendlyException)] = resultContext; + + // 处理验证信息 + _ = HandleValidation(context, method, actionDescriptor, friendlyException.ErrorMessage, resultContext, friendlyException); + } + } + + /// + /// 内部处理异常 + /// + /// + /// + /// + /// + /// + /// + /// 返回 false 表示结果没有处理 + private bool HandleValidation(ActionExecutingContext context, MethodInfo method, ControllerActionDescriptor actionDescriptor, object errors, ActionExecutedContext resultContext = default, AppFriendlyException friendlyException = default) + { + dynamic finalContext = resultContext != null ? resultContext : context; + + // 解析验证消息 + var validationMetadata = ValidatorContext.GetValidationMetadata(errors); + validationMetadata.ErrorCode = friendlyException?.ErrorCode; + validationMetadata.OriginErrorCode = friendlyException?.OriginErrorCode; + validationMetadata.StatusCode = friendlyException?.StatusCode; + validationMetadata.Data = friendlyException?.Data; + validationMetadata.SingleValidationErrorDisplay = _unifyResultSettingsOptions.SingleValidationErrorDisplay ?? false; + + // 存储验证信息 + context.HttpContext.Items[nameof(DataValidationFilter) + nameof(ValidationMetadata)] = validationMetadata; + + // 判断是否跳过规范化结果,如果跳过,返回 400 BadRequestResult + if (UnifyContext.CheckFailedNonUnify(actionDescriptor.MethodInfo, out var unifyResult)) + { + // WebAPI 情况 + if (Penetrates.IsApiController(method.DeclaringType)) + { + // 如果不启用 SuppressModelStateInvalidFilter,则跳过,理应手动验证 + if (!_apiBehaviorOptions.SuppressModelStateInvalidFilter) + { + finalContext.Result = _apiBehaviorOptions.InvalidModelStateResponseFactory(context); + } + else + { + // 返回 JsonResult + finalContext.Result = new JsonResult(validationMetadata.ValidationResult) + { + StatusCode = StatusCodes.Status400BadRequest + }; + } + } + else + { + // 返回自定义错误页面 + finalContext.Result = new BadPageResult(StatusCodes.Status400BadRequest) + { + Code = validationMetadata.Message + }; + } + } + else + { + // 判断是否支持 MVC 规范化处理,一旦启用,则自动调用规范化提供器进行操作,这里返回 false 表示没有处理结果 + if (!UnifyContext.CheckSupportMvcController(context.HttpContext, actionDescriptor, out _) + || UnifyContext.CheckHttpContextNonUnify(context.HttpContext)) return false; + + finalContext.Result = unifyResult.OnValidateFailed(context, validationMetadata); + } + + // 打印验证失败信息 + App.PrintToMiniProfiler("validation", "Failed", $"Validation Failed:\r\n\r\n{validationMetadata.Message}", true); + + return true; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Filters/DataValidationPageFilter.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Filters/DataValidationPageFilter.cs new file mode 100644 index 000000000..d48b8263f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Filters/DataValidationPageFilter.cs @@ -0,0 +1,170 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; + +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.DataValidation; + +/// +/// 数据验证拦截器(Razor Pages) +/// +[SuppressSniffer] +public sealed class DataValidationPageFilter : IAsyncPageFilter, IOrderedFilter +{ + /// + /// Api 行为配置选项 + /// + private readonly ApiBehaviorOptions _apiBehaviorOptions; + + /// + /// 构造函数 + /// + /// + public DataValidationPageFilter(IOptions options) + { + _apiBehaviorOptions = options.Value; + } + + /// + /// 过滤器排序 + /// + private const int FilterOrder = -1000; + + /// + /// 排序属性 + /// + public int Order => FilterOrder; + + /// + /// 是否是可重复使用的 + /// + public static bool IsReusable => true; + + /// + /// 模型绑定拦截 + /// + /// + /// + public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context) + { + return Task.CompletedTask; + } + + /// + /// 拦截请求 + /// + /// + /// + /// + public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + // 排除 WebSocket 请求处理 + if (context.HttpContext.IsWebSocketRequest()) + { + await next.Invoke().ConfigureAwait(false); + return; + } + + // 跳过验证类型 + var nonValidationAttributeType = typeof(NonValidationAttribute); + var method = context.HandlerMethod?.MethodInfo; + // 处理 Blazor Server + if (method == null) + { + await CallUnHandleResult(context, next).ConfigureAwait(false); + return; + } + + // 获取验证状态 + var modelState = context.ModelState; + + // 如果参数数量为 0 或贴了 [NonValidation] 特性 或所在类型贴了 [NonValidation] 特性或验证成功或已经设置了结果,则跳过验证 + if (context.HandlerArguments.Count == 0 || + method.IsDefined(nonValidationAttributeType, true) || + method.DeclaringType.IsDefined(nonValidationAttributeType, true) || + modelState.IsValid || + context.Result != null) + { + await CallUnHandleResult(context, next).ConfigureAwait(false); + return; + } + + // 处理执行前验证信息 + var handledResult = HandleValidation(context, modelState); + + // 处理 Mvc 未处理结果情况 + if (!handledResult) + { + await CallUnHandleResult(context, next).ConfigureAwait(false); + } + } + + /// + /// 调用未处理的结果类型 + /// + /// + /// + /// + private async Task CallUnHandleResult(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + // 处理执行后验证信息 + var resultContext = await next.Invoke().ConfigureAwait(false); + + // 如果异常不为空且属于友好验证异常 + if (resultContext.Exception != null && resultContext.Exception is AppFriendlyException friendlyException && friendlyException.ValidationException) + { + // 存储验证执行结果 + context.HttpContext.Items[nameof(DataValidationFilter) + nameof(AppFriendlyException)] = resultContext; + + // 处理验证信息 + _ = HandleValidation(context, friendlyException.ErrorMessage, resultContext, friendlyException); + } + } + + /// + /// 内部处理异常 + /// + /// + /// + /// + /// + /// 返回 false 表示结果没有处理 + private bool HandleValidation(PageHandlerExecutingContext context, object errors, PageHandlerExecutedContext resultContext = default, AppFriendlyException friendlyException = default) + { + dynamic finalContext = resultContext != null ? resultContext : context; + + // 解析验证消息 + var validationMetadata = ValidatorContext.GetValidationMetadata(errors); + validationMetadata.ErrorCode = friendlyException?.ErrorCode; + validationMetadata.OriginErrorCode = friendlyException?.OriginErrorCode; + validationMetadata.StatusCode = friendlyException?.StatusCode; + validationMetadata.Data = friendlyException?.Data; + + // 存储验证信息 + context.HttpContext.Items[nameof(DataValidationFilter) + nameof(ValidationMetadata)] = validationMetadata; + + // 返回自定义错误页面 + finalContext.Result = new BadPageResult(StatusCodes.Status400BadRequest) + { + Code = validationMetadata.Message + }; + + // 打印验证失败信息 + App.PrintToMiniProfiler("validation", "Failed", $"Validation Failed:\r\n\r\n{validationMetadata.Message}", true); + + return true; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Internal/DataValidationResult.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Internal/DataValidationResult.cs new file mode 100644 index 000000000..bcf25f688 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Internal/DataValidationResult.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.DataValidation; + +/// +/// 数据验证结果 +/// +[SuppressSniffer] +public sealed class DataValidationResult +{ + /// + /// 验证状态 + /// + public bool IsValid { get; set; } + + /// + /// 验证结果 + /// + public ICollection ValidationResults { get; set; } + + /// + /// 成员或值 + /// + public object MemberOrValue { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Internal/ValidationMetadata.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Internal/ValidationMetadata.cs new file mode 100644 index 000000000..2d28515fe --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Internal/ValidationMetadata.cs @@ -0,0 +1,71 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace ThingsGateway.DataValidation; + +/// +/// 验证信息元数据 +/// +public sealed class ValidationMetadata +{ + /// + /// 验证结果 + /// + /// 返回字典或字符串类型 + public object ValidationResult { get; internal set; } + + /// + /// 异常消息 + /// + public string Message { get; internal set; } + + /// + /// 验证状态 + /// + public ModelStateDictionary ModelState { get; internal set; } + + /// + /// 错误码 + /// + public object ErrorCode { get; internal set; } + + /// + /// 错误码(没被复写过的 ErrorCode ) + /// + public object OriginErrorCode { get; internal set; } + + /// + /// 状态码 + /// + public int? StatusCode { get; internal set; } + + /// + /// 首个错误属性 + /// + public string FirstErrorProperty { get; internal set; } + + /// + /// 首个错误消息 + /// + public string FirstErrorMessage { get; internal set; } + + /// + /// 额外数据 + /// + public object Data { get; internal set; } + + /// + /// 默认只显示验证错误的首个消息 + /// + public bool SingleValidationErrorDisplay { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Options/ValidationTypeMessageSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Options/ValidationTypeMessageSettingsOptions.cs new file mode 100644 index 000000000..5d86d745c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Options/ValidationTypeMessageSettingsOptions.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.DataValidation; + +/// +/// 验证消息配置选项 +/// +public sealed class ValidationTypeMessageSettingsOptions : IConfigurableOptions +{ + /// + /// 验证消息配置表 + /// + public object[][] Definitions { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Providers/IValidationMessageTypeProvider.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Providers/IValidationMessageTypeProvider.cs new file mode 100644 index 000000000..dcb9b0261 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Providers/IValidationMessageTypeProvider.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DataValidation; + +/// +/// 验证消息类型提供器 +/// +public interface IValidationMessageTypeProvider +{ + /// + /// 验证消息类型定义 + /// + Type[] Definitions { get; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/ValidatorContext.cs b/src/Admin/ThingsGateway.Furion/DataValidation/ValidatorContext.cs new file mode 100644 index 000000000..acdfc32d2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/ValidatorContext.cs @@ -0,0 +1,84 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace ThingsGateway.DataValidation; + +/// +/// 验证上下文 +/// +internal static class ValidatorContext +{ + private static readonly JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true + }; + + /// + /// 获取验证错误信息 + /// + /// + /// + internal static ValidationMetadata GetValidationMetadata(object errors) + { + ModelStateDictionary _modelState = null; + object validationResults = null; + (string message, string firstErrorMessage, string firstErrorProperty) = (default, default, default); + + // 判断是否是集合类型 + if (errors is IEnumerable && errors is not string) + { + // 如果是模型验证字典类型 + if (errors is ModelStateDictionary modelState) + { + _modelState = modelState; + // 将验证错误信息转换成字典并序列化成 Json + validationResults = modelState.Where(u => modelState[u.Key].ValidationState == ModelValidationState.Invalid) + .ToDictionary(u => u.Key, u => modelState[u.Key].Errors.Select(c => c.ErrorMessage).ToArray()); + } + // 如果是 ValidationProblemDetails 特殊类型 + else if (errors is ValidationProblemDetails validation) + { + validationResults = validation.Errors + .ToDictionary(u => u.Key, u => u.Value.ToArray()); + } + // 如果是字典类型 + else if (errors is Dictionary dicResults) + { + validationResults = dicResults; + } + + message = JsonSerializer.Serialize(validationResults, _jsonSerializerOptions); + firstErrorMessage = (validationResults as Dictionary).First().Value[0]; + firstErrorProperty = (validationResults as Dictionary).First().Key; + } + // 其他类型 + else + { + validationResults = firstErrorMessage = message = errors?.ToString(); + } + + return new ValidationMetadata + { + ValidationResult = validationResults, + Message = message, + ModelState = _modelState, + FirstErrorProperty = firstErrorProperty, + FirstErrorMessage = firstErrorMessage + }; + } +} diff --git a/src/Admin/ThingsGateway.Furion/DataValidation/Validators/DataValidator.cs b/src/Admin/ThingsGateway.Furion/DataValidation/Validators/DataValidator.cs new file mode 100644 index 000000000..da1afcc41 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DataValidation/Validators/DataValidator.cs @@ -0,0 +1,317 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; + +using System.Collections.Concurrent; +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Text.RegularExpressions; + +using ThingsGateway.Extensions; +using ThingsGateway.Templates.Extensions; + +namespace ThingsGateway.DataValidation; + +/// +/// 数据验证器 +/// +[SuppressSniffer] +public static class DataValidator +{ + /// + /// 所有验证类型 + /// + private static readonly IEnumerable ValidationTypes; + + /// + /// 所有验证类型 + /// + private static readonly IEnumerable ValidationMessageTypes; + + /// + /// 验证类型正则表达式 + /// + private static readonly ConcurrentDictionary ValidationItemMetadatas; + + /// + /// 构造函数 + /// + static DataValidator() + { + // 获取所有验证类型 + ValidationTypes = GetValidationTypes(); + + // 获取所有验证消息类型 + ValidationMessageTypes = GetValidationMessageTypes(); + + // 获取所有验证类型正则表达式 + ValidationItemMetadatas = GetValidationValidationItemMetadatas(); + + // 缓存所有正则表达式 + GetValidationTypeValidationItemMetadataCached = new ConcurrentDictionary(); + } + + /// + /// 验证类类型对象 + /// + /// 对象实例 + /// 是否验证所有属性 + /// 验证结果 + public static DataValidationResult TryValidateObject(object obj, bool validateAllProperties = true) + { + // 如果该类型贴有 [NonValidate] 特性,则跳过验证 + if (obj.GetType().IsDefined(typeof(NonValidationAttribute), true)) + return new DataValidationResult + { + IsValid = true, + MemberOrValue = obj + }; + + // 存储验证结果 + ICollection results = new List(); + var isValid = Validator.TryValidateObject(obj, new ValidationContext(obj), results, validateAllProperties); + + // 返回验证结果 + return new DataValidationResult + { + IsValid = isValid, + ValidationResults = results, + MemberOrValue = obj + }; + } + + /// + /// 验证单个值 + /// + /// 单个值 + /// 验证特性 + /// + public static DataValidationResult TryValidateValue(object value, params ValidationAttribute[] validationAttributes) + { + // 存储验证结果 + ICollection results = new List(); + var isValid = Validator.TryValidateValue(value, new ValidationContext(value), results, validationAttributes); + + // 返回验证结果 + return new DataValidationResult + { + IsValid = isValid, + ValidationResults = results, + MemberOrValue = value + }; + } + + /// + /// 正则表达式验证 + /// + /// + /// + /// 正则表达式选项 + /// + public static bool TryValidateValue(object value, string regexPattern, RegexOptions regexOptions = RegexOptions.None) + { + return value == null + ? throw new ArgumentNullException(nameof(value)) + : Regex.IsMatch(value.ToString(), regexPattern, regexOptions); + } + + /// + /// 验证类型验证 + /// + /// + /// + /// + public static DataValidationResult TryValidateValue(object value, params object[] validationTypes) + { + return TryValidateValue(value, ValidationPattern.AllOfThem, validationTypes); + } + + /// + /// 验证类型验证 + /// + /// + /// 验证方式 + /// + /// + public static DataValidationResult TryValidateValue(object value, ValidationPattern validationOptionss, params object[] validationTypes) + { + // 存储验证结果 + var results = new List(); + + // 如果值未null,验证失败 + if (value == null) + { + results.Add(new ValidationResult("The value is required")); + + // 返回验证结果 + return new DataValidationResult + { + IsValid = false, + ValidationResults = results, + MemberOrValue = value + }; + } + + // 验证标识 + bool? isValid = null; + foreach (var validationType in validationTypes) + { + // 解析名称和正则表达式 + var (validationName, validationItemMetadata) = GetValidationTypeValidationItemMetadata(validationType); + + // 验证结果 + var validResult = TryValidateValue(value, validationItemMetadata.RegularExpression, validationItemMetadata.RegexOptions); + + // 判断是否需要同时验证通过才通过 + if (validationOptionss == ValidationPattern.AtLeastOne) + { + // 只要有一个验证通过,则跳出 + if (validResult) + { + isValid = true; + break; + } + } + + if (!validResult) + { + if (isValid != false) isValid = false; + // 添加错误消息 + results.Add(new ValidationResult( + string.Format(validationItemMetadata.DefaultErrorMessage.Render(), value, validationName))); + } + } + + // 返回验证结果 + return new DataValidationResult + { + IsValid = isValid ?? true, + ValidationResults = results, + MemberOrValue = value + }; + } + + /// + /// 获取验证类型验证Item集合 + /// + private static readonly ConcurrentDictionary GetValidationTypeValidationItemMetadataCached; + + /// + /// 获取验证类型正则表达式(需要缓存) + /// + /// + /// + private static (string ValidationName, ValidationItemMetadataAttribute ValidationItemMetadata) GetValidationTypeValidationItemMetadata(object validationType) + { + return GetValidationTypeValidationItemMetadataCached.GetOrAdd(validationType, Function); + + // 本地函数 + static (string, ValidationItemMetadataAttribute) Function(object validationType) + { + // 获取验证类型 + var type = validationType.GetType(); + + // 判断是否是有效的验证类型 + if (!ValidationTypes.Any(u => u == type)) + throw new InvalidOperationException($"{type.Name} is not a valid validation type."); + + // 获取对应的枚举名称 + var validationName = Enum.GetName(type, validationType); + + // 判断是否配置验证正则表达式 + if (!ValidationItemMetadatas.TryGetValue(validationName, out var validationItemMetadataAttribute)) + throw new InvalidOperationException($"No {validationName} validation type metadata exists."); + + return (validationName, validationItemMetadataAttribute); + } + } + + /// + /// 获取所有验证类型 + /// + /// + private static IEnumerable GetValidationTypes() + { + // 扫描所有公开的枚举且贴有 [ValidationType] 特性 + var validationTypes = App.EffectiveTypes.Where(u => u.IsDefined(typeof(ValidationTypeAttribute), true) && u.IsEnum); + return validationTypes; + } + + /// + /// 获取所有验证消息类型 + /// + /// + private static IEnumerable GetValidationMessageTypes() + { + // 扫描所有公开的的枚举且贴有 [ValidationMessageType] 特性 + var validationMessageTypes = App.EffectiveTypes + .Where(u => u.IsDefined(typeof(ValidationMessageTypeAttribute), true) && u.IsEnum); + + // 加载自定义验证消息类型提供器 + var validationMessageTypeProvider = App.GetService(App.RootServices); + if (validationMessageTypeProvider is { Definitions: not null }) validationMessageTypes = validationMessageTypes.Concat(validationMessageTypeProvider.Definitions); + + return validationMessageTypes.Distinct(); + } + + /// + /// 获取验证类型所有有效的正则表达式 + /// + /// + private static ConcurrentDictionary GetValidationValidationItemMetadatas() + { + var vaidationItems = new ConcurrentDictionary(); + + // 查找所有 [ValidationMessageType] 类型中的 [ValidationMessage] 消息定义 + var customErrorMessages = ValidationMessageTypes.SelectMany(u => u.GetFields() + .Where(u => u.IsDefined(typeof(ValidationMessageAttribute)))) + .ToDictionary(u => u.Name, u => u.GetCustomAttribute().ErrorMessage.Render()); + + // 加载配置文件配置 + var validationTypeMessageSettings = App.GetConfig("ValidationTypeMessageSettings", true); + if (validationTypeMessageSettings is { Definitions: not null }) + { + // 获取所有参数大于1的配置 + var settingsErrorMessages = validationTypeMessageSettings.Definitions + .Where(u => u.Length > 1) + .ToDictionary(u => u[0].ToString(), u => u[1].ToString()); + + customErrorMessages = customErrorMessages.AddOrUpdate(settingsErrorMessages); + } + + // 获取所有验证属性 + var validationFields = ValidationTypes.SelectMany(u => u.GetFields() + .Where(u => u.IsDefined(typeof(ValidationItemMetadataAttribute)))) + .ToDictionary(u => u.Name, u => ReplaceValidateErrorMessage(u.Name, u, customErrorMessages)); + + vaidationItems.AddOrUpdate(validationFields); + + return vaidationItems; + } + + /// + /// 替换默认验证失败消息 + /// + /// 验证唯一名称 + /// + /// + private static ValidationItemMetadataAttribute ReplaceValidateErrorMessage(string name, FieldInfo field, Dictionary customErrorMessages) + { + var validationValidationItemMetadata = field.GetCustomAttribute(); + if (customErrorMessages.TryGetValue(name, out var errorMessage)) + { + validationValidationItemMetadata.DefaultErrorMessage = errorMessage; + } + + return validationValidationItemMetadata; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Attributes/InjectionAttribute.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Attributes/InjectionAttribute.cs new file mode 100644 index 000000000..3ab92bc98 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Attributes/InjectionAttribute.cs @@ -0,0 +1,72 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DependencyInjection; + +/// +/// 设置依赖注入方式 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class)] +public sealed class InjectionAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// + public InjectionAttribute(params Type[] exceptInterfaces) + : this(InjectionActions.Add, exceptInterfaces) + { + } + + /// + /// 构造函数 + /// + /// + /// + public InjectionAttribute(InjectionActions action, params Type[] exceptInterfaces) + { + Action = action; + Pattern = InjectionPatterns.All; + ExceptInterfaces = exceptInterfaces ?? Array.Empty(); + Order = 0; + } + + /// + /// 添加服务方式,存在不添加,或继续添加 + /// + public InjectionActions Action { get; set; } + + /// + /// 注册选项 + /// + public InjectionPatterns Pattern { get; set; } + + /// + /// 注册别名 + /// + /// 多服务时使用 + public string Named { get; set; } + + /// + /// 排序,排序越大,则在后面注册 + /// + public int Order { get; set; } + + /// + /// 排除接口 + /// + public Type[] ExceptInterfaces { get; set; } + + /// + /// 代理类型,必须继承 DispatchProxy、IDispatchProxy + /// + public Type Proxy { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Attributes/SuppressProxyAttribute.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Attributes/SuppressProxyAttribute.cs new file mode 100644 index 000000000..585ff90e7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Attributes/SuppressProxyAttribute.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DependencyInjection; + +/// +/// 跳过全局代理 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class)] +public sealed class SuppressProxyAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Attributes/SuppressSnifferAttribute.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Attributes/SuppressSnifferAttribute.cs new file mode 100644 index 000000000..eed7e9670 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Attributes/SuppressSnifferAttribute.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DependencyInjection; + +/// +/// 不被扫描和发现的特性 +/// +/// 用于程序集扫描类型或方法时候 +[SuppressSniffer, AttributeUsage(AttributeTargets.Class | AttributeTargets.Method | AttributeTargets.Enum | AttributeTargets.Struct)] +public sealed class SuppressSnifferAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/IPrivateDependency.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/IPrivateDependency.cs new file mode 100644 index 000000000..02f797589 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/IPrivateDependency.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DependencyInjection; + +/// +/// 依赖空接口(禁止外部继承) +/// +public interface IPrivateDependency +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/IScoped.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/IScoped.cs new file mode 100644 index 000000000..18116242d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/IScoped.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DependencyInjection; + +/// +/// 作用域服务注册依赖 +/// +public interface IScoped : IPrivateDependency +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/ISingleton.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/ISingleton.cs new file mode 100644 index 000000000..8c14909e0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/ISingleton.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DependencyInjection; + +/// +/// 单例服务注册依赖 +/// +public interface ISingleton : IPrivateDependency +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/ITransient.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/ITransient.cs new file mode 100644 index 000000000..a2ac70a6f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Dependencies/ITransient.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DependencyInjection; + +/// +/// 瞬时服务注册依赖 +/// +public interface ITransient : IPrivateDependency +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Enums/InjectionActions.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Enums/InjectionActions.cs new file mode 100644 index 000000000..466df3e7f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Enums/InjectionActions.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace ThingsGateway.DependencyInjection; + +/// +/// 服务注册方式 +/// +[SuppressSniffer] +public enum InjectionActions +{ + /// + /// 如果存在则覆盖 + /// + [Description("存在则覆盖")] + Add, + + /// + /// 如果存在则跳过,默认方式 + /// + [Description("存在则跳过")] + TryAdd +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Enums/InjectionPatterns.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Enums/InjectionPatterns.cs new file mode 100644 index 000000000..2c3803736 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Enums/InjectionPatterns.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace ThingsGateway.DependencyInjection; + +/// +/// 注册范围 +/// +[SuppressSniffer] +public enum InjectionPatterns +{ + /// + /// 只注册自己 + /// + [Description("只注册自己")] + Self, + + /// + /// 第一个接口 + /// + [Description("只注册第一个接口")] + FirstInterface, + + /// + /// 自己和第一个接口,默认值 + /// + [Description("自己和第一个接口")] + SelfWithFirstInterface, + + /// + /// 所有接口 + /// + [Description("所有接口")] + ImplementedInterfaces, + + /// + /// 注册自己包括所有接口 + /// + [Description("自己包括所有接口")] + All +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Enums/RegisterType.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Enums/RegisterType.cs new file mode 100644 index 000000000..ce7d1e9e0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Enums/RegisterType.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace ThingsGateway.DependencyInjection; + +/// +/// 注册类型 +/// +[SuppressSniffer] +public enum RegisterType +{ + /// + /// 瞬时 + /// + [Description("瞬时")] + Transient, + + /// + /// 作用域 + /// + [Description("作用域")] + Scoped, + + /// + /// 单例 + /// + [Description("单例")] + Singleton +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Extensions/DependencyInjectionServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Extensions/DependencyInjectionServiceCollectionExtensions.cs new file mode 100644 index 000000000..f77ba114e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Extensions/DependencyInjectionServiceCollectionExtensions.cs @@ -0,0 +1,380 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection.Extensions; + +using System.Collections.Concurrent; +using System.Reflection; + +using ThingsGateway; +using ThingsGateway.Reflection; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 依赖注入拓展类 +/// +[SuppressSniffer] +public static class DependencyInjectionServiceCollectionExtensions +{ + /// + /// 添加依赖注入接口 + /// + /// 服务集合 + /// 服务集合 + public static IServiceCollection AddDependencyInjection(this IServiceCollection services) + { + // 添加外部程序集配置 + services.AddConfigurableOptions(); + + // 添加内部依赖注入扫描拓展 + services.AddInnerDependencyInjection(); + + // 注册命名服务 + services.AddTransient(typeof(INamedServiceProvider<>), typeof(NamedServiceProvider<>)); + + return services; + } + + /// + /// 添加接口代理 + /// + /// 代理类 + /// 被代理接口依赖 + /// 服务集合 + /// + /// 服务集合 + public static IServiceCollection AddDispatchProxyForInterface(this IServiceCollection services, Type dependencyType) + where TDispatchProxy : AspectDispatchProxy, IDispatchProxy + where TIDispatchProxy : class + { + // 注册代理类 + var lifetime = TryGetServiceLifetime(dependencyType); + services.Add(ServiceDescriptor.Describe(typeof(AspectDispatchProxy), typeof(TDispatchProxy), lifetime)); + + // 代理依赖接口类型 + var proxyType = typeof(TDispatchProxy); + var typeDependency = typeof(TIDispatchProxy); + + // 获取所有的代理接口类型 + var dispatchProxyInterfaceTypes = App.EffectiveTypes + .Where(u => typeDependency.IsAssignableFrom(u) && u.IsInterface && u != typeDependency); + + // 注册代理类型 + foreach (var interfaceType in dispatchProxyInterfaceTypes) + { + AddDispatchProxy(services, dependencyType, default, proxyType, interfaceType, false); + } + + return services; + } + + /// + /// 添加扫描注入 + /// + /// 服务集合 + /// 服务集合 + private static IServiceCollection AddInnerDependencyInjection(this IServiceCollection services) + { + // 查找所有需要依赖注入的类型 + var injectTypes = App.EffectiveTypes + .Where(u => typeof(IPrivateDependency).IsAssignableFrom(u) && u.IsClass && !u.IsInterface && !u.IsAbstract) + .OrderBy(u => GetOrder(u)); + + var projectAssemblies = App.Assemblies; + var lifetimeInterfaces = new[] { typeof(ITransient), typeof(IScoped), typeof(ISingleton) }; + + // 执行依赖注入 + foreach (var type in injectTypes) + { + // 获取注册方式 + var injectionAttribute = !type.IsDefined(typeof(InjectionAttribute)) ? new InjectionAttribute() : type.GetCustomAttribute(); + + var interfaces = type.GetInterfaces(); + + // 获取所有能注册的接口 + var canInjectInterfaces = interfaces.Where(u => !injectionAttribute.ExceptInterfaces.Contains(u) + && u != typeof(IDisposable) + && u != typeof(IAsyncDisposable) + && u != typeof(IPrivateDependency) + //&& u != typeof(IDynamicApiController) + && !lifetimeInterfaces.Contains(u) + && projectAssemblies.Contains(u.Assembly) + && ( + (!type.IsGenericType && !u.IsGenericType) + || (type.IsGenericType && u.IsGenericType && type.GetGenericArguments().Length == u.GetGenericArguments().Length)) + ); + + // 获取生存周期类型 + var dependencyType = interfaces.Last(u => lifetimeInterfaces.Contains(u)); + + // 注册服务 + RegisterService(services, dependencyType, type, injectionAttribute, canInjectInterfaces); + + // 缓存类型注册 + var typeNamed = injectionAttribute.Named ?? type.Name; + TypeNamedCollection.TryAdd(typeNamed, type); + } + + // 注册外部配置服务 + RegisterExternalServices(services); + + // 注册命名服务(接口多实现) + RegisterNamedService(services); + RegisterNamedService(services); + RegisterNamedService(services); + + return services; + } + + /// + /// 注册服务 + /// + /// 服务集合 + /// + /// 类型 + /// 注入特性 + /// 能被注册的接口 + private static void RegisterService(IServiceCollection services, Type dependencyType, Type type, InjectionAttribute injectionAttribute, IEnumerable canInjectInterfaces) + { + // 注册自己 + if (injectionAttribute.Pattern is InjectionPatterns.Self or InjectionPatterns.All or InjectionPatterns.SelfWithFirstInterface) + { + Register(services, dependencyType, type, injectionAttribute); + } + + if (!canInjectInterfaces.Any()) return; + + // 只注册第一个接口 + if (injectionAttribute.Pattern is InjectionPatterns.FirstInterface or InjectionPatterns.SelfWithFirstInterface) + { + Register(services, dependencyType, type, injectionAttribute, canInjectInterfaces.Last()); + } + // 注册多个接口 + else if (injectionAttribute.Pattern is InjectionPatterns.ImplementedInterfaces or InjectionPatterns.All) + { + foreach (var inter in canInjectInterfaces) + { + Register(services, dependencyType, type, injectionAttribute, inter); + } + } + } + + /// + /// 注册类型 + /// + /// 服务 + /// + /// 类型 + /// 注入特性 + /// 接口 + private static void Register(IServiceCollection services, Type dependencyType, Type type, InjectionAttribute injectionAttribute, Type inter = null) + { + // 修复泛型注册类型 + var fixedType = FixedGenericType(type); + var fixedInter = inter == null ? null : FixedGenericType(inter); + var lifetime = TryGetServiceLifetime(dependencyType); + + switch (injectionAttribute.Action) + { + case InjectionActions.Add: + if (fixedInter == null) services.Add(ServiceDescriptor.Describe(fixedType, fixedType, lifetime)); + else + { + services.Add(ServiceDescriptor.Describe(fixedInter, fixedType, lifetime)); + AddDispatchProxy(services, dependencyType, fixedType, injectionAttribute.Proxy, fixedInter, true); + } + break; + + case InjectionActions.TryAdd: + if (fixedInter == null) services.TryAdd(ServiceDescriptor.Describe(fixedType, fixedType, lifetime)); + else services.Add(ServiceDescriptor.Describe(fixedInter, fixedType, lifetime)); + break; + + default: break; + } + } + + /// + /// 创建服务代理 + /// + /// 服务集合 + /// + /// 拦截的类型 + /// 代理类型 + /// 代理接口 + /// 是否有实现类 + private static void AddDispatchProxy(IServiceCollection services, Type dependencyType, Type type, Type proxyType, Type inter, bool hasTarget = true) + { + proxyType ??= GlobalServiceProxyType; + if (proxyType == null || (type != null && type.IsDefined(typeof(SuppressProxyAttribute), true))) return; + + var lifetime = TryGetServiceLifetime(dependencyType); + + // 注册代理类型 + services.Add(ServiceDescriptor.Describe(typeof(AspectDispatchProxy), proxyType, lifetime)); + + // 注册服务 + services.Add(ServiceDescriptor.Describe(inter, provider => + { + dynamic proxy = DispatchCreateMethod.MakeGenericMethod(inter, proxyType).Invoke(null, null); + proxy.Services = provider; + if (hasTarget) + { + proxy.Target = provider.GetService(type); + } + + return proxy; + }, lifetime)); + } + + /// + /// 注册命名服务(接口多实现) + /// + /// + /// + private static void RegisterNamedService(IServiceCollection services) + where TDependency : IPrivateDependency + { + var lifetime = TryGetServiceLifetime(typeof(TDependency)); + + // 注册命名服务 + services.Add(ServiceDescriptor.Describe(typeof(Func), provider => + { + object ResolveService(string named, TDependency _) + { + var isRegister = TypeNamedCollection.TryGetValue(named, out var serviceType); + + // 暂不支持 AOP + return isRegister ? provider.GetService(serviceType) : null; + } + return (Func)ResolveService; + }, lifetime)); + } + + /// + /// 注册外部服务 + /// + /// + private static void RegisterExternalServices(IServiceCollection services) + { + // 获取选项 + var externalServices = App.GetConfig("DependencyInjectionSettings", true); + + if (externalServices is { Definitions: not null }) + { + // 排序 + var extServices = externalServices.Definitions.OrderBy(u => u.Order); + foreach (var externalService in extServices) + { + var injectionAttribute = new InjectionAttribute + { + Action = externalService.Action, + Named = externalService.Named, + Order = externalService.Order, + Pattern = externalService.Pattern + }; + + // 加载代理拦截 + if (!string.IsNullOrWhiteSpace(externalService.Proxy)) injectionAttribute.Proxy = Reflect.GetStringType(externalService.Proxy); + + // 解析注册类型 + var dependencyType = externalService.RegisterType switch + { + RegisterType.Transient => typeof(ITransient), + RegisterType.Scoped => typeof(IScoped), + RegisterType.Singleton => typeof(ISingleton), + _ => throw new InvalidOperationException("Unknown lifetime type.") + }; + + RegisterService(services, dependencyType, + Reflect.GetStringType(externalService.Service), + injectionAttribute, + new[] { Reflect.GetStringType(externalService.Interface) }); + } + } + } + + /// + /// 修复泛型类型注册类型问题 + /// + /// 类型 + /// + private static Type FixedGenericType(Type type) + { + if (!type.IsGenericType) return type; + + return Reflect.GetType(type.Assembly, $"{type.Namespace}.{type.Name}"); + } + + /// + /// 获取 注册 排序 + /// + /// 排序类型 + /// int + private static int GetOrder(Type type) + { + return !type.IsDefined(typeof(InjectionAttribute), true) ? 0 : type.GetCustomAttribute(true).Order; + } + + /// + /// 根据依赖接口类型解析 ServiceLifetime 对象 + /// + /// + /// + private static ServiceLifetime TryGetServiceLifetime(Type dependencyType) + { + if (dependencyType == typeof(ITransient)) + { + return ServiceLifetime.Transient; + } + else if (dependencyType == typeof(IScoped)) + { + return ServiceLifetime.Scoped; + } + else if (dependencyType == typeof(ISingleton)) + { + return ServiceLifetime.Singleton; + } + else + { + throw new InvalidCastException("Invalid service registration lifetime."); + } + } + + /// + /// 类型名称集合 + /// + private static readonly ConcurrentDictionary TypeNamedCollection; + + /// + /// 创建代理方法 + /// + private static readonly MethodInfo DispatchCreateMethod; + + /// + /// 全局服务代理类型 + /// + private static readonly Type GlobalServiceProxyType; + + /// + /// 静态构造函数 + /// + static DependencyInjectionServiceCollectionExtensions() + { + // 获取全局代理类型 + GlobalServiceProxyType = App.EffectiveTypes + .FirstOrDefault(u => typeof(AspectDispatchProxy).IsAssignableFrom(u) && typeof(IGlobalDispatchProxy).IsAssignableFrom(u) && u.IsClass && !u.IsInterface && !u.IsAbstract); + + TypeNamedCollection = new ConcurrentDictionary(); + DispatchCreateMethod = typeof(AspectDispatchProxy).GetMethod(nameof(AspectDispatchProxy.Create)); + } +} diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Internal/ExternalService.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Internal/ExternalService.cs new file mode 100644 index 000000000..3e56efbbe --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Internal/ExternalService.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DependencyInjection; + +/// +/// 外部注册类型模型 +/// +[SuppressSniffer] +public sealed class ExternalService +{ + /// + /// 接口类型,格式:"程序集名称;接口完整名称" + /// + public string Interface { get; set; } + + /// + /// 实例类型,格式:"程序集名称;接口完整名称" + /// + public string Service { get; set; } + + /// + /// 注册类型 + /// + public RegisterType RegisterType { get; set; } + + /// + /// 添加服务方式,存在不添加,或继续添加 + /// + public InjectionActions Action { get; set; } = InjectionActions.Add; + + /// + /// 注册选项 + /// + public InjectionPatterns Pattern { get; set; } = InjectionPatterns.All; + + /// + /// 注册别名 + /// + /// 多服务时使用 + public string Named { get; set; } + + /// + /// 排序,排序越大,则在后面注册 + /// + public int Order { get; set; } + + /// + /// 代理类型,格式:"程序集名称;接口完整名称" + /// + public string Proxy { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Options/DependencyInjectionSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Options/DependencyInjectionSettingsOptions.cs new file mode 100644 index 000000000..c3889472f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Options/DependencyInjectionSettingsOptions.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.DependencyInjection; + +/// +/// 依赖注入配置选项 +/// +public sealed class DependencyInjectionSettingsOptions : IConfigurableOptions +{ + /// + /// 外部注册定义 + /// + public ExternalService[] Definitions { get; set; } + + /// + /// 后期配置 + /// + /// + /// + public void PostConfigure(DependencyInjectionSettingsOptions options, IConfiguration configuration) + { + options.Definitions ??= Array.Empty(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Providers/INamedServiceProvider.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Providers/INamedServiceProvider.cs new file mode 100644 index 000000000..4f76ac4d7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Providers/INamedServiceProvider.cs @@ -0,0 +1,52 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DependencyInjection; + +/// +/// 命名服务提供器 +/// +/// 目标服务接口 +public interface INamedServiceProvider + where TService : class +{ + /// + /// 根据服务名称获取服务 + /// + /// 服务名称 + /// + TService GetService(string serviceName); + + /// + /// 根据服务名称获取服务 + /// + /// 服务生存周期接口, + /// 服务名称 + /// + TService GetService(string serviceName) + where ILifetime : IPrivateDependency; + + /// + /// 根据服务名称获取服务 + /// + /// 服务名称 + /// + TService GetRequiredService(string serviceName); + + /// + /// 根据服务名称获取服务 + /// + /// 服务生存周期接口, + /// 服务名称 + /// + TService GetRequiredService(string serviceName) + where ILifetime : IPrivateDependency; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Providers/NamedServiceProvider.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Providers/NamedServiceProvider.cs new file mode 100644 index 000000000..0fd84bbe9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Providers/NamedServiceProvider.cs @@ -0,0 +1,124 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +using System.Reflection; + +using ThingsGateway.Reflection; + +namespace ThingsGateway.DependencyInjection; + +/// +/// 命名服务提供器默认实现 +/// +/// 目标服务接口 +internal sealed class NamedServiceProvider : INamedServiceProvider + where TService : class +{ + /// + /// 服务提供器 + /// + private readonly IServiceProvider _serviceProvider; + + /// + /// 构造函数 + /// + /// 服务提供器 + public NamedServiceProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + /// 根据服务名称获取服务 + /// + /// 服务名称 + /// + public TService GetService(string serviceName) + { + var services = _serviceProvider.GetServices(); + + if (services + .OfType() + .FirstOrDefault(u => ResovleServiceName(((dynamic)u).Target.GetType()) == serviceName) is not TService service) + { + service = services.FirstOrDefault(u => ResovleServiceName(u.GetType()) == serviceName); + } + + return service; + } + + /// + /// 根据服务名称获取服务 + /// + /// 服务生存周期接口, + /// 服务名称 + /// + public TService GetService(string serviceName) + where ILifetime : IPrivateDependency + { + var resolveNamed = _serviceProvider.GetService>(); + return resolveNamed == null ? default : resolveNamed(serviceName, default) as TService; + } + + /// + /// 根据服务名称获取服务 + /// + /// 服务名称 + /// + public TService GetRequiredService(string serviceName) + { + // 解析所有实现 + var services = _serviceProvider.GetServices(); + + if (services + .OfType() + .FirstOrDefault(u => ResovleServiceName(((dynamic)u).Target.GetType()) == serviceName) is not TService service) + { + service = services.FirstOrDefault(u => ResovleServiceName(u.GetType()) == serviceName); + } + + // 如果服务不存在,抛出异常 + return service ?? throw new InvalidOperationException($"Named service `{serviceName}` is not registered in container."); + } + + /// + /// 根据服务名称获取服务 + /// + /// 服务生存周期接口, + /// 服务名称 + /// + public TService GetRequiredService(string serviceName) + where ILifetime : IPrivateDependency + { + var resolveNamed = _serviceProvider.GetRequiredService>(); + var service = resolveNamed == null ? default : resolveNamed(serviceName, default) as TService; + + // 如果服务不存在,抛出异常 + return service ?? throw new InvalidOperationException($"Named service `{serviceName}` is not registered in container."); + } + + /// + /// 解析服务名称 + /// + /// + /// + private static string ResovleServiceName(Type type) + { + if (type.IsDefined(typeof(InjectionAttribute))) + { + return type.GetCustomAttribute().Named; + } + + return type.Name; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DependencyInjection/Scoped.cs b/src/Admin/ThingsGateway.Furion/DependencyInjection/Scoped.cs new file mode 100644 index 000000000..af14a6e07 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DependencyInjection/Scoped.cs @@ -0,0 +1,91 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +namespace ThingsGateway.DependencyInjection; + +/// +/// 创建作用域静态类 +/// +[SuppressSniffer] +public static partial class Scoped +{ + /// + /// 创建一个作用域范围 + /// + /// + /// + public static void Create(Action handler, IServiceScopeFactory scopeFactory = default) + { + CreateAsync(async (fac, scope) => + { + handler(fac, scope); + await Task.CompletedTask.ConfigureAwait(false); + }, scopeFactory).GetAwaiter().GetResult(); + } + + /// + /// 创建一个作用域范围(异步) + /// + /// + /// + public static async Task CreateAsync(Func handler, IServiceScopeFactory scopeFactory = default) + { + // 禁止空调用 + if (handler == null) throw new ArgumentNullException(nameof(handler)); + + // 创建作用域 + var (scoped, serviceProvider) = CreateScope(ref scopeFactory); + + try + { + // 执行方法 + await handler(scopeFactory, scoped).ConfigureAwait(false); + } + catch + { + throw; + } + finally + { + // 释放 + scoped.Dispose(); + if (serviceProvider != null) await serviceProvider.DisposeAsync().ConfigureAwait(false); + } + } + + /// + /// 创建一个作用域 + /// + /// + /// + private static (IServiceScope Scoped, ServiceProvider ServiceProvider) CreateScope(ref IServiceScopeFactory scopeFactory) + { + ServiceProvider undisposeServiceProvider = default; + + if (scopeFactory == null) + { + // 默认返回根服务 + if (App.RootServices != null) scopeFactory = App.RootServices.GetService(); + else + { + // 这里创建了一个待释放服务提供器(这里会有性能小问题,如果走到这一步) + undisposeServiceProvider = InternalApp.InternalServices.BuildServiceProvider(); + scopeFactory = undisposeServiceProvider.GetService(); + } + } + + // 解析服务作用域工厂 + var scoped = scopeFactory.CreateScope(); + return (scoped, undisposeServiceProvider); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/ApiDescriptionSettingsAttribute.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/ApiDescriptionSettingsAttribute.cs new file mode 100644 index 000000000..0f9b41dcc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/ApiDescriptionSettingsAttribute.cs @@ -0,0 +1,135 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.DynamicApiController; + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 接口描述设置 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Interface | AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public sealed class ApiDescriptionSettingsAttribute : Attribute +{ + /// + /// 构造函数 + /// + public ApiDescriptionSettingsAttribute() : base() + { + Order = 0; + } + + /// + /// 构造函数 + /// + /// 是否启用 + public ApiDescriptionSettingsAttribute(bool enabled) : base() + { + IgnoreApi = !enabled; + Order = 0; + } + + /// + /// 构造函数 + /// + /// 分组列表 + public ApiDescriptionSettingsAttribute(params string[] groups) : base() + { + GroupName = string.Join(Penetrates.GroupSeparator, groups); + Groups = groups; + Order = 0; + } + + /// + /// 自定义名称 + /// + public string Name { get; set; } + + /// + /// 保留原有名称(Boolean 类型) + /// + public object KeepName { get; set; } + + /// + /// 切割骆驼命名(Boolean 类型) + /// + public object SplitCamelCase { get; set; } + + /// + /// 小驼峰命名(首字符小写) + /// + public object AsLowerCamelCase { get; set; } + + /// + /// 保留路由谓词(Boolean 类型) + /// + public object KeepVerb { get; set; } + + /// + /// 小写路由(Boolean 类型) + /// + public object LowercaseRoute { get; set; } + + /// + /// 模块名 + /// + public string Module { get; set; } + + /// + /// 版本号 + /// + public string Version { get; set; } + + /// + /// 分组 + /// + public string[] Groups { get; set; } + + /// + /// 标签 + /// + public string Tag { get; set; } + + /// + /// 排序 + /// + public int Order { get; set; } + + /// + /// 配置控制器区域(只对控制器有效) + /// + public string Area { get; set; } + + /// + /// 额外描述,支持 HTML + /// + public string Description { get; set; } + + /// + /// 强制携带路由前缀,即使使用 [Route] 重写,仅对 Class/Controller 有效 + /// + public object ForceWithRoutePrefix { get; set; } + + /// + /// 禁止子类继承 + /// + public bool DisableInherite { get; set; } = false; + + /// + /// 分组名 + /// + public string GroupName { get; set; } + + /// + /// 是否忽略 API + /// + public bool IgnoreApi { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/ApiSeatAttribute.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/ApiSeatAttribute.cs new file mode 100644 index 000000000..059ac9acc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/ApiSeatAttribute.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 接口参数位置设置 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Parameter)] +public sealed class ApiSeatAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// + public ApiSeatAttribute(ApiSeats seat = ApiSeats.ActionEnd) + { + Seat = seat; + } + + /// + /// 参数位置 + /// + public ApiSeats Seat { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/DynamicApiControllerAttribute.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/DynamicApiControllerAttribute.cs new file mode 100644 index 000000000..f58b544a8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/DynamicApiControllerAttribute.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +//namespace System; + +///// +///// 动态 WebApi 特性 +///// +//[SuppressSniffer, AttributeUsage(AttributeTargets.Class)] +//public sealed class DynamicApiControllerAttribute : Attribute +//{ +//} diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/QueryParametersAttribute.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/QueryParametersAttribute.cs new file mode 100644 index 000000000..5009251c6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/QueryParametersAttribute.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DynamicApiController; + +/// +/// 将 Action 所有参数 [FromQuery] 化 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Method)] +public sealed class QueryParametersAttribute : Attribute +{ + /// + /// 默认构造函数 + /// + public QueryParametersAttribute() + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/RouteConstraintAttribute.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/RouteConstraintAttribute.cs new file mode 100644 index 000000000..f23ec386e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Attributes/RouteConstraintAttribute.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 接口参数约束 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Parameter)] +public sealed class RouteConstraintAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// + public RouteConstraintAttribute(string constraint) + { + Constraint = constraint; + } + + /// + /// 约束表达式 + /// + public string Constraint { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Builders/DynamicApiControllerBuilder.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Builders/DynamicApiControllerBuilder.cs new file mode 100644 index 000000000..c883fba1c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Builders/DynamicApiControllerBuilder.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace ThingsGateway.DynamicApiController; + +/// +/// 动态 WebAPI 构建器 +/// +[SuppressSniffer] +public sealed class DynamicApiControllerBuilder +{ + /// + /// 提供生成控制器过滤器 + /// + /// 返回 true 将生成控制器,否则跳过。 + public Func ControllerFilter { get; set; } + + /// + /// 添加 Action 自定义配置 + /// + /// 返回 true 将生成 Action,否则跳过。 + public Action ActionConfigure { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Conventions/DynamicApiControllerApplicationModelConvention.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Conventions/DynamicApiControllerApplicationModelConvention.cs new file mode 100644 index 000000000..44a585426 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Conventions/DynamicApiControllerApplicationModelConvention.cs @@ -0,0 +1,999 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ActionConstraints; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.Extensions.DependencyInjection; + +using System.Collections.Concurrent; +using System.Reflection; +using System.Text.RegularExpressions; + +using ThingsGateway.Extensions; +using ThingsGateway.UnifyResult; + +namespace ThingsGateway.DynamicApiController; + +/// +/// 动态接口控制器应用模型转换器 +/// +internal sealed class DynamicApiControllerApplicationModelConvention : IApplicationModelConvention +{ + /// + /// 动态接口控制器配置实例 + /// + private readonly DynamicApiControllerSettingsOptions _dynamicApiControllerSettings; + + /// + /// 带版本的名称正则表达式 + /// + private readonly Regex _nameVersionRegex; + + /// + /// 服务集合 + /// + private readonly IServiceCollection _services; + + /// + /// 模板正则表达式 + /// + private const string commonTemplatePattern = @"\{(?

.+?)\}"; + + ///

+ /// 动态 WebAPI 构建器 + /// + private readonly DynamicApiControllerBuilder _dynamicApiControllerBuilder; + + /// + /// 构造函数 + /// + /// 服务集合 + public DynamicApiControllerApplicationModelConvention(IServiceCollection services) + { + _services = services; + _dynamicApiControllerSettings = App.GetConfig("DynamicApiControllerSettings", true); + LoadVerbToHttpMethodsConfigure(); + _nameVersionRegex = new Regex(@"V(?[0-9_]+$)"); + + _dynamicApiControllerBuilder = services.FirstOrDefault(u => u.ServiceType == typeof(DynamicApiControllerBuilder))?.ImplementationInstance as DynamicApiControllerBuilder; + } + + /// + /// 配置应用模型信息 + /// + /// 引用模型 + public void Apply(ApplicationModel application) + { + var controllers = application.Controllers.Where(u => + { + return Penetrates.IsApiController(u.ControllerType) + && (_dynamicApiControllerBuilder?.ControllerFilter == null || _dynamicApiControllerBuilder.ControllerFilter.Invoke(u)); + }); + + + foreach (var controller in controllers) + { + var controllerType = controller.ControllerType; + + // 解析 [ApiDescriptionSettings] 特性 + var controllerApiDescriptionSettings = controllerType.IsDefined(typeof(ApiDescriptionSettingsAttribute), true) ? controllerType.GetCustomAttribute(true) : default; + + // 判断是否处理 Mvc控制器 + if (typeof(ControllerBase).IsAssignableFrom(controllerType)) + { + if (!_dynamicApiControllerSettings.SupportedMvcController.Value || controller.ApiExplorer?.IsVisible == false) + { + // 存储排序给 Swagger 使用 + Penetrates.ControllerOrderCollection.TryAdd(controller.ControllerName, (controllerApiDescriptionSettings?.Tag ?? controller.ControllerName, controllerApiDescriptionSettings?.Order ?? 0, controller.ControllerType)); + + // 控制器默认处理规范化结果 + if (UnifyContext.EnabledUnifyHandler) + { + foreach (var action in controller.Actions) + { + // 配置动作方法规范化特性 + ConfigureActionUnifyResultAttribute(action); + } + } + + continue; + } + } + + ConfigureController(controller, controllerApiDescriptionSettings); + } + } + + /// + /// 配置控制器 + /// + /// 控制器模型 + /// 接口描述配置 + private void ConfigureController(ControllerModel controller, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + // 配置区域 + ConfigureControllerArea(controller, controllerApiDescriptionSettings); + + // 配置控制器名称 + var isLowercaseRoute = ConfigureControllerName(controller, controllerApiDescriptionSettings); + + // 配置控制器路由特性 + ConfigureControllerRouteAttribute(controller, controllerApiDescriptionSettings, isLowercaseRoute); + + // 存储排序给 Swagger 使用 + Penetrates.ControllerOrderCollection.TryAdd(controller.ControllerName, (controllerApiDescriptionSettings?.Tag ?? controller.ControllerName, controllerApiDescriptionSettings?.Order ?? 0, controller.ControllerType)); + + var actions = controller.Actions; + + // 查找所有重复的方法签名 + var repeats = actions.GroupBy(u => new { u.ActionMethod.ReflectedType.Name, Signature = u.ActionMethod.ToString() }) + .Where(u => u.Count() > 1) + .SelectMany(u => u.Where(u => u.ActionMethod.ReflectedType.Name != u.ActionMethod.DeclaringType.Name)); + + // 2021年04月01日 https://docs.microsoft.com/en-US/aspnet/core/web-api/?view=aspnetcore-5.0#binding-source-parameter-inference + // 判断是否贴有 [ApiController] 特性 + var hasApiControllerAttribute = controller.Attributes.Any(u => u.GetType() == typeof(ApiControllerAttribute)); + + foreach (var action in actions) + { + // 跳过相同方法签名 + if (repeats.Contains(action)) + { + action.ApiExplorer.IsVisible = false; + continue; + }; + + var actionMethod = action.ActionMethod; + var actionApiDescriptionSettings = actionMethod.IsDefined(typeof(ApiDescriptionSettingsAttribute), true) ? actionMethod.GetCustomAttribute(true) : default; + + // 检查当前方法是否是继承而来 + if (controller.ControllerType.IsSubclassOf(actionMethod.DeclaringType) && actionApiDescriptionSettings?.DisableInherite == true) + { + action.ApiExplorer.IsVisible = false; + continue; + } + + ConfigureAction(action, actionApiDescriptionSettings, controllerApiDescriptionSettings, hasApiControllerAttribute); + + // 添加 Action 自定义配置 + _dynamicApiControllerBuilder?.ActionConfigure?.Invoke(action); + } + } + + /// + /// 配置控制器区域 + /// + /// + /// + private void ConfigureControllerArea(ControllerModel controller, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + // 如果配置了区域,则跳过 + if (controller.RouteValues.ContainsKey("area")) return; + + // 如果没有配置区域,则跳过 + var area = controllerApiDescriptionSettings?.Area ?? _dynamicApiControllerSettings.DefaultArea; + if (string.IsNullOrWhiteSpace(area)) return; + + controller.RouteValues["area"] = area; + } + + /// + /// 配置控制器名称 + /// + /// 控制器模型 + /// 接口描述配置 + /// + private bool ConfigureControllerName(ControllerModel controller, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + var (Name, IsLowercaseRoute, _, _) = ConfigureControllerAndActionName(controllerApiDescriptionSettings, controller.ControllerType.Name, _dynamicApiControllerSettings.AbandonControllerAffixes, _ => _); + controller.ControllerName = Name; + return IsLowercaseRoute; + } + + /// + /// 强制处理了 ForceWithDefaultPrefix 的控制器 + /// + /// 避免路由无限追加 + private ConcurrentBag ForceWithDefaultPrefixRouteControllerTypes { get; } = new ConcurrentBag(); + + /// + /// 配置控制器路由特性 + /// + /// + /// + /// + private void ConfigureControllerRouteAttribute(ControllerModel controller, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings, bool isLowercaseRoute) + { + // 解决 Gitee 该 Issue:https://gitee.com/dotnetchina/Furion/issues/I59B74 + if (CheckIsForceWithDefaultRoute(controllerApiDescriptionSettings) + && !string.IsNullOrWhiteSpace(_dynamicApiControllerSettings.DefaultRoutePrefix) + && controller.Selectors[0] != null + && controller.Selectors[0].AttributeRouteModel != null + && !ForceWithDefaultPrefixRouteControllerTypes.Contains(controller.ControllerType)) + { + // 读取模块 + var module = controllerApiDescriptionSettings?.Module ?? _dynamicApiControllerSettings.DefaultModule; + var template = $"{_dynamicApiControllerSettings.DefaultRoutePrefix}/{module}"; + + controller.Selectors[0].AttributeRouteModel = AttributeRouteModel.CombineAttributeRouteModel(new AttributeRouteModel(new RouteAttribute(isLowercaseRoute ? template?.ToLower() : template)) + , controller.Selectors[0].AttributeRouteModel); + ForceWithDefaultPrefixRouteControllerTypes.Add(controller.ControllerType); + } + } + + /// + /// 配置动作方法 + /// + /// 控制器模型 + /// 接口描述配置 + /// 控制器接口描述配置 + /// 是否贴有 ApiController 特性 + private void ConfigureAction(ActionModel action, ApiDescriptionSettingsAttribute apiDescriptionSettings, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings, bool hasApiControllerAttribute) + { + // 配置动作方法接口可见性 + ConfigureActionApiExplorer(action); + + // 配置动作方法名称 + var (isLowercaseRoute, isKeepName, isLowerCamelCase) = ConfigureActionName(action, apiDescriptionSettings, controllerApiDescriptionSettings); + + // 配置动作方法请求谓词特性 + ConfigureActionHttpMethodAttribute(action); + + // 配置引用类型参数 + ConfigureClassTypeParameter(action); + + // 配置动作方法路由特性 + ConfigureActionRouteAttribute(action, apiDescriptionSettings, controllerApiDescriptionSettings, isLowercaseRoute, isKeepName, isLowerCamelCase, hasApiControllerAttribute); + + // 配置动作方法规范化特性 + if (UnifyContext.EnabledUnifyHandler) ConfigureActionUnifyResultAttribute(action); + } + + /// + /// 配置动作方法接口可见性 + /// + /// 动作方法模型 + private static void ConfigureActionApiExplorer(ActionModel action) + { + if (!action.ApiExplorer.IsVisible.HasValue) action.ApiExplorer.IsVisible = true; + } + + /// + /// 配置动作方法名称 + /// + /// 动作方法模型 + /// 接口描述配置 + /// + /// + private (bool IsLowercaseRoute, bool IsKeepName, bool IsLowerCamelCase) ConfigureActionName(ActionModel action, ApiDescriptionSettingsAttribute apiDescriptionSettings, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + // 判断是否贴有 [ActionName] + string actionName = null; + + // 判断是否贴有 [ActionName] 且 Name 不为 null + var actionNameAttribute = action.ActionMethod.IsDefined(typeof(ActionNameAttribute), true) + ? action.ActionMethod.GetCustomAttribute(true) + : null; + + if (actionNameAttribute?.Name != null) + { + actionName = actionNameAttribute.Name; + } + + var (Name, IsLowercaseRoute, IsKeepName, IsLowerCamelCase) = ConfigureControllerAndActionName(apiDescriptionSettings, action.ActionMethod.Name + , _dynamicApiControllerSettings.AbandonActionAffixes + , (tempName) => + { + // 处理动作方法名称谓词 + if (!CheckIsKeepVerb(apiDescriptionSettings, controllerApiDescriptionSettings)) + { + var words = tempName.SplitCamelCase(); + var verbKey = words.First().ToLower(); + // 处理类似 getlist,getall 多个单词 + if (words.Length > 1 && Penetrates.VerbToHttpMethods.ContainsKey((words[0] + words[1]).ToLower())) + { + tempName = tempName[(words[0] + words[1]).Length..]; + } + else if (Penetrates.VerbToHttpMethods.ContainsKey(verbKey)) tempName = tempName[verbKey.Length..]; + } + + return tempName; + }, controllerApiDescriptionSettings, actionName); + action.ActionName = Name; + + return (IsLowercaseRoute, IsKeepName, IsLowerCamelCase); + } + + /// + /// 配置动作方法请求谓词特性 + /// + /// 动作方法模型 + private void ConfigureActionHttpMethodAttribute(ActionModel action) + { + var selectorModel = action.Selectors[0]; + // 跳过已配置请求谓词特性的配置 + if (selectorModel.ActionConstraints.Any(u => u is HttpMethodActionConstraint)) return; + + // 解析请求谓词 + var words = action.ActionMethod.Name.SplitCamelCase(); + var verbKey = words.First().ToLower(); + + // 处理类似 getlist,getall 多个单词 + if (words.Length > 1 && Penetrates.VerbToHttpMethods.ContainsKey((words[0] + words[1]).ToLower())) + { + verbKey = (words[0] + words[1]).ToLower(); + } + + var succeed = Penetrates.VerbToHttpMethods.TryGetValue(verbKey, out var verbValue); + var verb = succeed ? verbValue : _dynamicApiControllerSettings.DefaultHttpMethod.ToUpper(); + + // 添加请求约束 + selectorModel.ActionConstraints.Add(new HttpMethodActionConstraint(new[] { verb })); + + // 添加请求谓词特性 + HttpMethodAttribute httpMethodAttribute = verb switch + { + "GET" => new HttpGetAttribute(), + "POST" => new HttpPostAttribute(), + "PUT" => new HttpPutAttribute(), + "DELETE" => new HttpDeleteAttribute(), + "PATCH" => new HttpPatchAttribute(), + "HEAD" => new HttpHeadAttribute(), + _ => throw new NotSupportedException($"{verb}") + }; + + selectorModel.EndpointMetadata.Add(httpMethodAttribute); + } + + /// + /// 处理类类型参数(添加[FromBody] 特性) + /// + /// + private void ConfigureClassTypeParameter(ActionModel action) + { + // 没有参数无需处理 + if (action.Parameters.Count == 0) return; + + // 如果动作方法请求谓词只有GET和HEAD,则将类转查询参数 + if (_dynamicApiControllerSettings.ModelToQuery.Value) + { + var httpMethods = action.Selectors + .SelectMany(u => u.ActionConstraints.Where(u => u is HttpMethodActionConstraint) + .SelectMany(u => (u as HttpMethodActionConstraint).HttpMethods)); + + if (httpMethods.All(u => u.Equals("GET") || u.Equals("HEAD"))) return; + } + + var parameters = action.Parameters; + foreach (var parameterModel in parameters) + { + // 如果参数已有绑定特性,则跳过 + if (parameterModel.BindingInfo != null) continue; + + var parameterType = parameterModel.ParameterType; + // 如果是基元类型,则跳过 + if (parameterType.IsRichPrimitive()) continue; + + // 如果是文件类型,则跳过 + if (typeof(IFormFile).IsAssignableFrom(parameterType) || typeof(IFormFileCollection).IsAssignableFrom(parameterType)) continue; + + // 处理 .NET7 接口问题,同时支持 .NET5/6 无需贴 [FromServices] 操作 + if (parameterType.IsInterface + && !parameterModel.Attributes.Any(u => typeof(IBindingSourceMetadata).IsAssignableFrom(u.GetType())) + && _services.Any(s => s.ServiceType.Name == parameterType.Name)) + { + parameterModel.BindingInfo = BindingInfo.GetBindingInfo(new[] { new FromServicesAttribute() }); + continue; + } + + parameterModel.BindingInfo = BindingInfo.GetBindingInfo(new[] { new FromBodyAttribute() }); + } + } + + /// + /// 配置动作方法路由特性 + /// + /// 动作方法模型 + /// 接口描述配置 + /// 控制器接口描述配置 + /// + /// + /// + /// + private void ConfigureActionRouteAttribute(ActionModel action, ApiDescriptionSettingsAttribute apiDescriptionSettings, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings, bool isLowercaseRoute, bool isKeepName, bool isLowerCamelCase, bool hasApiControllerAttribute) + { + foreach (var selectorModel in action.Selectors) + { + // 读取模块 + var module = apiDescriptionSettings?.Module; + + // 跳过已配置路由特性的配置 + if (selectorModel.AttributeRouteModel != null) + { + // 1. 如果控制器自定义了 [Route] 特性,则跳过 + if (action.ActionMethod.DeclaringType.IsDefined(typeof(RouteAttribute), true) + || action.Controller.ControllerType.IsDefined(typeof(RouteAttribute), true)) + { + if (string.IsNullOrWhiteSpace(selectorModel.AttributeRouteModel.Template) + && !string.IsNullOrWhiteSpace(selectorModel.AttributeRouteModel.Name)) + { + selectorModel.AttributeRouteModel.Template = selectorModel.AttributeRouteModel.Name; + } + + var newTemplate = $"{(selectorModel.AttributeRouteModel.Template?.StartsWith('/') == true ? "/" : null)}{(string.IsNullOrWhiteSpace(module) ? null : $"{module}/")}{selectorModel.AttributeRouteModel.Template}"; + // 处理可能存在多斜杠问题 + newTemplate = Regex.Replace(newTemplate, @"\/{2,}", "/"); + selectorModel.AttributeRouteModel.Template = isLowercaseRoute ? ConvertToLowerCaseExceptBrackets(newTemplate) : newTemplate; + + continue; + } + + // 2. 如果方法自定义路由模板且以 `/` 开头,则跳过 + if (!string.IsNullOrWhiteSpace(selectorModel.AttributeRouteModel.Template) && selectorModel.AttributeRouteModel.Template.StartsWith('/')) continue; + } + + string template; + string controllerRouteTemplate = null; + // 如果动作方法名称为空、参数值为空,且无需保留谓词,则只生成控制器路由模板 + if (action.ActionName.Length == 0 && !isKeepName && action.Parameters.Count == 0) + { + template = GenerateControllerRouteTemplate(action.Controller, controllerApiDescriptionSettings); + if (!string.IsNullOrWhiteSpace(selectorModel.AttributeRouteModel?.Template)) + { + template = $"{template}/{selectorModel.AttributeRouteModel?.Template}"; + } + } + else + { + // 生成参数路由模板 + var parameterRouteTemplate = GenerateParameterRouteTemplates(action, isLowercaseRoute, isLowerCamelCase, hasApiControllerAttribute); + + // 生成控制器模板 + controllerRouteTemplate = GenerateControllerRouteTemplate(action.Controller, controllerApiDescriptionSettings, parameterRouteTemplate); + + // 拼接动作方法路由模板 + var ActionStartTemplate = parameterRouteTemplate != null ? (parameterRouteTemplate.ActionStartTemplates.Count == 0 ? null : string.Join("/", parameterRouteTemplate.ActionStartTemplates)) : null; + var ActionEndTemplate = parameterRouteTemplate != null ? (parameterRouteTemplate.ActionEndTemplates.Count == 0 ? null : string.Join("/", parameterRouteTemplate.ActionEndTemplates)) : null; + + // 判断是否定义了控制器路由,如果定义,则不拼接控制器路由 + var actionRouteTemplate = string.IsNullOrWhiteSpace(action.ActionName) + || (action.Controller.Selectors[0].AttributeRouteModel?.Template?.Contains("[action]") ?? false) ? null : (selectorModel?.AttributeRouteModel?.Template ?? selectorModel?.AttributeRouteModel?.Name ?? "[action]"); + + if (actionRouteTemplate == null && !string.IsNullOrWhiteSpace(selectorModel.AttributeRouteModel?.Template)) + { + actionRouteTemplate = $"{actionRouteTemplate}/{selectorModel.AttributeRouteModel?.Template}"; + } + + template = string.IsNullOrWhiteSpace(controllerRouteTemplate) + ? $"{(string.IsNullOrWhiteSpace(module) ? "/" : $"/{module}/")}{ActionStartTemplate}/{actionRouteTemplate}/{ActionEndTemplate}" + : $"{controllerRouteTemplate}/{(string.IsNullOrWhiteSpace(module) ? null : $"/{module}/")}{ActionStartTemplate}/{actionRouteTemplate}/{ActionEndTemplate}"; + } + + AttributeRouteModel actionAttributeRouteModel = null; + if (!string.IsNullOrWhiteSpace(template)) + { + // 处理多个斜杆问题 + template = isLowercaseRoute ? template.ToLower() : isLowerCamelCase ? template.ToLowerCamelCase() : template; + template = HandleRouteTemplateRepeat(template); + template = Regex.Replace(template, @"\/{2,}", "/"); + + // 生成路由 + actionAttributeRouteModel = string.IsNullOrWhiteSpace(template) ? null : new AttributeRouteModel(new RouteAttribute(template)); + } + + // 拼接路由 + selectorModel.AttributeRouteModel = string.IsNullOrWhiteSpace(controllerRouteTemplate) + ? (actionAttributeRouteModel == null ? null : AttributeRouteModel.CombineAttributeRouteModel(action.Controller.Selectors[0].AttributeRouteModel, actionAttributeRouteModel)) + : actionAttributeRouteModel; + } + } + + /// + /// 生成控制器路由模板 + /// + /// + /// + /// 参数路由模板 + /// + private string GenerateControllerRouteTemplate(ControllerModel controller, ApiDescriptionSettingsAttribute apiDescriptionSettings, ParameterRouteTemplate parameterRouteTemplate = default) + { + var selectorModel = controller.Selectors[0]; + // 跳过已配置路由特性的配置 + if (selectorModel.AttributeRouteModel != null) return default; + + // 读取模块 + var module = apiDescriptionSettings?.Module ?? _dynamicApiControllerSettings.DefaultModule; + + // 路由默认前缀 + var routePrefix = _dynamicApiControllerSettings.DefaultRoutePrefix; + + // 生成路由模板 + // 如果参数路由模板为空或不包含任何控制器参数模板,则返回正常的模板 + if (parameterRouteTemplate == null || (parameterRouteTemplate.ControllerStartTemplates.Count == 0 && parameterRouteTemplate.ControllerEndTemplates.Count == 0)) + return $"{(string.IsNullOrWhiteSpace(routePrefix) ? null : $"{routePrefix}/")}{(string.IsNullOrWhiteSpace(module) ? null : $"/{module}/")}[controller]"; + + // 拼接控制器路由模板 + var controllerStartTemplate = parameterRouteTemplate.ControllerStartTemplates.Count == 0 ? null : string.Join("/", parameterRouteTemplate.ControllerStartTemplates); + var controllerEndTemplate = parameterRouteTemplate.ControllerEndTemplates.Count == 0 ? null : string.Join("/", parameterRouteTemplate.ControllerEndTemplates); + var template = $"{(string.IsNullOrWhiteSpace(routePrefix) ? null : $"{routePrefix}/")}{(string.IsNullOrWhiteSpace(module) ? null : $"/{module}/")}{controllerStartTemplate}/[controller]/{controllerEndTemplate}"; + + return template; + } + + /// + /// 生成参数路由模板(非引用类型) + /// + /// 动作方法模型 + /// + /// + /// + private ParameterRouteTemplate GenerateParameterRouteTemplates(ActionModel action, bool isLowercaseRoute, bool isLowerCamelCase, bool hasApiControllerAttribute) + { + // 如果没有参数,则跳过 + if (action.Parameters.Count == 0) return default; + + var parameterRouteTemplate = new ParameterRouteTemplate(); + var parameters = action.Parameters + .Where(u => !(u.BindingInfo is { BindingSource.DisplayName: "Special" } || u.Attributes.Any(c => c.GetType() == typeof(BindNeverAttribute)))); + + // 判断是否贴有 [QueryParameters] 特性 + var isQueryParametersAction = action.Attributes.Any(u => u is QueryParametersAttribute); + + // 遍历所有参数 + foreach (var parameterModel in parameters) + { + var parameterType = parameterModel.ParameterType; + var parameterAttributes = parameterModel.Attributes; + + // 处理小写参数路由匹配问题 + if (isLowercaseRoute) parameterModel.ParameterName = parameterModel.ParameterName.ToLower(); + + // 处理小驼峰命名 + if (isLowerCamelCase) parameterModel.ParameterName = parameterModel.ParameterName.ToLowerCamelCase(); + + // 判断是否贴有任何 [FromXXX] 特性了 + var hasFormAttribute = parameterAttributes.Any(u => typeof(IBindingSourceMetadata).IsAssignableFrom(u.GetType())); + + // 判断方法贴有 [QueryParameters] 特性且当前参数没有任何 [FromXXX] 特性,则添加 [FromQuery] 特性 + if (isQueryParametersAction && !hasFormAttribute) + { + parameterModel.BindingInfo = BindingInfo.GetBindingInfo(new[] { new FromQueryAttribute() }); + continue; + } + + // 如果没有贴 [FromRoute] 特性且不是基元类型,则跳过 + // 如果没有贴 [FromRoute] 特性且有任何绑定特性,则跳过 + if (!parameterAttributes.Any(u => u is FromRouteAttribute) + && (!parameterType.IsRichPrimitive() || hasFormAttribute)) continue; + + // 处理基元数组数组类型,还有全局配置参数问题 + if (_dynamicApiControllerSettings?.UrlParameterization == true || parameterType.IsArray) + { + parameterModel.BindingInfo = BindingInfo.GetBindingInfo(new[] { new FromQueryAttribute() }); + continue; + } + + // 处理 [ApiController] 特性情况 + // https://docs.microsoft.com/en-US/aspnet/core/web-api/?view=aspnetcore-5.0#binding-source-parameter-inference + if (!hasFormAttribute && hasApiControllerAttribute) continue; + + // 处理默认基元参数绑定方式,若是 query([FromQuery])则跳过 + if (_dynamicApiControllerSettings?.DefaultBindingInfo?.ToLower() == "query") + { + continue; + } + + // 判断是否可以为null + var canBeNull = parameterType.IsGenericType && parameterType.GetGenericTypeDefinition() == typeof(Nullable<>); + + // 判断是否贴有路由约束特性 + string constraint = default; + if (parameterAttributes.FirstOrDefault(u => u is RouteConstraintAttribute) is RouteConstraintAttribute routeConstraint && !string.IsNullOrWhiteSpace(routeConstraint.Constraint)) + { + constraint = !routeConstraint.Constraint.StartsWith(':') + ? $":{routeConstraint.Constraint}" : routeConstraint.Constraint; + } + + var template = $"{{{(constraint == ":*" ? "*" : default)}{parameterModel.ParameterName}{(canBeNull ? "?" : string.Empty)}{(constraint == ":*" ? default : constraint)}}}"; + // 如果没有贴路由位置特性,则默认添加到动作方法后面 + if (parameterAttributes.FirstOrDefault(u => u is ApiSeatAttribute) is not ApiSeatAttribute apiSeat) + { + parameterRouteTemplate.ActionEndTemplates.Add(template); + continue; + } + + // 生成路由参数位置 + switch (apiSeat.Seat) + { + // 控制器名之前 + case ApiSeats.ControllerStart: + parameterRouteTemplate.ControllerStartTemplates.Add(template); + break; + // 控制器名之后 + case ApiSeats.ControllerEnd: + parameterRouteTemplate.ControllerEndTemplates.Add(template); + break; + // 动作方法名之前 + case ApiSeats.ActionStart: + parameterRouteTemplate.ActionStartTemplates.Add(template); + break; + // 动作方法名之后 + case ApiSeats.ActionEnd: + parameterRouteTemplate.ActionEndTemplates.Add(template); + break; + + default: break; + } + } + + return parameterRouteTemplate; + } + + /// + /// 配置控制器和动作方法名称 + /// + /// + /// + /// + /// + /// + /// 针对 [ActionName] 特性和 [HttpMethod] 特性处理 + /// + private (string Name, bool IsLowercaseRoute, bool IsKeepName, bool IsLowerCamelCase) ConfigureControllerAndActionName(ApiDescriptionSettingsAttribute apiDescriptionSettings + , string orignalName + , string[] affixes + , Func configure + , ApiDescriptionSettingsAttribute controllerApiDescriptionSettings = default + , string actionName = default) + { + // 获取版本号 + var apiVersion = apiDescriptionSettings?.Version; + var isKeepName = false; + + // 判断是否有自定义名称 + var tempName = actionName ?? apiDescriptionSettings?.Name; + if (string.IsNullOrWhiteSpace(tempName)) + { + // 处理版本号 + var (name, version) = ResolveNameVersion(orignalName); + tempName = name; + apiVersion ??= version; + + // 清除指定(前)后缀,只处理后缀,解决 ServiceService 的情况 + tempName = tempName.ClearStringAffixes(1, affixes: affixes); + + isKeepName = CheckIsKeepName(controllerApiDescriptionSettings == null ? null : apiDescriptionSettings, controllerApiDescriptionSettings ?? apiDescriptionSettings); + + // 判断是否保留原有名称 + if (!isKeepName) + { + // 自定义配置 + tempName = configure.Invoke(tempName); + + // 处理骆驼命名 + if (CheckIsSplitCamelCase(controllerApiDescriptionSettings == null ? null : apiDescriptionSettings, controllerApiDescriptionSettings ?? apiDescriptionSettings)) + { + tempName = string.Join(_dynamicApiControllerSettings.CamelCaseSeparator, tempName.SplitCamelCase()); + } + } + } + + // 拼接名称和版本号 + var versionString = string.IsNullOrWhiteSpace(apiVersion) ? null : $"{_dynamicApiControllerSettings.VersionSeparator}{apiVersion}/"; + + var isLowercaseRoute = CheckIsLowercaseRoute(controllerApiDescriptionSettings == null ? null : apiDescriptionSettings, controllerApiDescriptionSettings ?? apiDescriptionSettings); + var isLowerCamelCase = CheckIsLowerCamelCase(controllerApiDescriptionSettings == null ? null : apiDescriptionSettings, controllerApiDescriptionSettings ?? apiDescriptionSettings); + + tempName = isLowerCamelCase ? tempName.ToLowerCamelCase() : tempName; + + // 处理版本号前后问题 + var newName = _dynamicApiControllerSettings.VersionInFront == true + ? $"{versionString}{tempName}" + : $"{tempName}{versionString}"; + + newName = newName.TrimEnd('/'); + + return (isLowercaseRoute ? newName.ToLower() : newName + , isLowercaseRoute, isKeepName, isLowerCamelCase); + } + + /// + /// 检查是否设置了 KeepName参数 + /// + /// + /// + /// + private bool CheckIsKeepName(ApiDescriptionSettingsAttribute apiDescriptionSettings, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + bool isKeepName; + + // 判断 Action 是否配置了 KeepName 属性 + if (apiDescriptionSettings?.KeepName != null) + { + var canParse = bool.TryParse(apiDescriptionSettings.KeepName.ToString(), out var value); + isKeepName = canParse && value; + } + // 判断 Controller 是否配置了 KeepName 属性 + else if (controllerApiDescriptionSettings?.KeepName != null) + { + var canParse = bool.TryParse(controllerApiDescriptionSettings.KeepName.ToString(), out var value); + isKeepName = canParse && value; + } + // 取全局配置 + else isKeepName = _dynamicApiControllerSettings?.KeepName == true; + + return isKeepName; + } + + /// + /// 检查是否设置了 KeepVerb 参数 + /// + /// + /// + /// + private bool CheckIsKeepVerb(ApiDescriptionSettingsAttribute apiDescriptionSettings, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + bool isKeepVerb; + + // 判断 Action 是否配置了 KeepVerb 属性 + if (apiDescriptionSettings?.KeepVerb != null) + { + var canParse = bool.TryParse(apiDescriptionSettings.KeepVerb.ToString(), out var value); + isKeepVerb = canParse && value; + } + // 判断 Controller 是否配置了 KeepVerb 属性 + else if (controllerApiDescriptionSettings?.KeepVerb != null) + { + var canParse = bool.TryParse(controllerApiDescriptionSettings.KeepVerb.ToString(), out var value); + isKeepVerb = canParse && value; + } + // 取全局配置 + else isKeepVerb = _dynamicApiControllerSettings?.KeepVerb == true; + + return isKeepVerb; + } + + /// + /// 检查是否设置了 ForceWithRoutePrefix 参数 + /// + /// + /// + private bool CheckIsForceWithDefaultRoute(ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + bool isForceWithRoutePrefix; + + // 判断 Controller 是否配置了 ForceWithRoutePrefix 属性 + if (controllerApiDescriptionSettings?.ForceWithRoutePrefix != null) + { + var canParse = bool.TryParse(controllerApiDescriptionSettings.ForceWithRoutePrefix.ToString(), out var value); + isForceWithRoutePrefix = canParse && value; + } + // 取全局配置 + else isForceWithRoutePrefix = _dynamicApiControllerSettings?.ForceWithRoutePrefix == true; + + return isForceWithRoutePrefix; + } + + /// + /// 检查是否设置了 AsLowerCamelCase 参数 + /// + /// + /// + /// + private bool CheckIsLowerCamelCase(ApiDescriptionSettingsAttribute apiDescriptionSettings, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + bool isLowerCamelCase; + + // 判断 Action 是否配置了 AsLowerCamelCase 属性 + if (apiDescriptionSettings?.AsLowerCamelCase != null) + { + var canParse = bool.TryParse(apiDescriptionSettings.AsLowerCamelCase.ToString(), out var value); + isLowerCamelCase = canParse && value; + } + // 判断 Controller 是否配置了 AsLowerCamelCase 属性 + else if (controllerApiDescriptionSettings?.AsLowerCamelCase != null) + { + var canParse = bool.TryParse(controllerApiDescriptionSettings.AsLowerCamelCase.ToString(), out var value); + isLowerCamelCase = canParse && value; + } + // 取全局配置 + else isLowerCamelCase = _dynamicApiControllerSettings?.AsLowerCamelCase == true; + + return isLowerCamelCase; + } + + /// + /// 判断切割命名参数是否配置 + /// + /// + /// + /// + private static bool CheckIsSplitCamelCase(ApiDescriptionSettingsAttribute apiDescriptionSettings, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + bool isSplitCamelCase; + + // 判断 Action 是否配置了 SplitCamelCase 属性 + if (apiDescriptionSettings?.SplitCamelCase != null) + { + var canParse = bool.TryParse(apiDescriptionSettings.SplitCamelCase.ToString(), out var value); + isSplitCamelCase = !canParse || value; + } + // 判断 Controller 是否配置了 SplitCamelCase 属性 + else if (controllerApiDescriptionSettings?.SplitCamelCase != null) + { + var canParse = bool.TryParse(controllerApiDescriptionSettings.SplitCamelCase.ToString(), out var value); + isSplitCamelCase = !canParse || value; + } + // 取全局配置 + else isSplitCamelCase = true; + + return isSplitCamelCase; + } + + /// + /// 检查是否启用小写路由 + /// + /// + /// + /// + private bool CheckIsLowercaseRoute(ApiDescriptionSettingsAttribute apiDescriptionSettings, ApiDescriptionSettingsAttribute controllerApiDescriptionSettings) + { + bool isLowercaseRoute; + + // 判断 Action 是否配置了 LowercaseRoute 属性 + if (apiDescriptionSettings?.LowercaseRoute != null) + { + var canParse = bool.TryParse(apiDescriptionSettings.LowercaseRoute.ToString(), out var value); + isLowercaseRoute = !canParse || value; + } + // 判断 Controller 是否配置了 LowercaseRoute 属性 + else if (controllerApiDescriptionSettings?.LowercaseRoute != null) + { + var canParse = bool.TryParse(controllerApiDescriptionSettings.LowercaseRoute.ToString(), out var value); + isLowercaseRoute = !canParse || value; + } + // 取全局配置 + else isLowercaseRoute = (_dynamicApiControllerSettings?.LowercaseRoute) != false; + + return isLowercaseRoute; + } + + /// + /// 配置规范化结果类型 + /// + /// + private static void ConfigureActionUnifyResultAttribute(ActionModel action) + { + // 判断是否手动添加了标注或跳过规范化处理 + if (UnifyContext.CheckSucceededNonUnify(action.ActionMethod, out _, false)) return; + + // 获取真实类型 + var returnType = action.ActionMethod.GetRealReturnType(); + if (returnType == typeof(void)) return; + + // 添加规范化结果特性 + action.Filters.Add(new UnifyResultAttribute(returnType, StatusCodes.Status200OK, action.ActionMethod)); + } + + /// + /// 解析名称中的版本号 + /// + /// 名称 + /// 名称和版本号 + private (string name, string version) ResolveNameVersion(string name) + { + if (!_nameVersionRegex.IsMatch(name)) return (name, default); + + var version = _nameVersionRegex.Match(name).Groups["version"].Value.Replace("_", "."); + return (_nameVersionRegex.Replace(name, ""), version); + } + + /// + /// 获取方法名映射 [HttpMethod] 规则 + /// + /// + private void LoadVerbToHttpMethodsConfigure() + { + var defaultVerbToHttpMethods = Penetrates.VerbToHttpMethods; + + // 获取配置的复写映射规则 + var verbToHttpMethods = _dynamicApiControllerSettings.VerbToHttpMethods; + + if (verbToHttpMethods is not null) + { + // 获取所有参数大于1的配置 + var settingsVerbToHttpMethods = verbToHttpMethods + .Where(u => u.Length > 1) + .ToDictionary(u => u[0].ToString().ToLower(), u => u[1]?.ToString()); + + defaultVerbToHttpMethods.AddOrUpdate(settingsVerbToHttpMethods); + } + } + + /// + /// 处理路由模板重复参数 + /// + /// + /// + private static string HandleRouteTemplateRepeat(string template) + { + var isStartDiagonal = template.StartsWith('/'); + var paths = template.Split('/', StringSplitOptions.RemoveEmptyEntries); + var routeParts = new List(); + + // 参数模板 + var paramTemplates = new List(); + foreach (var part in paths) + { + // 不包含 {} 模板的直接添加 + if (!Regex.IsMatch(part, commonTemplatePattern)) + { + routeParts.Add(part); + continue; + } + else + { + var templates = Regex.Matches(part, commonTemplatePattern).Select(t => t.Value); + foreach (var temp in templates) + { + // 处理带路由约束的路由参数模板 https://gitee.com/zuohuaijun/Admin.NET/issues/I736XJ + var t = !temp.Contains('?', StringComparison.CurrentCulture) + ? (!temp.Contains(':', StringComparison.CurrentCulture) + ? temp + : temp[..temp.IndexOf(':')] + "}") + : temp[..temp.IndexOf('?')] + "}"; + + if (!paramTemplates.Contains(t, StringComparer.OrdinalIgnoreCase)) + { + routeParts.Add(part); + paramTemplates.Add(t); + continue; + } + } + } + } + + var tmp = string.Join('/', routeParts); + return isStartDiagonal ? "/" + tmp : tmp; + } + + /// + /// 排除自定义参数模板并进行路由小写 + /// + /// + /// + private static string ConvertToLowerCaseExceptBrackets(string input) + { + if (string.IsNullOrWhiteSpace(input)) return input; + + // 将整个字符串转为小写 + var lowerCaseInput = input.ToLower(); + + // 匹配花括号内的内容 + var regex = new Regex(@"\{.*?\}"); + + // 找到花括号内容并替换回原始大写形式 + var matches = regex.Matches(input); + foreach (Match match in matches) + { + var startIndex = match.Index; + var length = match.Length; + var originalPart = input.Substring(startIndex, length); + lowerCaseInput = lowerCaseInput.Remove(startIndex, length).Insert(startIndex, originalPart); + } + + return lowerCaseInput; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Dependencies/IDynamicApiController.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Dependencies/IDynamicApiController.cs new file mode 100644 index 000000000..3aa40dab7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Dependencies/IDynamicApiController.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +//namespace ThingsGateway.DynamicApiController; + +///// +///// 动态Api控制器依赖接口 +///// +//public interface IDynamicApiController +//{ +//} diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Enums/ApiSeats.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Enums/ApiSeats.cs new file mode 100644 index 000000000..0fdff4f51 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Enums/ApiSeats.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 接口参数位置 +/// +[SuppressSniffer] +public enum ApiSeats +{ + /// + /// 控制器之前 + /// + [Description("控制器之前")] + ControllerStart, + + /// + /// 控制器之后 + /// + [Description("控制器之后")] + ControllerEnd, + + /// + /// 行为之前 + /// + [Description("行为之前")] + ActionStart, + + /// + /// 行为之后 + /// + [Description("行为之后")] + ActionEnd +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Extensions/DynamicApiControllerServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Extensions/DynamicApiControllerServiceCollectionExtensions.cs new file mode 100644 index 000000000..32af1b237 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Extensions/DynamicApiControllerServiceCollectionExtensions.cs @@ -0,0 +1,142 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using System.Reflection; + +using ThingsGateway; +using ThingsGateway.DynamicApiController; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 动态接口控制器拓展类 +/// +[SuppressSniffer] +public static class DynamicApiControllerServiceCollectionExtensions +{ + /// + /// 添加动态接口控制器服务 + /// + /// Mvc构建器 + /// Mvc构建器 + public static IMvcBuilder AddDynamicApiControllers(this IMvcBuilder mvcBuilder) + { + mvcBuilder.Services.AddDynamicApiControllers(); + + return mvcBuilder; + } + + + /// + /// 配置动态 WebAPI + /// + /// 请确保在 AddDynamicApiControllers()Inject() 之前注册。 + /// + /// + public static void ConfigureDynamicApiController(this IServiceCollection services, Action configure) + { + var dynamicApiControllerBuilder = new DynamicApiControllerBuilder(); + configure?.Invoke(dynamicApiControllerBuilder); + + services.TryAddSingleton(dynamicApiControllerBuilder); + } + + /// + /// 添加动态接口控制器服务 + /// + /// + /// + public static IServiceCollection AddDynamicApiControllers(this IServiceCollection services) + { + // 解决服务重复注册问题 + if (services.Any(u => u.ServiceType == typeof(MvcActionDescriptorChangeProvider))) + { + return services; + } + + var partManager = services.FirstOrDefault(s => s.ServiceType == typeof(ApplicationPartManager))?.ImplementationInstance as ApplicationPartManager + ?? throw new InvalidOperationException($"`{nameof(AddDynamicApiControllers)}` must be invoked after `{nameof(MvcServiceCollectionExtensions.AddControllers)}`."); + + // 解决项目类型为 不能加载 API 问题,默认支持 + foreach (var assembly in App.Assemblies) + { + if (partManager.ApplicationParts.Any(u => u.Name != assembly.GetName().Name)) + { + partManager.ApplicationParts.Add(new AssemblyPart(assembly)); + } + } + + // 载入模块化/插件程序集部件 + if (App.ExternalAssemblies.Any()) + { + foreach (var assembly in App.ExternalAssemblies) + { + if (partManager.ApplicationParts.Any(u => u.Name != assembly.GetName().Name)) + { + partManager.ApplicationParts.Add(new AssemblyPart(assembly)); + } + } + } + + // 添加控制器特性提供器 + partManager.FeatureProviders.Add(new DynamicApiControllerFeatureProvider()); + + // 添加动态 WebAPI 运行时感知服务 + services.AddSingleton() + .AddSingleton(provider => provider.GetRequiredService()); + services.AddSingleton(); + + // 添加配置 + services.AddConfigurableOptions(); + + // 配置 Mvc 选项 + services.Configure(options => + { + // 添加应用模型转换器 + options.Conventions.Add(new DynamicApiControllerApplicationModelConvention(services)); + + // 添加 text/plain 请求 Body 参数支持 + options.InputFormatters.Add(new TextPlainMediaTypeFormatter()); + }); + + return services; + } + + /// + /// 添加外部程序集部件集合 + /// + /// Mvc构建器 + /// + /// Mvc构建器 + public static IMvcBuilder AddExternalAssemblyParts(this IMvcBuilder mvcBuilder, IEnumerable assemblies) + { + var partManager = mvcBuilder.PartManager; + // 载入程序集部件 + if (partManager != null && assemblies != null && assemblies.Any()) + { + foreach (var assembly in assemblies) + { + if (partManager.ApplicationParts.Any(u => u.Name != assembly.GetName().Name)) + { + mvcBuilder.AddApplicationPart(assembly); + } + } + } + + return mvcBuilder; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Formatters/TextPlainMediaTypeFormatter.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Formatters/TextPlainMediaTypeFormatter.cs new file mode 100644 index 000000000..18b47bd77 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Formatters/TextPlainMediaTypeFormatter.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Net.Http.Headers; + +using System.Text; + +namespace Microsoft.AspNetCore.Mvc.Formatters; + +/// +/// text/plain 请求 Body 参数支持 +/// +[SuppressSniffer] +public sealed class TextPlainMediaTypeFormatter : TextInputFormatter +{ + /// + /// 构造函数 + /// + public TextPlainMediaTypeFormatter() + { + SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/plain")); + + SupportedEncodings.Add(Encoding.UTF8); + SupportedEncodings.Add(Encoding.Unicode); + } + + /// + /// 重写 + /// + /// + /// + /// + public async override Task ReadRequestBodyAsync(InputFormatterContext context, Encoding effectiveEncoding) + { + using var reader = new StreamReader(context.HttpContext.Request.Body, effectiveEncoding); + var stringContent = await reader.ReadToEndAsync().ConfigureAwait(false); + + return await InputFormatterResult.SuccessAsync(stringContent).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Internal/ParameterRouteTemplate.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Internal/ParameterRouteTemplate.cs new file mode 100644 index 000000000..7eb29b8c6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Internal/ParameterRouteTemplate.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.DynamicApiController; + +/// +/// 参数路由模板 +/// +internal sealed class ParameterRouteTemplate +{ + /// + /// 构造函数 + /// + public ParameterRouteTemplate() + { + ControllerStartTemplates = new List(); + ControllerEndTemplates = new List(); + ActionStartTemplates = new List(); + ActionEndTemplates = new List(); + } + + /// + /// 控制器之前的参数 + /// + public IList ControllerStartTemplates { get; set; } + + /// + /// 控制器之后的参数 + /// + public IList ControllerEndTemplates { get; set; } + + /// + /// 行为之前的参数 + /// + public IList ActionStartTemplates { get; set; } + + /// + /// 行为之后的参数 + /// + public IList ActionEndTemplates { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Internal/Penetrates.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Internal/Penetrates.cs new file mode 100644 index 000000000..d6c873123 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Internal/Penetrates.cs @@ -0,0 +1,118 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; + +using System.Collections.Concurrent; + +namespace ThingsGateway.DynamicApiController; + +/// +/// 常量、公共方法配置类 +/// +internal static class Penetrates +{ + /// + /// 分组分隔符 + /// + internal const string GroupSeparator = "##"; + + /// + /// 请求动词映射字典 + /// + internal static ConcurrentDictionary VerbToHttpMethods { get; private set; } + + /// + /// 控制器排序集合 + /// + internal static ConcurrentDictionary ControllerOrderCollection { get; set; } + + /// + /// 构造函数 + /// + static Penetrates() + { + ControllerOrderCollection = new ConcurrentDictionary(); + + VerbToHttpMethods = new ConcurrentDictionary + { + ["post"] = "POST", + ["add"] = "POST", + ["create"] = "POST", + ["insert"] = "POST", + ["submit"] = "POST", + + ["get"] = "GET", + ["find"] = "GET", + ["fetch"] = "GET", + ["query"] = "GET", + //["getlist"] = "GET", + //["getall"] = "GET", + + ["put"] = "PUT", + ["update"] = "PUT", + + ["delete"] = "DELETE", + ["remove"] = "DELETE", + ["clear"] = "DELETE", + + ["patch"] = "PATCH" + }; + + IsApiControllerCached = new ConcurrentDictionary(); + } + + /// + /// 缓存集合 + /// + private static readonly ConcurrentDictionary IsApiControllerCached; + + /// + /// 是否是Api控制器 + /// + /// type + /// + internal static bool IsApiController(Type type) + { + return IsApiControllerCached.GetOrAdd(type, Function); + + // 本地静态方法 + static bool Function(Type type) + { + // 排除 OData 控制器 + if (type.Assembly.GetName().Name.StartsWith("Microsoft.AspNetCore.OData")) return false; + + // 不能是非公开、基元类型、值类型、抽象类、接口、泛型类 + if (!type.IsPublic || type.IsPrimitive || type.IsValueType || type.IsAbstract || type.IsInterface || type.IsGenericType) return false; + + // 如果控制器贴有 [NonController] 特性则忽略 + if (type.IsDefined(typeof(NonControllerAttribute), false)) return false; + + // 继承 ControllerBase 或 实现 IDynamicApiController 的类型 或 贴了 [DynamicApiController] 特性 + if ((!typeof(Controller).IsAssignableFrom(type) && typeof(ControllerBase).IsAssignableFrom(type)) + //|| typeof(IDynamicApiController).IsAssignableFrom(type) + //|| type.IsDefined(typeof(DynamicApiControllerAttribute), true) + // 支持没有继承 ControllerBase 且贴了 [Route] 特性的情况 + || (type.IsDefined(typeof(RouteAttribute), true))) + { + // 处理运行时动态生成程序集问题 + //if (type.Assembly?.ManifestModule?.Name == "") return true; + + // 解决 ASP.NET Core 启动时自动载入 NuGet 包导致模块化配置 SupportPackageNamePrefixs 出现非预期的结果 + //if (!App.EffectiveTypes.Any(t => t == type)) return false; + + return true; + } + + return false; + } + } +} diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Options/DynamicApiControllerSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Options/DynamicApiControllerSettingsOptions.cs new file mode 100644 index 000000000..41d0e0c47 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Options/DynamicApiControllerSettingsOptions.cs @@ -0,0 +1,156 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; + +using System.ComponentModel.DataAnnotations; + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.DynamicApiController; + +/// +/// 动态接口控制器配置 +/// +public sealed class DynamicApiControllerSettingsOptions : IConfigurableOptions +{ + /// + /// 默认路由前缀 + /// + public string DefaultRoutePrefix { get; set; } + + /// + /// 默认请求谓词 + /// + [Required] + public string DefaultHttpMethod { get; set; } + + /// + /// 默认模块名称 + /// + public string DefaultModule { get; set; } + + /// + /// 小写路由 + /// + public bool? LowercaseRoute { get; set; } + + /// + /// 小驼峰命名(首字符小写) + /// + public bool? AsLowerCamelCase { get; set; } + + /// + /// 保留行为名称谓词 + /// + public bool? KeepVerb { get; set; } + + /// + /// 保留名称 + /// + public bool? KeepName { get; set; } + + /// + /// 骆驼命名分隔符 + /// + public string CamelCaseSeparator { get; set; } + + /// + /// 版本号分隔符 + /// + [Required] + public string VersionSeparator { get; set; } + + /// + /// 版本号在前面 + /// + public bool? VersionInFront { get; set; } + + /// + /// 模型转查询参数(只有GET、HEAD请求有效) + /// + public bool? ModelToQuery { get; set; } + + /// + /// 支持Mvc控制器处理 + /// + public bool? SupportedMvcController { get; set; } + + /// + /// 配置参数 [FromQuery] 化,默认 false ([FromRoute]) + /// + public bool? UrlParameterization { get; set; } + + /// + /// 被舍弃的控制器名称前后缀 + /// + public string[] AbandonControllerAffixes { get; set; } + + /// + /// 被舍弃的行为名称前后缀 + /// + public string[] AbandonActionAffixes { get; set; } + + /// + /// 复写默认配置路由规则配置 + /// + public object[][] VerbToHttpMethods { get; set; } + + /// + /// 默认区域 + /// + public string DefaultArea { get; set; } + + /// + /// 强制携带路由前缀,即使使用 [Route] 重写 + /// + public bool? ForceWithRoutePrefix { get; set; } + + /// + /// 默认基元参数绑定方式 + /// + public string DefaultBindingInfo { get; set; } + + /// + /// 选项后期配置 + /// + /// + /// + public void PostConfigure(DynamicApiControllerSettingsOptions options, IConfiguration configuration) + { + options.DefaultRoutePrefix ??= "api"; + options.DefaultHttpMethod ??= "POST"; + options.LowercaseRoute ??= true; + options.AsLowerCamelCase ??= false; + options.KeepVerb ??= false; + options.KeepName ??= false; + options.CamelCaseSeparator ??= "-"; + options.VersionSeparator ??= "v"; + options.VersionInFront ??= true; + options.ModelToQuery ??= false; + options.SupportedMvcController ??= false; + options.ForceWithRoutePrefix ??= false; + options.AbandonControllerAffixes ??= new string[] + { + "AppServices", + "AppService", + "ApiController", + "Controller", + "Services", + "Service" + }; + options.AbandonActionAffixes ??= new string[] + { + "Async" + }; + DefaultBindingInfo ??= "route"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Providers/DynamicApiControllerFeatureProvider.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Providers/DynamicApiControllerFeatureProvider.cs new file mode 100644 index 000000000..925481ae9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Providers/DynamicApiControllerFeatureProvider.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.Controllers; + +using System.Reflection; + +namespace ThingsGateway.DynamicApiController; + +/// +/// 动态接口控制器特性提供器 +/// +[SuppressSniffer] +public sealed class DynamicApiControllerFeatureProvider : ControllerFeatureProvider +{ + /// + /// 扫描控制器 + /// + /// 类型 + /// bool + protected override bool IsController(TypeInfo typeInfo) + { + return Penetrates.IsApiController(typeInfo); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Providers/MvcActionDescriptorChangeProvider.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Providers/MvcActionDescriptorChangeProvider.cs new file mode 100644 index 000000000..a4b8db101 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Providers/MvcActionDescriptorChangeProvider.cs @@ -0,0 +1,53 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.Primitives; + +namespace ThingsGateway.DynamicApiController; + +/// +/// MVC 控制器感知提供器 +/// +[SuppressSniffer] +public class MvcActionDescriptorChangeProvider : IActionDescriptorChangeProvider +{ + private CancellationTokenSource _cancellationTokenSource; + private CancellationChangeToken _stoppingToken; + + /// + /// 构造函数 + /// + public MvcActionDescriptorChangeProvider() + { + _cancellationTokenSource = new CancellationTokenSource(); + _stoppingToken = new CancellationChangeToken(_cancellationTokenSource.Token); + } + + /// + /// 获取改变 ChangeToken + /// + /// + public IChangeToken GetChangeToken() + { + return _stoppingToken; + } + + /// + /// 通知变化 + /// + public void NotifyChanges() + { + var oldCancellationTokenSource = Interlocked.Exchange(ref _cancellationTokenSource, new CancellationTokenSource()); + _stoppingToken = new CancellationChangeToken(_cancellationTokenSource.Token); + oldCancellationTokenSource.Cancel(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Runtimes/DynamicApiRuntimeChangeProvider.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Runtimes/DynamicApiRuntimeChangeProvider.cs new file mode 100644 index 000000000..ad4ee6417 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Runtimes/DynamicApiRuntimeChangeProvider.cs @@ -0,0 +1,137 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.ApplicationParts; + +using System.Reflection; + +namespace ThingsGateway.DynamicApiController; + +/// +/// 动态 WebAPI 运行时感知提供器 +/// +internal sealed class DynamicApiRuntimeChangeProvider : IDynamicApiRuntimeChangeProvider +{ + /// + /// 应用程序部件管理器 + /// + private readonly ApplicationPartManager _applicationPartManager; + + /// + /// MVC 控制器感知提供器 + /// + private readonly MvcActionDescriptorChangeProvider _mvcActionDescriptorChangeProvider; + + /// + /// 构造函数 + /// + /// 应用程序部件管理器 + /// MVC 控制器感知提供器 + public DynamicApiRuntimeChangeProvider(ApplicationPartManager applicationPartManager + , MvcActionDescriptorChangeProvider mvcActionDescriptorChangeProvider) + { + _applicationPartManager = applicationPartManager; + _mvcActionDescriptorChangeProvider = mvcActionDescriptorChangeProvider; + } + + /// + /// 添加程序集 + /// + /// 程序集 + public void AddAssemblies(params Assembly[] assemblies) + { + if (assemblies != null && assemblies.Length > 0) + { + foreach (var assembly in assemblies) + { + _applicationPartManager.ApplicationParts.Add(new AssemblyPart(assembly)); + } + } + } + + /// + /// 添加程序集并立即感知变化 + /// + /// 程序集 + public void AddAssembliesWithNotifyChanges(params Assembly[] assemblies) + { + if (assemblies != null && assemblies.Length > 0) + { + AddAssemblies(assemblies); + NotifyChanges(); + } + } + + /// + /// 移除程序集 + /// + /// 程序集名称 + public void RemoveAssemblies(params string[] assemblyNames) + { + if (assemblyNames != null && assemblyNames.Length > 0) + { + foreach (var assemblyName in assemblyNames) + { + var applicationPart = _applicationPartManager.ApplicationParts.FirstOrDefault(p => p.Name == assemblyName); + if (applicationPart != null) _applicationPartManager.ApplicationParts.Remove(applicationPart); + } + + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } + + /// + /// 移除程序集 + /// + /// 程序集 + public void RemoveAssemblies(params Assembly[] assemblies) + { + if (assemblies != null && assemblies.Length > 0) + { + RemoveAssemblies(assemblies.Select(ass => ass.GetName().Name).ToArray()); + } + } + + /// + /// 移除程序集并立即感知变化 + /// + /// 程序集名称 + public void RemoveAssembliesWithNotifyChanges(params string[] assemblyNames) + { + if (assemblyNames != null && assemblyNames.Length > 0) + { + RemoveAssemblies(assemblyNames); + NotifyChanges(); + } + } + + /// + /// 移除程序集并立即感知变化 + /// + /// 程序集 + public void RemoveAssembliesWithNotifyChanges(params Assembly[] assemblies) + { + if (assemblies != null && assemblies.Length > 0) + { + RemoveAssemblies(assemblies); + NotifyChanges(); + } + } + + /// + /// 感知变化 + /// + public void NotifyChanges() + { + _mvcActionDescriptorChangeProvider.NotifyChanges(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/DynamicApiController/Runtimes/IDynamicApiRuntimeChangeProvider.cs b/src/Admin/ThingsGateway.Furion/DynamicApiController/Runtimes/IDynamicApiRuntimeChangeProvider.cs new file mode 100644 index 000000000..49a968d00 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/DynamicApiController/Runtimes/IDynamicApiRuntimeChangeProvider.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.DynamicApiController; + +/// +/// 动态 WebAPI 运行时感知提供器 +/// +public interface IDynamicApiRuntimeChangeProvider +{ + /// + /// 添加程序集 + /// + /// 程序集 + void AddAssemblies(params Assembly[] assemblies); + + /// + /// 添加程序集并立即感知变化 + /// + /// 程序集 + void AddAssembliesWithNotifyChanges(params Assembly[] assemblies); + + /// + /// 移除程序集 + /// + /// 程序集名称 + void RemoveAssemblies(params string[] assemblyNames); + + /// + /// 移除程序集 + /// + /// 程序集 + void RemoveAssemblies(params Assembly[] assemblies); + + /// + /// 移除程序集并立即感知变化 + /// + /// 程序集名称 + void RemoveAssembliesWithNotifyChanges(params string[] assemblyNames); + + /// + /// 移除程序集并立即感知变化 + /// + /// 程序集 + void RemoveAssembliesWithNotifyChanges(params Assembly[] assemblies); + + /// + /// 感知变化 + /// + void NotifyChanges(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Attributes/EventSubscribeAttribute.cs b/src/Admin/ThingsGateway.Furion/EventBus/Attributes/EventSubscribeAttribute.cs new file mode 100644 index 000000000..601fd8dec --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Attributes/EventSubscribeAttribute.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Extensitions.EventBus; + +namespace ThingsGateway.EventBus; + +/// +/// 事件处理程序特性 +/// +/// +/// 作用于 实现类实例方法 +/// 支持多个事件 Id 触发同一个事件处理程序 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)] +public sealed class EventSubscribeAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// 事件 Id + /// 只支持事件类型和 Enum 类型 + public EventSubscribeAttribute(object eventId) + { + if (eventId is string) + { + EventId = eventId as string; + } + else if (eventId is Enum) + { + EventId = (eventId as Enum).ParseToString(); + } + else throw new ArgumentException("Only support string or Enum data type."); + } + + /// + /// 事件 Id + /// + public string EventId { get; set; } + + /// + /// 是否启用模糊匹配消息 + /// + /// 支持正则表达式,bool 类型,默认为 null + public object FuzzyMatch { get; set; } = null; + + /// + /// 是否启用执行完成触发 GC 回收 + /// + /// bool 类型,默认为 null + public object GCCollect { get; set; } = null; + + /// + /// 重试次数 + /// + public int NumRetries { get; set; } = 0; + + /// + /// 重试间隔时间 + /// + /// 默认1000毫秒 + public int RetryTimeout { get; set; } = 1000; + + /// + /// 可以指定特定异常类型才重试 + /// + public Type[] ExceptionTypes { get; set; } + + /// + /// 重试失败策略配置 + /// + /// 如果没有注册,必须通过 options.AddFallbackPolicy(type) 注册 + public Type FallbackPolicy { get; set; } + + /// + /// 排序 + /// + /// 数值越大的先执行 + public int Order { get; set; } = 0; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Builders/EventBusOptionsBuilder.cs b/src/Admin/ThingsGateway.Furion/EventBus/Builders/EventBusOptionsBuilder.cs new file mode 100644 index 000000000..f0103877e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Builders/EventBusOptionsBuilder.cs @@ -0,0 +1,309 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using System.Reflection; + +namespace ThingsGateway.EventBus; + +/// +/// 事件总线配置选项构建器 +/// +[SuppressSniffer] +public sealed class EventBusOptionsBuilder +{ + /// + /// 事件订阅者类型集合 + /// + private readonly List _eventSubscribers = new(); + + /// + /// 事件发布者类型 + /// + private Type _eventPublisher; + + /// + /// 事件存储器实现工厂 + /// + private Func _eventSourceStorerImplementationFactory; + + /// + /// 事件处理程序监视器 + /// + private Type _eventHandlerMonitor; + + /// + /// 事件处理程序执行器 + /// + private Type _eventHandlerExecutor; + + /// + /// 事件重试策略类型集合 + /// + private readonly List _fallbackPolicyTypes = new(); + + /// + /// 默认内置事件源存储器内存通道容量 + /// + /// 超过 n 条待处理消息,第 n+1 条将进入等待,默认为 12000 + public int ChannelCapacity { get; set; } = 12000; + + /// + /// 是否使用 UTC 时间戳,默认 false + /// + public bool UseUtcTimestamp { get; set; } = false; + + /// + /// 是否启用模糊匹配消息 + /// + /// 支持正则表达式 + public bool FuzzyMatch { get; set; } = false; + + /// + /// 是否启用执行完成触发 GC 回收 + /// + public bool GCCollect { get; set; } = true; + + /// + /// 是否启用日志记录 + /// + public bool LogEnabled { get; set; } = true; + + /// + /// 重试失败策略配置 + /// + public Type FallbackPolicy { get; set; } + + /// + /// 未察觉任务异常事件处理程序 + /// + public EventHandler UnobservedTaskExceptionHandler { get; set; } + + /// + /// 注册事件订阅者 + /// + /// 实现自 + /// 实例 + public EventBusOptionsBuilder AddSubscriber() + where TEventSubscriber : class, IEventSubscriber + { + _eventSubscribers.Add(typeof(TEventSubscriber)); + return this; + } + + /// + /// 注册事件订阅者 + /// + /// 派生类型 + /// 实例 + public EventBusOptionsBuilder AddSubscriber(Type eventSubscriberType) + { + // 类型检查 + if (!typeof(IEventSubscriber).IsAssignableFrom(eventSubscriberType) || eventSubscriberType.IsInterface) throw new InvalidOperationException("The is not implement the IEventSubscriber interface."); + + _eventSubscribers.Add(eventSubscriberType); + return this; + } + + /// + /// 批量注册事件订阅者 + /// + /// 程序集 + /// 实例 + public EventBusOptionsBuilder AddSubscribers(params Assembly[] assemblies) + { + if (assemblies == null || assemblies.Length == 0) + { + throw new InvalidOperationException("The assemblies can be not null or empty."); + } + + // 获取所有导出类型(非接口,非抽象类且实现 IEventSubscriber)接口 + var subscribers = assemblies.SelectMany(ass => + ass.GetExportedTypes() + .Where(t => t.IsPublic && t.IsClass && !t.IsInterface && !t.IsAbstract && typeof(IEventSubscriber).IsAssignableFrom(t))); + + foreach (var subscriber in subscribers) + { + _eventSubscribers.Add(subscriber); + } + + return this; + } + + /// + /// 替换事件发布者 + /// + /// 实现自 + /// 实例 + public EventBusOptionsBuilder ReplacePublisher() + where TEventPublisher : class, IEventPublisher + { + _eventPublisher = typeof(TEventPublisher); + return this; + } + + /// + /// 替换事件源存储器 + /// + /// 自定义事件源存储器工厂 + /// 实例 + public EventBusOptionsBuilder ReplaceStorer(Func implementationFactory) + { + _eventSourceStorerImplementationFactory = implementationFactory; + return this; + } + + /// + /// 替换事件源存储器(如果初始化失败则回退为默认的) + /// + /// + /// + /// + public EventBusOptionsBuilder ReplaceStorerOrFallback(Func createStorer) + { + // 空检查 + if (createStorer == null) throw new ArgumentNullException(nameof(createStorer)); + + try + { + // 创建事件源存储器 + var storer = createStorer.Invoke(); + + // 替换事件源存储器 + ReplaceStorer(_ => storer); + } + catch { } + + return this; + } + + /// + /// 替换事件源存储器(如果初始化失败则回退为默认的) + /// + /// + /// + /// + public EventBusOptionsBuilder ReplaceStorerOrFallback(Func createStorer) + { + // 空检查 + if (createStorer == null) throw new ArgumentNullException(nameof(createStorer)); + + // 替换事件源存储器 + ReplaceStorer(serviceProvider => + { + try + { + return createStorer.Invoke(serviceProvider); + } + catch + { + return new ChannelEventSourceStorer(ChannelCapacity); + } + }); + + return this; + } + + /// + /// 注册事件处理程序监视器 + /// + /// 实现自 + /// 实例 + public EventBusOptionsBuilder AddMonitor() + where TEventHandlerMonitor : class, IEventHandlerMonitor + { + _eventHandlerMonitor = typeof(TEventHandlerMonitor); + return this; + } + + /// + /// 注册事件处理程序执行器 + /// + /// 实现自 + /// 实例 + public EventBusOptionsBuilder AddExecutor() + where TEventHandlerExecutor : class, IEventHandlerExecutor + { + _eventHandlerExecutor = typeof(TEventHandlerExecutor); + return this; + } + + /// + /// 注册事件重试策略 + /// + /// 实现自 + /// 实例 + public EventBusOptionsBuilder AddFallbackPolicy() + where TEventFallbackPolicy : class, IEventFallbackPolicy + { + _fallbackPolicyTypes.Add(typeof(TEventFallbackPolicy)); + return this; + } + + /// + /// 注册事件重试策略 + /// + /// 派生类型 + /// 实例 + public EventBusOptionsBuilder AddFallbackPolicy(Type fallbackPolicyType) + { + // 类型检查 + if (!typeof(IEventFallbackPolicy).IsAssignableFrom(fallbackPolicyType) || fallbackPolicyType.IsInterface) throw new InvalidOperationException("The is not implement the IEventFallbackPolicy interface."); + + _fallbackPolicyTypes.Add(fallbackPolicyType); + return this; + } + + /// + /// 构建事件总线配置选项 + /// + /// 服务集合对象 + internal void Build(IServiceCollection services) + { + // 注册事件订阅者 + foreach (var eventSubscriber in _eventSubscribers) + { + services.TryAddEnumerable(ServiceDescriptor.Singleton(typeof(IEventSubscriber), eventSubscriber)); + } + + // 替换事件发布者 + if (_eventPublisher != default) + { + services.Replace(ServiceDescriptor.Singleton(typeof(IEventPublisher), _eventPublisher)); + } + + // 替换事件存储器 + if (_eventSourceStorerImplementationFactory != default) + { + services.Replace(ServiceDescriptor.Singleton(_eventSourceStorerImplementationFactory)); + } + + // 注册事件监视器 + if (_eventHandlerMonitor != default) + { + services.AddSingleton(typeof(IEventHandlerMonitor), _eventHandlerMonitor); + } + + // 注册事件执行器 + if (_eventHandlerExecutor != default) + { + services.AddSingleton(typeof(IEventHandlerExecutor), _eventHandlerExecutor); + } + + // 注册事件重试策略 + foreach (var fallbackPolicyType in _fallbackPolicyTypes) + { + services.AddSingleton(fallbackPolicyType); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Constants/EventSubscribeOperates.cs b/src/Admin/ThingsGateway.Furion/EventBus/Constants/EventSubscribeOperates.cs new file mode 100644 index 000000000..cb2ff554a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Constants/EventSubscribeOperates.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 事件订阅器操作选项 +/// +/// 控制动态新增/删除事件订阅器 +internal enum EventSubscribeOperates +{ + /// + /// 添加一条订阅器 + /// + Append, + + /// + /// 删除一条订阅器 + /// + Remove +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Contexts/EventHandlerContext.cs b/src/Admin/ThingsGateway.Furion/EventBus/Contexts/EventHandlerContext.cs new file mode 100644 index 000000000..64566f767 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Contexts/EventHandlerContext.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.EventBus; + +/// +/// 事件处理程序上下文 +/// +public abstract class EventHandlerContext +{ + /// + /// 构造函数 + /// + /// 事件源(事件承载对象) + /// 共享上下文数据 + /// 触发的方法 + /// 订阅特性 + internal EventHandlerContext(IEventSource eventSource + , IDictionary properties + , MethodInfo handlerMethod + , EventSubscribeAttribute attribute) + { + Source = eventSource; + Properties = properties; + HandlerMethod = handlerMethod; + Attribute = attribute; + } + + /// + /// 事件源(事件承载对象) + /// + public IEventSource Source { get; } + + /// + /// 共享上下文数据 + /// + public IDictionary Properties { get; set; } + + /// + /// 触发的方法 + /// + /// 如果是动态订阅,可能为 null + public MethodInfo HandlerMethod { get; } + + /// + /// 订阅特性 + /// + /// 如果是动态订阅,可能为 null + public EventSubscribeAttribute Attribute { get; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Contexts/EventHandlerExecutedContext.cs b/src/Admin/ThingsGateway.Furion/EventBus/Contexts/EventHandlerExecutedContext.cs new file mode 100644 index 000000000..9307c0ae6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Contexts/EventHandlerExecutedContext.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.EventBus; + +/// +/// 事件处理程序执行后上下文 +/// +[SuppressSniffer] +public sealed class EventHandlerExecutedContext : EventHandlerContext +{ + /// + /// 构造函数 + /// + /// 事件源(事件承载对象) + /// 共享上下文数据 + /// 触发的方法 + /// 订阅特性 + internal EventHandlerExecutedContext(IEventSource eventSource + , IDictionary properties + , MethodInfo handlerMethod + , EventSubscribeAttribute attribute) + : base(eventSource, properties, handlerMethod, attribute) + { + } + + /// + /// 执行后时间 + /// + public DateTime ExecutedTime { get; internal set; } + + /// + /// 异常信息 + /// + public InvalidOperationException Exception { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Contexts/EventHandlerExecutingContext.cs b/src/Admin/ThingsGateway.Furion/EventBus/Contexts/EventHandlerExecutingContext.cs new file mode 100644 index 000000000..38de54585 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Contexts/EventHandlerExecutingContext.cs @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.EventBus; + +/// +/// 事件处理程序执行前上下文 +/// +[SuppressSniffer] +public sealed class EventHandlerExecutingContext : EventHandlerContext +{ + /// + /// 构造函数 + /// + /// 事件源(事件承载对象) + /// 共享上下文数据 + /// 触发的方法 + /// 订阅特性 + internal EventHandlerExecutingContext(IEventSource eventSource + , IDictionary properties + , MethodInfo handlerMethod + , EventSubscribeAttribute attribute) + : base(eventSource, properties, handlerMethod, attribute) + { + } + + /// + /// 执行前时间 + /// + public DateTime ExecutingTime { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Dependencies/IEventPublisher.cs b/src/Admin/ThingsGateway.Furion/EventBus/Dependencies/IEventPublisher.cs new file mode 100644 index 000000000..48634c3ff --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Dependencies/IEventPublisher.cs @@ -0,0 +1,82 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 事件发布服务依赖接口 +/// +public interface IEventPublisher +{ + /// + /// 事件处理程序事件 + /// + event EventHandler OnExecuted; + + /// + /// 发布一条消息 + /// + /// 事件源 + /// 实例 + Task PublishAsync(IEventSource eventSource); + + /// + /// 延迟发布一条消息 + /// + /// 事件源 + /// 延迟数(毫秒) + /// 实例 + Task PublishDelayAsync(IEventSource eventSource, long delay); + + /// + /// 发布一条消息 + /// + /// 事件 Id + /// 事件承载(携带)数据 + /// 取消任务 Token + /// + Task PublishAsync(string eventId, object payload = default, CancellationToken cancellationToken = default); + + /// + /// 发布一条消息 + /// + /// 事件 Id + /// 事件承载(携带)数据 + /// 取消任务 Token + /// + Task PublishAsync(Enum eventId, object payload = default, CancellationToken cancellationToken = default); + + /// + /// 延迟发布一条消息 + /// + /// 事件 Id + /// 延迟数(毫秒) + /// 事件承载(携带)数据 + /// 取消任务 Token + /// 实例 + Task PublishDelayAsync(string eventId, long delay, object payload = default, CancellationToken cancellationToken = default); + + /// + /// 延迟发布一条消息 + /// + /// 事件 Id + /// 延迟数(毫秒) + /// 事件承载(携带)数据 + /// 取消任务 Token + /// 实例 + Task PublishDelayAsync(Enum eventId, long delay, object payload = default, CancellationToken cancellationToken = default); + + /// + /// 触发事件处理程序事件 + /// + /// 事件参数 + void InvokeEvents(EventHandlerEventArgs args); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Dependencies/IEventSubscriber.cs b/src/Admin/ThingsGateway.Furion/EventBus/Dependencies/IEventSubscriber.cs new file mode 100644 index 000000000..40cfb844d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Dependencies/IEventSubscriber.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 事件订阅者依赖接口 +/// +/// +/// 可自定义事件处理方法,但须符合 Func{EventSubscribeExecutingContext, Task} 签名 +/// 通常只做依赖查找,不做服务调用 +/// +public interface IEventSubscriber +{ + /* + * // 事件处理程序定义规范 + * [EventSubscribe(YourEventID)] + * public Task YourHandler(EventHandlerExecutingContext context) + * { + * // To Do... + * } + */ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Events/EventHandlerEventArgs.cs b/src/Admin/ThingsGateway.Furion/EventBus/Events/EventHandlerEventArgs.cs new file mode 100644 index 000000000..0920966b7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Events/EventHandlerEventArgs.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 事件处理程序事件参数 +/// +[SuppressSniffer] +public sealed class EventHandlerEventArgs : EventArgs +{ + /// + /// 构造函数 + /// + /// 事件源(事件承载对象) + /// 任务处理委托调用结果 + public EventHandlerEventArgs(IEventSource eventSource, bool success) + { + Source = eventSource; + Status = success ? "SUCCESS" : "FAIL"; + } + + /// + /// 事件源(事件承载对象) + /// + public IEventSource Source { get; } + + /// + /// 执行状态 + /// + public string Status { get; } + + /// + /// 异常信息 + /// + public Exception Exception { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Executors/IEventHandlerExecutor.cs b/src/Admin/ThingsGateway.Furion/EventBus/Executors/IEventHandlerExecutor.cs new file mode 100644 index 000000000..e2bf44baa --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Executors/IEventHandlerExecutor.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 事件处理程序执行器依赖接口 +/// +public interface IEventHandlerExecutor +{ + /// + /// 执行事件处理程序 + /// + /// 在这里可以实现超时控制,失败重试控制等等 + /// 事件处理程序执行前上下文 + /// 事件处理程序 + /// 实例 + Task ExecuteAsync(EventHandlerExecutingContext context, Func handler); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Extensions/EventBusExtensitions.cs b/src/Admin/ThingsGateway.Furion/EventBus/Extensions/EventBusExtensitions.cs new file mode 100644 index 000000000..cbdb933ef --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Extensions/EventBusExtensitions.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Extensitions.EventBus; + +/// +/// 事件总线拓展类 +/// +[SuppressSniffer] +public static class EventBusExtensitions +{ + /// + /// 将事件枚举 Id 转换成字符串对象 + /// + /// + /// + public static string ParseToString(this Enum em) + { + var enumType = em.GetType(); + return $"{enumType.Assembly.GetName().Name};{enumType.FullName}.{em}"; + } + + /// + /// 将事件枚举字符串转换成枚举对象 + /// + /// + /// + public static Enum ParseToEnum(this string str) + { + var assemblyName = str[..str.IndexOf(';')]; + var fullName = str[(str.IndexOf(';') + 1)..str.LastIndexOf('.')]; + var name = str[(str.LastIndexOf('.') + 1)..]; + + return Enum.Parse(Assembly.Load(assemblyName).GetType(fullName), name) as Enum; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Extensions/EventBusServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/EventBus/Extensions/EventBusServiceCollectionExtensions.cs new file mode 100644 index 000000000..a9fc537ab --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Extensions/EventBusServiceCollectionExtensions.cs @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.EventBus; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// EventBus 模块服务拓展 +/// +[SuppressSniffer] +public static class EventBusServiceCollectionExtensions +{ + /// + /// 添加 EventBus 模块注册 + /// + /// 服务集合对象 + /// 事件总线配置选项构建器委托 + /// 服务集合实例 + public static IServiceCollection AddEventBus(this IServiceCollection services, Action configureOptionsBuilder) + { + // 创建初始事件总线配置选项构建器 + var eventBusOptionsBuilder = new EventBusOptionsBuilder(); + configureOptionsBuilder.Invoke(eventBusOptionsBuilder); + + return services.AddEventBus(eventBusOptionsBuilder); + } + + /// + /// 添加 EventBus 模块注册 + /// + /// 服务集合对象 + /// 事件总线配置选项构建器 + /// 服务集合实例 + public static IServiceCollection AddEventBus(this IServiceCollection services, EventBusOptionsBuilder eventBusOptionsBuilder = default) + { + // 初始化事件总线配置项 + eventBusOptionsBuilder ??= new EventBusOptionsBuilder(); + + // 注册内部服务 + services.AddInternalService(eventBusOptionsBuilder); + + // 构建事件总线服务 + eventBusOptionsBuilder.Build(services); + + // 通过工厂模式创建 + services.AddHostedService(serviceProvider => + { + // 创建事件总线后台服务对象 + var eventBusHostedService = ActivatorUtilities.CreateInstance( + serviceProvider + , eventBusOptionsBuilder.UseUtcTimestamp + , eventBusOptionsBuilder.FuzzyMatch + , eventBusOptionsBuilder.GCCollect + , eventBusOptionsBuilder.LogEnabled); + + // 订阅未察觉任务异常事件 + var unobservedTaskExceptionHandler = eventBusOptionsBuilder.UnobservedTaskExceptionHandler; + if (unobservedTaskExceptionHandler != default) + { + eventBusHostedService.UnobservedTaskException += unobservedTaskExceptionHandler; + } + + return eventBusHostedService; + }); + + return services; + } + + /// + /// 注册内部服务 + /// + /// 服务集合对象 + /// 事件总线配置选项构建器 + /// 服务集合实例 + private static IServiceCollection AddInternalService(this IServiceCollection services, EventBusOptionsBuilder eventBusOptionsBuilder) + { + // 创建默认内存通道事件源对象 + var defaultStorerOfChannel = new ChannelEventSourceStorer(eventBusOptionsBuilder.ChannelCapacity); + + // 注册后台任务队列接口/实例为单例,采用工厂方式创建 + services.AddSingleton(_ => + { + return defaultStorerOfChannel; + }); + + // 注册默认内存通道事件发布者 + services.AddSingleton(); + + // 注册事件总线工厂 + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Factories/EventBusFactory.cs b/src/Admin/ThingsGateway.Furion/EventBus/Factories/EventBusFactory.cs new file mode 100644 index 000000000..0e6d6e72a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Factories/EventBusFactory.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.EventBus; + +/// +/// 事件总线工厂默认实现 +/// +internal sealed class EventBusFactory : IEventBusFactory +{ + /// + /// 事件源存储器 + /// + private readonly IEventSourceStorer _eventSourceStorer; + + /// + /// 构造函数 + /// + /// 事件源存储器 + public EventBusFactory(IEventSourceStorer eventSourceStorer) + { + _eventSourceStorer = eventSourceStorer; + } + + /// + /// 添加事件订阅者 + /// + /// 事件 Id + /// 事件订阅委托 + /// 特性对象 + /// 对象 + /// 取消任务 Token + /// + public async Task Subscribe(string eventId, Func handler, EventSubscribeAttribute attribute = default, MethodInfo handlerMethod = default, CancellationToken cancellationToken = default) + { + // 空检查 + if (handler == null) throw new ArgumentNullException(nameof(handler)); + + await _eventSourceStorer.WriteAsync(new EventSubscribeOperateSource + { + SubscribeEventId = eventId, + Attribute = attribute, + Handler = handler, + HandlerMethod = handlerMethod, + Operate = EventSubscribeOperates.Append + }, cancellationToken).ConfigureAwait(false); + } + + /// + /// 删除事件订阅者 + /// + /// 事件 Id + /// 取消任务 Token + /// + public async Task Unsubscribe(string eventId, CancellationToken cancellationToken = default) + { + // 空检查 + if (eventId == null) throw new ArgumentNullException(nameof(eventId)); + + await _eventSourceStorer.WriteAsync(new EventSubscribeOperateSource + { + SubscribeEventId = eventId, + Operate = EventSubscribeOperates.Remove + }, default).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Factories/IEventBusFactory.cs b/src/Admin/ThingsGateway.Furion/EventBus/Factories/IEventBusFactory.cs new file mode 100644 index 000000000..97c5de39c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Factories/IEventBusFactory.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.EventBus; + +/// +/// 事件总线工厂接口 +/// +public interface IEventBusFactory +{ + /// + /// 添加事件订阅者 + /// + /// 事件 Id + /// 事件订阅委托 + /// 特性对象 + /// 对象 + /// 取消任务 Token + /// + Task Subscribe(string eventId, Func handler, EventSubscribeAttribute attribute = default, MethodInfo handlerMethod = default, CancellationToken cancellationToken = default); + + /// + /// 删除事件订阅者 + /// + /// 事件 Id + /// 取消任务 Token + /// + Task Unsubscribe(string eventId, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/HostedServices/EventBusHostedService.cs b/src/Admin/ThingsGateway.Furion/EventBus/HostedServices/EventBusHostedService.cs new file mode 100644 index 000000000..315982978 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/HostedServices/EventBusHostedService.cs @@ -0,0 +1,456 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +using System.Collections.Concurrent; +using System.Logging; +using System.Reflection; +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.EventBus; + +/// +/// 事件总线后台主机服务 +/// +internal sealed class EventBusHostedService : BackgroundService +{ + /// + /// GC 回收默认间隔 + /// + private const int GC_COLLECT_INTERVAL_SECONDS = 3; + + /// + /// 避免由 CLR 的终结器捕获该异常从而终止应用程序,让所有未觉察异常被觉察 + /// + internal event EventHandler UnobservedTaskException; + + /// + /// 日志对象 + /// + private readonly ILogger _logger; + + /// + /// 服务提供器 + /// + private readonly IServiceProvider _serviceProvider; + + /// + /// 事件源存储器 + /// + private readonly IEventSourceStorer _eventSourceStorer; + + /// + /// 事件发布服务 + /// + private readonly IEventPublisher _eventPublisher; + + /// + /// 事件处理程序集合 + /// + private readonly ConcurrentDictionary _eventHandlers = new(); + + /// + /// 构造函数 + /// + /// 日志对象 + /// 服务提供器 + /// 事件源存储器 + /// 事件发布服务 + /// 事件订阅者集合 + /// 是否使用 Utc 时间 + /// 是否启用模糊匹配事件消息 + /// 是否启用执行完成触发 GC 回收 + /// 是否启用日志记录 + public EventBusHostedService(ILogger logger + , IServiceProvider serviceProvider + , IEventSourceStorer eventSourceStorer + , IEventPublisher eventPublisher + , IEnumerable eventSubscribers + , bool useUtcTimestamp + , bool fuzzyMatch + , bool gcCollect + , bool logEnabled) + { + _logger = logger; + _serviceProvider = serviceProvider; + _eventPublisher = eventPublisher; + _eventSourceStorer = eventSourceStorer; + + Monitor = serviceProvider.GetService(); + Executor = serviceProvider.GetService(); + UseUtcTimestamp = useUtcTimestamp; + FuzzyMatch = fuzzyMatch; + GCCollect = gcCollect; + LogEnabled = logEnabled; + + var bindingAttr = BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly; + // 逐条获取事件处理程序并进行包装 + foreach (var eventSubscriber in eventSubscribers) + { + // 获取事件订阅者类型 + var eventSubscriberType = eventSubscriber.GetType(); + + // 查找所有公开且贴有 [EventSubscribe] 的实例方法 + var eventHandlerMethods = eventSubscriberType.GetMethods(bindingAttr) + .Where(u => u.IsDefined(typeof(EventSubscribeAttribute), false)); + + // 遍历所有事件订阅者处理方法 + foreach (var eventHandlerMethod in eventHandlerMethods) + { + // 将方法转换成 Func 委托 + var handler = (Func)eventHandlerMethod.CreateDelegate(typeof(Func), eventSubscriber); + + // 处理同一个事件处理程序支持多个事件 Id 情况 + var eventSubscribeAttributes = eventHandlerMethod.GetCustomAttributes(false); + + // 逐条包装并添加到 _eventHandlers 集合中 + foreach (var eventSubscribeAttribute in eventSubscribeAttributes) + { + var wrapper = new EventHandlerWrapper(eventSubscribeAttribute.EventId) + { + Handler = handler, + HandlerMethod = eventHandlerMethod, + Attribute = eventSubscribeAttribute, + Pattern = CheckIsSetFuzzyMatch(eventSubscribeAttribute.FuzzyMatch) ? new Regex(eventSubscribeAttribute.EventId, RegexOptions.Singleline) : default, + GCCollect = CheckIsSetGCCollect(eventSubscribeAttribute.GCCollect), + Order = eventSubscribeAttribute.Order + }; + + _eventHandlers.TryAdd(wrapper, wrapper); + } + } + } + } + + /// + /// 事件处理程序监视器 + /// + private IEventHandlerMonitor Monitor { get; } + + /// + /// 事件处理程序执行器 + /// + private IEventHandlerExecutor Executor { get; } + + /// + /// 是否使用 UTC 时间 + /// + private bool UseUtcTimestamp { get; } + + /// + /// 是否启用模糊匹配事件消息 + /// + private bool FuzzyMatch { get; } + + /// + /// 是否启用执行完成触发 GC 回收 + /// + private bool GCCollect { get; } + + /// + /// 是否启用日志记录 + /// + private bool LogEnabled { get; } + + /// + /// 最近一次收集时间 + /// + private DateTime? LastGCCollectTime { get; set; } + + /// + /// 执行后台任务 + /// + /// 后台主机服务停止时取消任务 Token + /// 实例 + protected async override Task ExecuteAsync(CancellationToken stoppingToken) + { + Log(LogLevel.Information, "EventBus hosted service is running."); + + // 注册后台主机服务停止监听 + stoppingToken.Register(() => + Log(LogLevel.Debug, $"EventBus hosted service is stopping.")); + + // 监听服务是否取消 + while (!stoppingToken.IsCancellationRequested) + { + // 执行具体任务 + await BackgroundProcessing(stoppingToken).ConfigureAwait(false); + } + + Log(LogLevel.Critical, $"EventBus hosted service is stopped."); + } + + /// + /// 后台调用处理程序 + /// + /// 后台主机服务停止时取消任务 Token + /// 实例 + private async Task BackgroundProcessing(CancellationToken stoppingToken) + { + // 从事件存储器中读取一条 + var eventSource = await _eventSourceStorer.ReadAsync(stoppingToken).ConfigureAwait(false); + + // 处理动态新增/删除事件订阅器 + if (eventSource is EventSubscribeOperateSource subscribeOperateSource) + { + ManageEventSubscribers(subscribeOperateSource); + + return; + } + + // 空检查 + if (string.IsNullOrWhiteSpace(eventSource?.EventId)) + { + Log(LogLevel.Warning, "Invalid EventId, EventId cannot be or an empty string."); + + return; + } + + // 查找事件 Id 匹配的事件处理程序 + var eventHandlersThatShouldRun = _eventHandlers.Where(t => t.Key.ShouldRun(eventSource.EventId)).OrderByDescending(u => u.Value.Order) + .Select(u => u.Key) + .ToList(); + + // 空订阅 + if (eventHandlersThatShouldRun.Count <= 0) + { + Log(LogLevel.Warning, "Subscriber with event ID <{EventId}> was not found.", new[] { eventSource.EventId }); + + return; + } + + // 检查是否配置只消费一次 + if (eventSource.IsConsumOnce) + { + var randomId = RandomNumberGenerator.GetInt32(0, eventHandlersThatShouldRun.Count); + eventHandlersThatShouldRun = [eventHandlersThatShouldRun.ElementAt(randomId)]; + } + + // 创建一个任务工厂并保证执行任务都使用当前的计划程序 + var taskFactory = new TaskFactory(TaskScheduler.Current); + + // 创建共享上下文数据对象 + var properties = new Dictionary(); + + // 通过并行方式提高吞吐量并解决 Thread.Sleep 问题 + Parallel.ForEach(eventHandlersThatShouldRun, (eventHandlerThatShouldRun) => + { + // 创建新的线程执行 + taskFactory.StartNew(async () => + { + // 获取特性信息,可能为 null + var eventSubscribeAttribute = eventHandlerThatShouldRun.Attribute; + + // 创建执行前上下文 + var eventHandlerExecutingContext = new EventHandlerExecutingContext(eventSource, properties, eventHandlerThatShouldRun.HandlerMethod, eventSubscribeAttribute) + { + ExecutingTime = UseUtcTimestamp ? DateTime.UtcNow : DateTime.Now + }; + + // 执行异常对象 + InvalidOperationException executionException = default; + + try + { + // 处理任务取消 + eventSource.CancellationToken.ThrowIfCancellationRequested(); + + // 调用执行前监视器 + if (Monitor != default) + { + await Monitor.OnExecutingAsync(eventHandlerExecutingContext).ConfigureAwait(false); + } + + // 判断是否自定义了执行器 + if (Executor == default) + { + // 判断是否自定义了重试失败回调服务 + var fallbackPolicyService = eventSubscribeAttribute?.FallbackPolicy == null + ? null + : _serviceProvider.GetService(eventSubscribeAttribute.FallbackPolicy) as IEventFallbackPolicy; + + // 调用事件处理程序并配置出错执行重试 + await Retry.InvokeAsync(async () => + { + await eventHandlerThatShouldRun.Handler!(eventHandlerExecutingContext).ConfigureAwait(false); + } + , eventSubscribeAttribute?.NumRetries ?? 0 + , eventSubscribeAttribute?.RetryTimeout ?? 1000 + , exceptionTypes: eventSubscribeAttribute?.ExceptionTypes + , fallbackPolicy: fallbackPolicyService == null ? null : async (ex) => await fallbackPolicyService.CallbackAsync(eventHandlerExecutingContext, ex).ConfigureAwait(false) + , retryAction: (total, times) => + { + // 输出重试日志 + _logger.LogWarning("Retrying {times}/{total} times for {EventId}", times, total, eventSource.EventId); + }).ConfigureAwait(false); + } + else + { + await Executor.ExecuteAsync(eventHandlerExecutingContext, eventHandlerThatShouldRun.Handler!).ConfigureAwait(false); + } + + // 触发事件处理程序事件 + _eventPublisher.InvokeEvents(new(eventSource, true)); + } + catch (Exception ex) + { + // 输出异常日志 + Log(LogLevel.Error, "Error occurred executing in {EventId}.", new[] { eventSource.EventId }, ex); + + // 标记异常 + executionException = new InvalidOperationException(string.Format("Error occurred executing in {0}.", eventSource.EventId), ex); + + // 捕获 Task 任务异常信息并统计所有异常 + if (UnobservedTaskException != default) + { + var args = new UnobservedTaskExceptionEventArgs( + ex as AggregateException ?? new AggregateException(ex)); + + UnobservedTaskException.Invoke(this, args); + } + + // 触发事件处理程序事件 + _eventPublisher.InvokeEvents(new(eventSource, false) + { + Exception = ex + }); + } + finally + { + // 调用执行后监视器 + if (Monitor != default) + { + // 创建执行后上下文 + var eventHandlerExecutedContext = new EventHandlerExecutedContext(eventSource, properties, eventHandlerThatShouldRun.HandlerMethod, eventSubscribeAttribute) + { + ExecutedTime = UseUtcTimestamp ? DateTime.UtcNow : DateTime.Now, + Exception = executionException + }; + + await Monitor.OnExecutedAsync(eventHandlerExecutedContext).ConfigureAwait(false); + } + + // 判断是否执行完成后调用 GC 回收 + var nowTime = DateTime.UtcNow; + if (eventHandlerThatShouldRun.GCCollect && (LastGCCollectTime == null || (nowTime - LastGCCollectTime.Value).TotalSeconds > GC_COLLECT_INTERVAL_SECONDS)) + { + LastGCCollectTime = nowTime; + GC.Collect(); + GC.WaitForPendingFinalizers(); + } + } + }, stoppingToken); + }); + } + + /// + /// 管理事件订阅器动态 + /// + /// + private void ManageEventSubscribers(EventSubscribeOperateSource subscribeOperateSource) + { + // 获取实际订阅事件 Id + var eventId = subscribeOperateSource.SubscribeEventId; + + // 确保事件订阅 Id 和传入的特性 EventId 一致 + if (subscribeOperateSource.Attribute != null && subscribeOperateSource.Attribute.EventId != eventId) throw new InvalidOperationException("Ensure that the is consistent with the attribute of the EventSubscribeAttribute object."); + + // 处理动态新增 + if (subscribeOperateSource.Operate == EventSubscribeOperates.Append) + { + var wrapper = new EventHandlerWrapper(eventId) + { + Attribute = subscribeOperateSource.Attribute, + HandlerMethod = subscribeOperateSource.HandlerMethod, + Handler = subscribeOperateSource.Handler, + Pattern = CheckIsSetFuzzyMatch(subscribeOperateSource.Attribute?.FuzzyMatch) ? new Regex(eventId, RegexOptions.Singleline) : default, + GCCollect = CheckIsSetGCCollect(subscribeOperateSource.Attribute?.GCCollect), + Order = subscribeOperateSource.Attribute?.Order ?? 0 + }; + + // 追加到集合中 + var succeeded = _eventHandlers.TryAdd(wrapper, wrapper); + + // 输出日志 + if (succeeded) + { + Log(LogLevel.Information, "Subscriber with event ID <{EventId}> was appended successfully.", new[] { eventId }); + } + } + // 处理动态删除 + else if (subscribeOperateSource.Operate == EventSubscribeOperates.Remove) + { + // 删除所有匹配事件 Id 的处理程序 + foreach (var wrapper in _eventHandlers.Keys) + { + if (wrapper.EventId != eventId) continue; + + var succeeded = _eventHandlers.TryRemove(wrapper, out _); + if (!succeeded) continue; + + // 输出日志 + Log(LogLevel.Warning, "Subscriber<{Name}> with event ID <{EventId}> was remove.", new[] { wrapper.HandlerMethod?.Name, eventId }); + } + } + } + + /// + /// 检查是否开启模糊匹配事件 Id 功能 + /// + /// + /// + private bool CheckIsSetFuzzyMatch(object fuzzyMatch) + { + return fuzzyMatch == null + ? FuzzyMatch + : Convert.ToBoolean(fuzzyMatch); + } + + /// + /// 检查是否开启执行完成触发 GC 回收 + /// + /// + /// + private bool CheckIsSetGCCollect(object gcCollect) + { + return gcCollect == null + ? GCCollect + : Convert.ToBoolean(gcCollect); + } + + /// + /// 记录日志 + /// + /// 日志级别 + /// 消息 + /// 参数 + /// 异常 + private void Log(LogLevel logLevel, string message, object[] args = default, Exception ex = default) + { + // 如果未启用日志记录则直接返回 + if (!LogEnabled) return; + + if (logLevel == LogLevel.Error) + { + _logger.LogError(ex, message, args); + } + else + { + _logger.Log(logLevel, message, args); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Internal/ChannelEventPublisher.cs b/src/Admin/ThingsGateway.Furion/EventBus/Internal/ChannelEventPublisher.cs new file mode 100644 index 000000000..aaee1f15d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Internal/ChannelEventPublisher.cs @@ -0,0 +1,130 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 基于内存通道事件发布者(默认实现) +/// +internal sealed partial class ChannelEventPublisher : IEventPublisher +{ + /// + /// 事件处理程序事件 + /// + public event EventHandler OnExecuted; + + /// + /// 事件源存储器 + /// + private readonly IEventSourceStorer _eventSourceStorer; + + /// + /// 构造函数 + /// + /// 事件源存储器 + public ChannelEventPublisher(IEventSourceStorer eventSourceStorer) + { + _eventSourceStorer = eventSourceStorer; + } + + /// + /// 发布一条消息 + /// + /// 事件源 + /// 实例 + public async Task PublishAsync(IEventSource eventSource) + { + await _eventSourceStorer.WriteAsync(eventSource, eventSource.CancellationToken).ConfigureAwait(false); + } + + /// + /// 延迟发布一条消息 + /// + /// 事件源 + /// 延迟数(毫秒) + /// 实例 + public Task PublishDelayAsync(IEventSource eventSource, long delay) + { + // 创建新线程 + Task.Factory.StartNew(async () => + { + // 延迟 delay 毫秒 + await Task.Delay(TimeSpan.FromMilliseconds(delay), eventSource.CancellationToken).ConfigureAwait(false); + + await _eventSourceStorer.WriteAsync(eventSource, eventSource.CancellationToken).ConfigureAwait(false); + }, eventSource.CancellationToken); + + return Task.CompletedTask; + } + + /// + /// 发布一条消息 + /// + /// 事件 Id + /// 事件承载(携带)数据 + /// 取消任务 Token + /// + public async Task PublishAsync(string eventId, object payload = default, CancellationToken cancellationToken = default) + { + await PublishAsync(new ChannelEventSource(eventId, payload, cancellationToken)).ConfigureAwait(false); + } + + /// + /// 发布一条消息 + /// + /// 事件 Id + /// 事件承载(携带)数据 + /// 取消任务 Token + /// + public async Task PublishAsync(Enum eventId, object payload = default, CancellationToken cancellationToken = default) + { + await PublishAsync(new ChannelEventSource(eventId, payload, cancellationToken)).ConfigureAwait(false); + } + + /// + /// 延迟发布一条消息 + /// + /// 事件 Id + /// 延迟数(毫秒) + /// 事件承载(携带)数据 + /// 取消任务 Token + /// 实例 + public async Task PublishDelayAsync(string eventId, long delay, object payload = default, CancellationToken cancellationToken = default) + { + await PublishDelayAsync(new ChannelEventSource(eventId, payload, cancellationToken), delay).ConfigureAwait(false); + } + + /// + /// 延迟发布一条消息 + /// + /// 事件 Id + /// 延迟数(毫秒) + /// 事件承载(携带)数据 + /// 取消任务 Token + /// 实例 + public async Task PublishDelayAsync(Enum eventId, long delay, object payload = default, CancellationToken cancellationToken = default) + { + await PublishDelayAsync(new ChannelEventSource(eventId, payload, cancellationToken), delay).ConfigureAwait(false); + } + + /// + /// 触发事件处理程序事件 + /// + /// 事件参数 + public void InvokeEvents(EventHandlerEventArgs args) + { + try + { + OnExecuted?.Invoke(this, args); + } + catch { } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Internal/Logging.cs b/src/Admin/ThingsGateway.Furion/EventBus/Internal/Logging.cs new file mode 100644 index 000000000..21363c1df --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Internal/Logging.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace System.Logging; + +/// +/// EventBusService 日志拓展默认分类名 +/// +internal sealed class EventBusService +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/MessageCenter.cs b/src/Admin/ThingsGateway.Furion/EventBus/MessageCenter.cs new file mode 100644 index 000000000..49cefc4cd --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/MessageCenter.cs @@ -0,0 +1,139 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.EventBus; + +/// +/// 全局事件总线静态类 +/// +[SuppressSniffer] +public static class MessageCenter +{ + /// + /// 发布一条消息 + /// + /// 事件源 + /// 实例 + public static Task PublishAsync(IEventSource eventSource) + { + return GetEventPublisher().PublishAsync(eventSource); + } + + /// + /// 延迟发布一条消息 + /// + /// 事件源 + /// 延迟数(毫秒) + /// 实例 + public static Task PublishDelayAsync(IEventSource eventSource, long delay) + { + return GetEventPublisher().PublishDelayAsync(eventSource, delay); + } + + /// + /// 发布一条消息 + /// + /// 事件 Id + /// 事件承载(携带)数据 + /// 取消任务 Token + /// + public static Task PublishAsync(string eventId, object payload = default, CancellationToken cancellationToken = default) + { + return GetEventPublisher().PublishAsync(eventId, payload, cancellationToken); + } + + /// + /// 发布一条消息 + /// + /// 事件 Id + /// 事件承载(携带)数据 + /// 取消任务 Token + /// + public static Task PublishAsync(Enum eventId, object payload = default, CancellationToken cancellationToken = default) + { + return GetEventPublisher().PublishAsync(eventId, payload, cancellationToken); + } + + /// + /// 延迟发布一条消息 + /// + /// 事件 Id + /// 延迟数(毫秒) + /// 事件承载(携带)数据 + /// 取消任务 Token + /// 实例 + public static Task PublishDelayAsync(string eventId, long delay, object payload = default, CancellationToken cancellationToken = default) + { + return GetEventPublisher().PublishDelayAsync(eventId, delay, payload, cancellationToken); + } + + /// + /// 延迟发布一条消息 + /// + /// 事件 Id + /// 延迟数(毫秒) + /// 事件承载(携带)数据 + /// 取消任务 Token + /// 实例 + public static Task PublishDelayAsync(Enum eventId, long delay, object payload = default, CancellationToken cancellationToken = default) + { + return GetEventPublisher().PublishDelayAsync(eventId, delay, payload, cancellationToken); + } + + /// + /// 添加事件订阅者 + /// + /// 事件 Id + /// 事件订阅委托 + /// 特性对象 + /// 对象 + /// 取消任务 Token + /// + public static Task Subscribe(string eventId, Func handler, EventSubscribeAttribute attribute = default, MethodInfo handlerMethod = default, CancellationToken cancellationToken = default) + { + return GetEventFactory().Subscribe(eventId + , handler + , attribute + , handlerMethod + , cancellationToken); + } + + /// + /// 删除事件订阅者 + /// + /// 事件 Id + /// 取消任务 Token + /// + public static Task Unsubscribe(string eventId, CancellationToken cancellationToken = default) + { + return GetEventFactory().Unsubscribe(eventId, cancellationToken); + } + + /// + /// 获取事件发布者 + /// + /// + private static IEventPublisher GetEventPublisher() + { + return App.GetService(App.RootServices); + } + + /// + /// 获取事件工厂 + /// + /// + private static IEventBusFactory GetEventFactory() + { + return App.GetService(App.RootServices); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Monitors/IEventHandlerMonitor.cs b/src/Admin/ThingsGateway.Furion/EventBus/Monitors/IEventHandlerMonitor.cs new file mode 100644 index 000000000..5b611718a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Monitors/IEventHandlerMonitor.cs @@ -0,0 +1,32 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 事件处理程序监视器 +/// +public interface IEventHandlerMonitor +{ + /// + /// 事件处理程序执行前 + /// + /// 上下文 + /// 实例 + Task OnExecutingAsync(EventHandlerExecutingContext context); + + /// + /// 事件处理程序执行后 + /// + /// 上下文 + /// 实例 + Task OnExecutedAsync(EventHandlerExecutedContext context); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Policies/IEventFallbackPolicy.cs b/src/Admin/ThingsGateway.Furion/EventBus/Policies/IEventFallbackPolicy.cs new file mode 100644 index 000000000..6e7fba9bb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Policies/IEventFallbackPolicy.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 事件重试失败回调服务 +/// +/// 需注册为单例 +public interface IEventFallbackPolicy +{ + /// + /// 重试失败回调 + /// + /// + /// + /// + Task CallbackAsync(EventHandlerExecutingContext context, Exception ex); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Sources/ChannelEventSource.cs b/src/Admin/ThingsGateway.Furion/EventBus/Sources/ChannelEventSource.cs new file mode 100644 index 000000000..e1ad7b1c3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Sources/ChannelEventSource.cs @@ -0,0 +1,118 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Extensitions.EventBus; + +namespace ThingsGateway.EventBus; + +/// +/// 内存通道事件源(事件承载对象) +/// +[SuppressSniffer] +public sealed class ChannelEventSource : IEventSource +{ + /// + /// 构造函数 + /// + public ChannelEventSource() + { + } + + /// + /// 构造函数 + /// + /// 事件 Id + public ChannelEventSource(string eventId) + { + EventId = eventId; + } + + /// + /// 构造函数 + /// + /// 事件 Id + /// 事件承载(携带)数据 + public ChannelEventSource(string eventId, object payload) + : this(eventId) + { + Payload = payload; + } + + /// + /// 构造函数 + /// + /// 事件 Id + /// 事件承载(携带)数据 + /// 取消任务 Token + public ChannelEventSource(string eventId, object payload, CancellationToken cancellationToken) + : this(eventId, payload) + { + CancellationToken = cancellationToken; + } + + /// + /// 构造函数 + /// + /// 事件 Id + public ChannelEventSource(Enum eventId) + : this(eventId.ParseToString()) + { + } + + /// + /// 构造函数 + /// + /// 事件 Id + /// 事件承载(携带)数据 + public ChannelEventSource(Enum eventId, object payload) + : this(eventId.ParseToString(), payload) + { + } + + /// + /// 构造函数 + /// + /// 事件 Id + /// 事件承载(携带)数据 + /// 取消任务 Token + public ChannelEventSource(Enum eventId, object payload, CancellationToken cancellationToken) + : this(eventId.ParseToString(), payload, cancellationToken) + { + } + + /// + /// 事件 Id + /// + public string EventId { get; set; } + + /// + /// 事件承载(携带)数据 + /// + public object Payload { get; set; } + + /// + /// 事件创建时间 + /// + public DateTime CreatedTime { get; set; } = DateTime.UtcNow; + + /// + /// 消息是否只消费一次 + /// + public bool IsConsumOnce { get; set; } + + /// + /// 取消任务 Token + /// + /// 用于取消本次消息处理 + [Newtonsoft.Json.JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] + public CancellationToken CancellationToken { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Sources/EventSubscribeOperateSource.cs b/src/Admin/ThingsGateway.Furion/EventBus/Sources/EventSubscribeOperateSource.cs new file mode 100644 index 000000000..febc0e8b0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Sources/EventSubscribeOperateSource.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.EventBus; + +/// +/// 事件总线订阅管理事件源 +/// +public sealed class EventSubscribeOperateSource : IEventSource +{ + /// + /// 事件 Id + /// + public string EventId { get; set; } + + /// + /// 事件承载(携带)数据 + /// + public object Payload { get; set; } + + /// + /// 事件创建时间 + /// + public DateTime CreatedTime { get; set; } = DateTime.UtcNow; + + /// + /// 消息是否只消费一次 + /// + public bool IsConsumOnce { get; set; } + + /// + /// 取消任务 Token + /// + /// 用于取消本次消息处理 + [Newtonsoft.Json.JsonIgnore] + [System.Text.Json.Serialization.JsonIgnore] + public CancellationToken CancellationToken { get; set; } + + /// + /// 事件处理程序 + /// + internal Func Handler { get; set; } + + /// + /// 订阅特性 + /// + internal EventSubscribeAttribute Attribute { get; set; } + + /// + /// 触发的方法 + /// + internal MethodInfo HandlerMethod { get; set; } + + /// + /// 实际事件 Id + /// + internal string SubscribeEventId { get; set; } + + /// + /// 事件订阅器操作选项 + /// + internal EventSubscribeOperates Operate { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Sources/IEventSource.cs b/src/Admin/ThingsGateway.Furion/EventBus/Sources/IEventSource.cs new file mode 100644 index 000000000..3e2336894 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Sources/IEventSource.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 事件源(事件承载对象)依赖接口 +/// +public interface IEventSource +{ + /// + /// 事件 Id + /// + string EventId { get; } + + /// + /// 事件承载(携带)数据 + /// + object Payload { get; } + + /// + /// 事件创建时间 + /// + DateTime CreatedTime { get; } + + /// + /// 取消任务 Token + /// + /// 用于取消本次消息处理 + CancellationToken CancellationToken { get; } + + /// + /// 消息是否只消费一次 + /// + bool IsConsumOnce { get; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Storers/ChannelEventSourceStorer.cs b/src/Admin/ThingsGateway.Furion/EventBus/Storers/ChannelEventSourceStorer.cs new file mode 100644 index 000000000..76be954b9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Storers/ChannelEventSourceStorer.cs @@ -0,0 +1,75 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Threading.Channels; + +namespace ThingsGateway.EventBus; + +/// +/// 内存通道事件源存储器(默认实现) +/// +/// +/// 顾名思义,这里指的是事件消息存储中心,提供读写能力 +/// 默认实现为内存中的 ,可自由更换存储介质,如 Kafka,SQL Server 等 +/// +internal sealed partial class ChannelEventSourceStorer : IEventSourceStorer +{ + /// + /// 内存通道事件源存储器 + /// + private readonly Channel _channel; + + /// + /// 构造函数 + /// + /// 管道最多能够处理多少消息,超过该容量进入等待写入 + public ChannelEventSourceStorer(int capacity) + { + // 配置通道,设置超出默认容量后进入等待 + var boundedChannelOptions = new BoundedChannelOptions(capacity) + { + FullMode = BoundedChannelFullMode.Wait + }; + + // 创建有限容量通道 + _channel = Channel.CreateBounded(boundedChannelOptions); + } + + /// + /// 将事件源写入存储器 + /// + /// 事件源对象 + /// 取消任务 Token + /// + public async ValueTask WriteAsync(IEventSource eventSource, CancellationToken cancellationToken) + { + // 空检查 + if (eventSource == default) + { + throw new ArgumentNullException(nameof(eventSource)); + } + + // 写入存储器 + await _channel.Writer.WriteAsync(eventSource, cancellationToken).ConfigureAwait(false); + } + + /// + /// 从存储器中读取一条事件源 + /// + /// 取消任务 Token + /// 事件源对象 + public async ValueTask ReadAsync(CancellationToken cancellationToken) + { + // 读取一条事件源 + var eventSource = await _channel.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + return eventSource; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Storers/IEventSourceStorer.cs b/src/Admin/ThingsGateway.Furion/EventBus/Storers/IEventSourceStorer.cs new file mode 100644 index 000000000..e21864c9a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Storers/IEventSourceStorer.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.EventBus; + +/// +/// 事件源存储器 +/// +/// +/// 顾名思义,这里指的是事件消息存储中心,提供读写能力 +/// 默认实现为内存中的 ,可自由更换存储介质,如 Kafka,SQL Server 等 +/// +public interface IEventSourceStorer +{ + /// + /// 将事件源写入存储器 + /// + /// 事件源对象 + /// 取消任务 Token + /// + ValueTask WriteAsync(IEventSource eventSource, CancellationToken cancellationToken); + + /// + /// 从存储器中读取一条事件源 + /// + /// 取消任务 Token + /// 事件源对象 + ValueTask ReadAsync(CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/EventBus/Wrappers/EventHandlerWrapper.cs b/src/Admin/ThingsGateway.Furion/EventBus/Wrappers/EventHandlerWrapper.cs new file mode 100644 index 000000000..a7740905d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/EventBus/Wrappers/EventHandlerWrapper.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; +using System.Text.RegularExpressions; + +namespace ThingsGateway.EventBus; + +/// +/// 事件处理程序包装类 +/// +/// 主要用于主机服务启动时将所有处理程序和事件 Id 进行包装绑定 +internal sealed class EventHandlerWrapper +{ + /// + /// 构造函数 + /// + /// 事件Id + internal EventHandlerWrapper(string eventId) + { + EventId = eventId; + } + + /// + /// 事件 Id + /// + internal string EventId { get; set; } + + /// + /// 事件处理程序 + /// + internal Func Handler { get; set; } + + /// + /// 触发的方法 + /// + internal MethodInfo HandlerMethod { get; set; } + + /// + /// 订阅特性 + /// + internal EventSubscribeAttribute Attribute { get; set; } + + /// + /// 正则表达式 + /// + internal Regex Pattern { get; set; } + + /// + /// 是否启用执行完成触发 GC 回收 + /// + public bool GCCollect { get; set; } + + /// + /// 排序 + /// + /// 数值越大的先执行 + public int Order { get; set; } = 0; + + /// + /// 是否符合条件执行处理程序 + /// + /// 支持正则表达式 + /// 事件 Id + /// + internal bool ShouldRun(string eventId) + { + return EventId == eventId || (Pattern?.IsMatch(eventId) ?? false); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Assets/error.html b/src/Admin/ThingsGateway.Furion/FriendlyException/Assets/error.html new file mode 100644 index 000000000..69993da4f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Assets/error.html @@ -0,0 +1,164 @@ + + + + + + + + @{Title} + + + +
+
+
+
+

@{Title}

+

@{Description}

+
+ See more details. +
+@{Code}
+
+
HTTP ERROR @{StatusCode}
+
+
+
+ + + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Attributes/ErrorCodeItemMetadataAttribute.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Attributes/ErrorCodeItemMetadataAttribute.cs new file mode 100644 index 000000000..6c1681f7e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Attributes/ErrorCodeItemMetadataAttribute.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.FriendlyException; + +/// +/// 异常元数据特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Field)] +public sealed class ErrorCodeItemMetadataAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// 错误消息 + /// 格式化参数 + public ErrorCodeItemMetadataAttribute(string errorMessage, params object[] args) + { + ErrorMessage = errorMessage; + Args = args; + } + + /// + /// 错误消息 + /// + public string ErrorMessage { get; set; } + + /// + /// 错误码 + /// + public object ErrorCode { get; set; } + + /// + /// 格式化参数 + /// + public object[] Args { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Attributes/ErrorCodeTypeAttribute.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Attributes/ErrorCodeTypeAttribute.cs new file mode 100644 index 000000000..09245b25e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Attributes/ErrorCodeTypeAttribute.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.FriendlyException; + +/// +/// 错误代码类型特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Enum)] +public sealed class ErrorCodeTypeAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Exceptions/AppFriendlyException.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Exceptions/AppFriendlyException.cs new file mode 100644 index 000000000..1a125058b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Exceptions/AppFriendlyException.cs @@ -0,0 +1,81 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; + +namespace ThingsGateway.FriendlyException; + +/// +/// 自定义友好异常类 +/// +[SuppressSniffer] +public class AppFriendlyException : Exception +{ + /// + /// 构造函数 + /// + public AppFriendlyException() : base() + { + } + + /// + /// 构造函数 + /// + /// + /// + public AppFriendlyException(string message, object errorCode) : base(message) + { + ErrorMessage = message; + ErrorCode = OriginErrorCode = errorCode; + } + + /// + /// 构造函数 + /// + /// + /// + /// + public AppFriendlyException(string message, object errorCode, Exception innerException) : base(message, innerException) + { + ErrorMessage = message; + ErrorCode = OriginErrorCode = errorCode; + } + + /// + /// 错误码 + /// + public object ErrorCode { get; set; } + + /// + /// 错误码(没被复写过的 ErrorCode ) + /// + public object OriginErrorCode { get; set; } + + /// + /// 错误消息(支持 Object 对象) + /// + public object ErrorMessage { get; set; } + + /// + /// 状态码 + /// + public int StatusCode { get; set; } = StatusCodes.Status500InternalServerError; + + /// + /// 是否是数据验证异常 + /// + public bool ValidationException { get; set; } = false; + + /// + /// 额外数据 + /// + public new object Data { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Extensions/AppFriendlyExceptionExtensions.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Extensions/AppFriendlyExceptionExtensions.cs new file mode 100644 index 000000000..bf72f9245 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Extensions/AppFriendlyExceptionExtensions.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; + +namespace ThingsGateway.FriendlyException; + +/// +/// 异常拓展 +/// +[SuppressSniffer] +public static class AppFriendlyExceptionExtensions +{ + /// + /// 设置异常状态码 + /// + /// + /// + /// + public static AppFriendlyException StatusCode(this AppFriendlyException exception, int statusCode = StatusCodes.Status500InternalServerError) + { + exception.StatusCode = statusCode; + return exception; + } + + /// + /// 设置额外数据 + /// + /// + /// + /// + public static AppFriendlyException WithData(this AppFriendlyException exception, object data) + { + exception.Data = data; + return exception; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Extensions/FriendlyExceptionServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Extensions/FriendlyExceptionServiceCollectionExtensions.cs new file mode 100644 index 000000000..a25695f76 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Extensions/FriendlyExceptionServiceCollectionExtensions.cs @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +using ThingsGateway.FriendlyException; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 友好异常服务拓展类 +/// +[SuppressSniffer] +public static class FriendlyExceptionServiceCollectionExtensions +{ + /// + /// 添加友好异常服务拓展服务 + /// + /// 异常错误码提供器 + /// Mvc构建器 + /// 是否启用全局异常过滤器 + /// + public static IMvcBuilder AddFriendlyException(this IMvcBuilder mvcBuilder, Action configure = null) + where TErrorCodeTypeProvider : class, IErrorCodeTypeProvider + { + mvcBuilder.Services.AddFriendlyException(configure); + + return mvcBuilder; + } + + /// + /// 添加友好异常服务拓展服务 + /// + /// 异常错误码提供器 + /// + /// + /// + public static IServiceCollection AddFriendlyException(this IServiceCollection services, Action configure = null) + where TErrorCodeTypeProvider : class, IErrorCodeTypeProvider + { + // 添加全局异常过滤器 + services.AddFriendlyException(configure); + + // 单例注册异常状态码提供器 + services.TryAddSingleton(); + + return services; + } + + /// + /// 添加友好异常服务拓展服务 + /// + /// Mvc构建器 + /// + /// + public static IMvcBuilder AddFriendlyException(this IMvcBuilder mvcBuilder, Action configure = null) + { + mvcBuilder.Services.AddFriendlyException(configure); + + return mvcBuilder; + } + + /// + /// 添加友好异常服务拓展服务 + /// + /// + /// + /// + public static IServiceCollection AddFriendlyException(this IServiceCollection services, Action configure = null) + { + // 解决服务重复注册问题 + if (services.Any(u => u.ServiceType == typeof(IConfigureOptions))) + { + return services; + } + + // 添加友好异常配置文件支持 + services.AddConfigurableOptions(); + + // 添加异常配置文件支持 + services.AddConfigurableOptions(); + + // 载入服务配置选项 + var configureOptions = new FriendlyExceptionOptions(); + configure?.Invoke(configureOptions); + + // 添加全局异常过滤器 + if (configureOptions.GlobalEnabled) + services.AddMvcFilter(); + + return services; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Extensions/Options/FriendlyExceptionOptions.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Extensions/Options/FriendlyExceptionOptions.cs new file mode 100644 index 000000000..8483b5757 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Extensions/Options/FriendlyExceptionOptions.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.FriendlyException; + +/// +/// AddInject 友好异常配置选项 +/// +public sealed class FriendlyExceptionOptions +{ + /// + /// 是否启用全局友好异常 + /// + public bool GlobalEnabled { get; set; } = true; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Filters/FriendlyExceptionFilter.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Filters/FriendlyExceptionFilter.cs new file mode 100644 index 000000000..920f29f44 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Filters/FriendlyExceptionFilter.cs @@ -0,0 +1,189 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using System.Diagnostics; +using System.Logging; + +using ThingsGateway; +using ThingsGateway.DataValidation; +using ThingsGateway.DynamicApiController; +using ThingsGateway.FriendlyException; +using ThingsGateway.UnifyResult; + +namespace Microsoft.AspNetCore.Mvc.Filters; + +/// +/// 友好异常拦截器 +/// +[SuppressSniffer] +public sealed class FriendlyExceptionFilter : IAsyncExceptionFilter +{ + /// + /// 异常拦截 + /// + /// + /// + public async Task OnExceptionAsync(ExceptionContext context) + { + // 判断是否是验证异常 + var isValidationException = context.Exception is AppFriendlyException friendlyException && friendlyException.ValidationException; + + // 只有不是验证异常才处理 + if (!isValidationException) + { + // 解析异常处理服务,实现自定义异常额外操作,如记录日志等 + var globalExceptionHandler = context.HttpContext.RequestServices.GetService(); + if (globalExceptionHandler != null) + { + await globalExceptionHandler.OnExceptionAsync(context).ConfigureAwait(false); + } + } + + // 排除 WebSocket 请求处理 + if (context.HttpContext.IsWebSocketRequest()) return; + + // 如果异常在其他地方被标记了处理,那么这里不再处理 + if (context.ExceptionHandled) return; + + // 解析异常信息 + var exceptionMetadata = UnifyContext.GetExceptionMetadata(context); + + // 判断是否是 Razor Pages + var isPageDescriptor = context.ActionDescriptor is CompiledPageActionDescriptor; + + // 判断是否是验证异常,如果是,则不处理 + if (isValidationException) + { + var resultHttpContext = context.HttpContext.Items[nameof(DataValidationFilter) + nameof(AppFriendlyException)]; + // 读取验证执行结果 + if (resultHttpContext != null) + { + var result = isPageDescriptor + ? (resultHttpContext as PageHandlerExecutedContext).Result + : (resultHttpContext as ActionExecutedContext).Result; + + // 直接将验证结果设置为异常结果 + context.Result = result ?? new BadPageResult(StatusCodes.Status400BadRequest) + { + Code = ValidatorContext.GetValidationMetadata((context.Exception as AppFriendlyException).ErrorMessage).Message + }; + + // 标记验证异常已被处理 + context.ExceptionHandled = true; + return; + } + } + + // 处理 Razor Pages + if (isPageDescriptor) + { + // 返回自定义错误页面 + context.Result = new BadPageResult(isValidationException ? StatusCodes.Status400BadRequest : exceptionMetadata.StatusCode) + { + Title = isValidationException ? "ModelState Invalid" : ("Internal Server: " + exceptionMetadata.Errors.ToString()), + Code = isValidationException + ? ValidatorContext.GetValidationMetadata((context.Exception as AppFriendlyException).ErrorMessage).Message + : context.Exception.ToString() + }; + } + // Mvc/WebApi + else + { + // 获取控制器信息 + var actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; + + // 判断是否跳过规范化结果,如果是,则只处理为友好异常消息 + if (UnifyContext.CheckFailedNonUnify(actionDescriptor.MethodInfo, out var unifyResult)) + { + // WebAPI 情况 + if (Penetrates.IsApiController(actionDescriptor.MethodInfo.DeclaringType)) + { + // 返回 JsonResult + context.Result = new JsonResult(exceptionMetadata.Errors) + { + StatusCode = exceptionMetadata.StatusCode, + }; + } + else + { + // 返回自定义错误页面 + context.Result = new BadPageResult(exceptionMetadata.StatusCode) + { + Title = "Internal Server: " + exceptionMetadata.Errors.ToString(), + Code = context.Exception.ToString() + }; + } + } + else + { + // 判断是否支持 MVC 规范化处理 + if (!UnifyContext.CheckSupportMvcController(context.HttpContext, actionDescriptor, out _) + || UnifyContext.CheckHttpContextNonUnify(context.HttpContext)) return; + + // 执行规范化异常处理 + context.Result = unifyResult.OnException(context, exceptionMetadata); + } + } + + // 读取异常配置 + var friendlyExceptionSettings = context.HttpContext.RequestServices.GetRequiredService>(); + + // 判断是否启用异常日志输出 + if (friendlyExceptionSettings.Value.LogError == true) + { + // 创建日志记录器 + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + + // 记录拦截日常 + logger.LogError(context.Exception, context.Exception.Message); + } + + // 打印错误消息 + PrintToMiniProfiler(context.Exception); + } + + /// + /// 打印错误到 MiniProfiler 中 + /// + /// + internal static void PrintToMiniProfiler(Exception exception) + { + // 判断是否注入 MiniProfiler 组件 + if (App.Settings.InjectMiniProfiler != true || exception == null) return; + + // 获取异常堆栈 + var stackTrace = new StackTrace(exception, true); + if (stackTrace.FrameCount == 0) return; + var traceFrame = stackTrace.GetFrame(0); + + // 获取出错的文件名 + var exceptionFileName = traceFrame.GetFileName(); + + // 获取出错的行号 + var exceptionFileLineNumber = traceFrame.GetFileLineNumber(); + + // 打印错误文件名和行号 + if (!string.IsNullOrWhiteSpace(exceptionFileName) && exceptionFileLineNumber > 0) + { + App.PrintToMiniProfiler("errors", "Locator", $"{exceptionFileName}:line {exceptionFileLineNumber}", true); + } + + // 打印完整的堆栈信息 + App.PrintToMiniProfiler("errors", "StackTrace", exception.ToString(), true); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Handlers/IGlobalExceptionHandler.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Handlers/IGlobalExceptionHandler.cs new file mode 100644 index 000000000..6782ba349 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Handlers/IGlobalExceptionHandler.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.Filters; + +namespace ThingsGateway.FriendlyException; + +/// +/// 全局异常处理 +/// +public interface IGlobalExceptionHandler +{ + /// + /// 异常拦截 + /// + /// + /// + Task OnExceptionAsync(ExceptionContext context); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Internal/ExceptionMetadata.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Internal/ExceptionMetadata.cs new file mode 100644 index 000000000..7a763d526 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Internal/ExceptionMetadata.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.FriendlyException; + +/// +/// 异常元数据 +/// +[SuppressSniffer] +public sealed class ExceptionMetadata +{ + /// + /// 状态码 + /// + public int StatusCode { get; internal set; } + + /// + /// 错误码 + /// + public object ErrorCode { get; internal set; } + + /// + /// 错误码(没被复写过的 ErrorCode ) + /// + public object OriginErrorCode { get; internal set; } + + /// + /// 错误对象(信息) + /// + public object Errors { get; internal set; } + + /// + /// 额外数据 + /// + public object Data { get; internal set; } + + /// + /// 异常对象 + /// + public Exception Exception { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Internal/Logging.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Internal/Logging.cs new file mode 100644 index 000000000..f2656d4ae --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Internal/Logging.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace System.Logging; + +/// +/// FriendlyException 日志拓展默认分类名 +/// +internal sealed class FriendlyException +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Internal/MethodIfException.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Internal/MethodIfException.cs new file mode 100644 index 000000000..05c7a0349 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Internal/MethodIfException.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.FriendlyException; + +/// +/// 方法异常类 +/// +internal sealed class MethodIfException +{ + /// + /// 出异常的方法 + /// + public MethodBase ErrorMethod { get; set; } + +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Oops.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Oops.cs new file mode 100644 index 000000000..7d12a4693 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Oops.cs @@ -0,0 +1,381 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System.Collections.Concurrent; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics; +using System.Reflection; + +using ThingsGateway.Extensions; +using ThingsGateway.Templates.Extensions; + +namespace ThingsGateway.FriendlyException; + +/// +/// 抛异常静态类 +/// +[SuppressSniffer] +public static class Oops +{ + /// + /// 方法错误异常特性 + /// + private static readonly ConcurrentDictionary _errorMethods; + + /// + /// 错误代码类型 + /// + private static readonly IEnumerable _errorCodeTypes; + + /// + /// 错误消息字典 + /// + private static readonly ConcurrentDictionary _errorCodeMessages; + + /// + /// 友好异常设置 + /// + private static readonly FriendlyExceptionSettingsOptions _friendlyExceptionSettings; + + /// + /// 构造函数 + /// + static Oops() + { + _errorMethods = new ConcurrentDictionary(); + _friendlyExceptionSettings = App.GetConfig("FriendlyExceptionSettings", true); + _errorCodeTypes = GetErrorCodeTypes(); + _errorCodeMessages = GetErrorCodeMessages(); + } + + /// + /// 抛出业务异常信息 + /// + /// 异常消息 + /// String.Format 参数 + /// 异常实例 + public static AppFriendlyException Bah(string errorMessage, params object[] args) + { + var friendlyException = Oh(errorMessage, typeof(ValidationException), args).StatusCode(StatusCodes.Status400BadRequest); + friendlyException.ValidationException = true; + return friendlyException; + } + + /// + /// 抛出业务异常信息 + /// + /// 错误码 + /// String.Format 参数 + /// 异常实例 + public static AppFriendlyException Bah(object errorCode, params object[] args) + { + var friendlyException = Oh(errorCode, typeof(ValidationException), args).StatusCode(StatusCodes.Status400BadRequest); + friendlyException.ValidationException = true; + return friendlyException; + } + + /// + /// 抛出字符串异常 + /// + /// 异常消息 + /// String.Format 参数 + /// 异常实例 + public static AppFriendlyException Oh(string errorMessage, params object[] args) + { + var friendlyException = new AppFriendlyException(MontageErrorMessage(errorMessage, default, null, args), default); + + // 处理默认配置为业务异常问题 + if (_friendlyExceptionSettings.ThrowBah == true) + { + friendlyException.StatusCode(StatusCodes.Status400BadRequest); + friendlyException.ValidationException = true; + } + return friendlyException; + } + + /// + /// 抛出字符串异常 + /// + /// 异常消息 + /// 具体异常类型 + /// String.Format 参数 + /// 异常实例 + public static AppFriendlyException Oh(string errorMessage, Type exceptionType, params object[] args) + { + var exceptionMessage = MontageErrorMessage(errorMessage, default, null, args); + return new AppFriendlyException(exceptionMessage, default, + Activator.CreateInstance(exceptionType, new object[] { exceptionMessage }) as Exception); + } + + /// + /// 抛出字符串异常 + /// + /// 具体异常类型 + /// 异常消息 + /// String.Format 参数 + /// 异常实例 + public static AppFriendlyException Oh(string errorMessage, params object[] args) + where TException : class + { + return Oh(errorMessage, typeof(TException), args); + } + + /// + /// 抛出错误码异常 + /// + /// 错误码 + /// String.Format 参数 + /// 异常实例 + public static AppFriendlyException Oh(object errorCode, params object[] args) + { + var (ErrorCode, Message) = GetErrorCodeMessage(errorCode, null, args); + var friendlyException = new AppFriendlyException(Message, errorCode) { ErrorCode = ErrorCode }; + + // 处理默认配置为业务异常问题 + if (_friendlyExceptionSettings.ThrowBah == true) + { + friendlyException.StatusCode(StatusCodes.Status400BadRequest); + friendlyException.ValidationException = true; + } + return friendlyException; + } + + /// + /// 抛出错误码异常 + /// + /// 错误码 + /// 具体异常类型 + /// String.Format 参数 + /// 异常实例 + public static AppFriendlyException Oh(object errorCode, Type exceptionType, params object[] args) + { + var (ErrorCode, Message) = GetErrorCodeMessage(errorCode, null, args); + return new AppFriendlyException(Message, errorCode, + Activator.CreateInstance(exceptionType, new object[] { Message }) as Exception) + { ErrorCode = ErrorCode }; + } + + /// + /// 抛出错误码异常 + /// + /// 具体异常类型 + /// 错误码 + /// String.Format 参数 + /// 异常实例 + public static AppFriendlyException Oh(object errorCode, params object[] args) + where TException : class + { + return Oh(errorCode, typeof(TException), args); + } + + /// + /// 获取错误码错误消息 + /// + /// + /// + /// + /// + public static string Text(object errorCode, bool? hideErrorCode = null, params object[] args) + { + var (_, Message) = GetErrorCodeMessage(errorCode, hideErrorCode, args); + return Message; + } + + /// + /// 获取错误码消息 + /// + /// + /// + /// + /// + private static (object ErrorCode, string Message) GetErrorCodeMessage(object errorCode, bool? hideErrorCode, params object[] args) + { + errorCode = HandleEnumErrorCode(errorCode); + + // 获取出错的方法 + var methodIfException = GetEndPointExceptionMethod(); + + + // 获取错误码消息 + var errorCodeMessage = _errorCodeMessages.GetValueOrDefault(errorCode.ToString()) ?? _friendlyExceptionSettings.DefaultErrorMessage; + + + // 字符串格式化 + return (errorCode, MontageErrorMessage(errorCodeMessage, errorCode.ToString(), hideErrorCode + , args)); + } + + /// + /// 处理枚举类型错误码 + /// + /// 错误码 + /// + private static object HandleEnumErrorCode(object errorCode) + { + // 获取类型 + var errorType = errorCode.GetType(); + + // 判断是否是内置枚举类型,如果是解析特性 + if (_errorCodeTypes.Any(u => u == errorType)) + { + var fieldinfo = errorType.GetField(Enum.GetName(errorType, errorCode)); + if (fieldinfo.IsDefined(typeof(ErrorCodeItemMetadataAttribute), true)) + { + errorCode = GetErrorCodeItemInformation(fieldinfo).Key; + } + } + + return errorCode; + } + + /// + /// 获取错误代码类型 + /// + /// + private static IEnumerable GetErrorCodeTypes() + { + // 查找所有公开的枚举贴有 [ErrorCodeType] 特性的类型 + var errorCodeTypes = App.EffectiveTypes + .Where(u => u.IsDefined(typeof(ErrorCodeTypeAttribute), true) && u.IsEnum); + + // 获取错误代码提供器中定义的类型 + var errorCodeTypeProvider = App.GetService(App.RootServices); + if (errorCodeTypeProvider is { Definitions: not null }) errorCodeTypes = errorCodeTypes.Concat(errorCodeTypeProvider.Definitions); + + return errorCodeTypes.Distinct(); + } + + /// + /// 获取所有错误消息 + /// + /// + private static ConcurrentDictionary GetErrorCodeMessages() + { + var defaultErrorCodeMessages = new ConcurrentDictionary(); + + // 查找所有 [ErrorCodeType] 类型中的 [ErrorCodeMetadata] 元数据定义 + var errorCodeMessages = _errorCodeTypes.SelectMany(u => u.GetFields().Where(u => u.IsDefined(typeof(ErrorCodeItemMetadataAttribute)))) + .Select(u => GetErrorCodeItemInformation(u)) + .ToDictionary(u => u.Key.ToString(), u => u.Value); + + defaultErrorCodeMessages.AddOrUpdate(errorCodeMessages); + + // 加载配置文件状态码 + var errorCodeMessageSettings = App.GetConfig("ErrorCodeMessageSettings", true); + if (errorCodeMessageSettings is { Definitions: not null }) + { + // 获取所有参数大于1的配置 + var fitErrorCodes = errorCodeMessageSettings.Definitions + .Where(u => u.Length > 1) + .ToDictionary(u => u[0].ToString(), u => FixErrorCodeSettingMessage(u)); + + defaultErrorCodeMessages.AddOrUpdate(fitErrorCodes); + } + + return defaultErrorCodeMessages; + } + + /// + /// 处理异常配置数据 + /// + /// 错误消息配置对象 + /// + /// 方式:数组第一个元素为错误码,第二个参数为错误消息,剩下的参数为错误码格式化字符串 + /// + /// + private static string FixErrorCodeSettingMessage(object[] errorCodes) + { + var args = errorCodes.Skip(2).ToArray(); + var errorMessage = errorCodes[1].ToString(); + return errorMessage.Format(args); + } + + /// + /// 获取堆栈中顶部抛异常方法 + /// + /// + private static MethodIfException GetEndPointExceptionMethod() + { + try + { + // 获取调用堆栈信息 + var stackTrace = EnhancedStackTrace.Current(); + + // 获取出错的堆栈信息,在 web 请求中获取控制器或动态API的方法,除外获取第一个出错的方法 + var stackFrame = stackTrace.FirstOrDefault(u => + typeof(ControllerBase).IsAssignableFrom(u.MethodInfo.DeclaringType) + //|| typeof(IDynamicApiController).IsAssignableFrom(u.MethodInfo.DeclaringType) + ) + ?? stackTrace.FirstOrDefault(u => u.GetMethod().DeclaringType.Namespace != typeof(Oops).Namespace); + + // 获取出错的方法 + var errorMethod = stackFrame.MethodInfo.MethodBase; + + // 判断是否已经缓存过该方法,避免重复解析 + var isCached = _errorMethods.TryGetValue(errorMethod, out var methodIfException); + if (isCached) return methodIfException; + + + // 组装方法异常对象 + methodIfException = new MethodIfException + { + ErrorMethod = errorMethod, + }; + + // 存入缓存 + _errorMethods.TryAdd(errorMethod, methodIfException); + + return methodIfException; + } + catch + { + return null; + } + } + + /// + /// 获取错误代码信息 + /// + /// 字段对象 + /// (object key, object value) + private static (object Key, string Value) GetErrorCodeItemInformation(FieldInfo fieldInfo) + { + var errorCodeItemMetadata = fieldInfo.GetCustomAttribute(); + return (errorCodeItemMetadata.ErrorCode ?? fieldInfo.Name, errorCodeItemMetadata.ErrorMessage.Format(errorCodeItemMetadata.Args)); + } + + /// + /// 获取错误码字符串 + /// + /// + /// + /// 隐藏错误码 + /// + /// + private static string MontageErrorMessage(string errorMessage, string errorCode, bool? hideErrorCode, params object[] args) + { + // 支持读取配置渲染 + var realErrorMessage = errorMessage.Render(); + + // 多语言处理 + realErrorMessage = App.StringLocalizerFactory == null ? realErrorMessage : App.CreateLocalizerByType(typeof(Oops))[realErrorMessage]; + + // 判断是否隐藏错误码 + var msg = (hideErrorCode == true || _friendlyExceptionSettings.HideErrorCode == true || string.IsNullOrWhiteSpace(errorCode) + ? string.Empty + : $"[{errorCode}] ") + realErrorMessage; + + return msg.Format(args); + } +} diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Options/ErrorCodeMessageSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Options/ErrorCodeMessageSettingsOptions.cs new file mode 100644 index 000000000..6c977ead0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Options/ErrorCodeMessageSettingsOptions.cs @@ -0,0 +1,25 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.FriendlyException; + +/// +/// 异常配置选项,最优的方式是采用后期配置,也就是所有异常状态码先不设置(推荐) +/// +public sealed class ErrorCodeMessageSettingsOptions : IConfigurableOptions +{ + /// + /// 异常状态码配置列表 + /// + public object[][] Definitions { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Options/FriendlyExceptionSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Options/FriendlyExceptionSettingsOptions.cs new file mode 100644 index 000000000..62861effb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Options/FriendlyExceptionSettingsOptions.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.FriendlyException; + +/// +/// 友好异常配置选项 +/// +public sealed class FriendlyExceptionSettingsOptions : IConfigurableOptions +{ + /// + /// 隐藏错误码 + /// + public bool? HideErrorCode { get; set; } + + /// + /// 默认错误码 + /// + public string DefaultErrorCode { get; set; } + + /// + /// 默认错误消息 + /// + public string DefaultErrorMessage { get; set; } + + /// + /// 标记 Oops.Oh 为业务异常 + /// + /// 也就是不会进入异常处理 + public bool? ThrowBah { get; set; } + + /// + /// 是否输出异常日志 + /// + public bool? LogError { get; set; } + + /// + /// 选项后期配置 + /// + /// + /// + public void PostConfigure(FriendlyExceptionSettingsOptions options, IConfiguration configuration) + { + options.HideErrorCode ??= false; + options.DefaultErrorCode ??= string.Empty; + //options.DefaultErrorMessage ??= "Internal Server Error"; + options.ThrowBah ??= false; + options.LogError ??= true; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Providers/IErrorCodeTypeProvider.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Providers/IErrorCodeTypeProvider.cs new file mode 100644 index 000000000..eb4798f5f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Providers/IErrorCodeTypeProvider.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.FriendlyException; + +/// +/// 异常错误代码提供器 +/// +public interface IErrorCodeTypeProvider +{ + /// + /// 错误代码定义类型 + /// + Type[] Definitions { get; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Results/BadPageResult.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Results/BadPageResult.cs new file mode 100644 index 000000000..1a4dcbf16 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Results/BadPageResult.cs @@ -0,0 +1,169 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using System.Text; + +using ThingsGateway.Reflection; + +namespace ThingsGateway.FriendlyException; + +/// +/// 错误页面 +/// +public class BadPageResult : StatusCodeResult +{ + /// + /// 构造函数 + /// + public BadPageResult() + : base(400) + { + } + + /// + /// 构造函数 + /// + /// 状态码 + public BadPageResult(int statusCode) + : base(statusCode) + { + } + + /// + /// 标题 + /// + public string Title { get; set; } = "ModelState Invalid"; + + /// + /// 描述 + /// + public string Description { get; set; } = "User data verification failed. Please input it correctly."; + + /// + /// 图标 + /// + /// 必须是 base64 类型 + public string Base64Icon { get; set; } = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTE0LjIxIDEzLjVsMS43NjcgMS43NzMtLjcwNC43MDRMMTMuNSAxNC4yMWwtMS43NzMgMS43NzMtLjcwNC0uNzEgMS43NzQtMS43NzQtMS43NzQtMS43NzMuNzA0LS43MDQgMS43NzMgMS43NzQgMS43NzMtMS43NzQuNzA0LjcxMUwxNC4yMSAxMy41ek0yIDE1aDh2MUgxVjBoOC43MUwxNCA0LjI5VjEwaC0xVjVIOVYxSDJ2MTR6bTgtMTFoMi4yOUwxMCAxLjcxVjR6IiBmaWxsPSIjMTAxMDEwIi8+PC9zdmc+"; + + /// + /// 错误代码 + /// + public string Code { get; set; } = ""; + + /// + /// 错误代码语言 + /// + public string CodeLang { get; set; } = "json"; + + /// + /// 返回通用 401 错误页 + /// + public static BadPageResult Status401Unauthorized => new(StatusCodes.Status401Unauthorized) + { + Title = "401 Unauthorized", + Code = "401 Unauthorized", + Description = "", + CodeLang = "txt" + }; + + /// + /// 返回通用 403 错误页 + /// + public static BadPageResult Status403Forbidden => new(StatusCodes.Status403Forbidden) + { + Title = "403 Forbidden", + Code = "403 Forbidden", + Description = "", + CodeLang = "txt" + }; + + /// + /// 返回通用 404 错误页 + /// + public static BadPageResult Status404NotFound => new(StatusCodes.Status404NotFound) + { + Title = "404 Not Found", + Code = "404 Not Found", + Description = "", + CodeLang = "txt" + }; + + /// + /// 返回通用 500 错误页 + /// + public static BadPageResult Status500InternalServerError => new(StatusCodes.Status500InternalServerError) + { + Title = "500 Internal Server Error", + Code = "500 Internal Server Error", + Description = "", + CodeLang = "txt" + }; + + /// + /// 重写返回结果 + /// + /// + public override void ExecuteResult(ActionContext context) + { + var httpContext = context.HttpContext; + + // 如果 Response 已经完成输出或 WebSocket 请求,则禁止写入 + if (httpContext.IsWebSocketRequest() || httpContext.Response.HasStarted) return; + + base.ExecuteResult(context); + httpContext.Response.Body.Write(ToByteArray()); + } + + /// + /// 将 转换成字符串 + /// + /// + public override string ToString() + { + // 获取当前类型信息 + var thisType = typeof(BadPageResult); + var thisAssembly = thisType.Assembly; + + // 读取嵌入式页面路径 + var errorhtml = $"{Reflect.GetAssemblyName(thisAssembly)}{thisType.Namespace.Replace(nameof(ThingsGateway), string.Empty)}.Assets.error.html"; + + // 解析嵌入式文件流 + byte[] buffer; + using (var readStream = thisAssembly.GetManifestResourceStream(errorhtml)) + { + buffer = new byte[readStream.Length]; + _ = readStream.Read(buffer, 0, buffer.Length); + } + + // 读取内容并替换 + var content = Encoding.UTF8.GetString(buffer); + content = content.Replace($"@{{{nameof(Title)}}}", Title) + .Replace($"@{{{nameof(Description)}}}", Description) + .Replace($"@{{{nameof(StatusCode)}}}", StatusCode.ToString()) + .Replace($"@{{{nameof(Code)}}}", Code) + .Replace($"@{{{nameof(CodeLang)}}}", CodeLang) + .Replace($"@{{{nameof(Base64Icon)}}}", Base64Icon); + + return content; + } + + /// + /// 将 转换成字节数组 + /// + /// + public byte[] ToByteArray() + { + return Encoding.UTF8.GetBytes(ToString()); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/FriendlyException/Retry.cs b/src/Admin/ThingsGateway.Furion/FriendlyException/Retry.cs new file mode 100644 index 000000000..706484d27 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/FriendlyException/Retry.cs @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.FriendlyException; + +/// +/// 重试静态类 +/// +[SuppressSniffer] +public sealed class Retry +{ + /// + /// 重试有异常的方法,还可以指定特定异常 + /// + /// + /// 重试次数 + /// 重试间隔时间 + /// 是否最终抛异常 + /// 异常类型,可多个 + /// 重试失败回调 + /// 重试时调用方法 + public static void Invoke(Action action + , int numRetries + , int retryTimeout = 1000 + , bool finalThrow = true + , Type[] exceptionTypes = default + , Action fallbackPolicy = default + , Action retryAction = default) + { + if (action == null) throw new ArgumentNullException(nameof(action)); + + InvokeAsync(async () => + { + action(); + await Task.CompletedTask.ConfigureAwait(false); + }, numRetries, retryTimeout, finalThrow, exceptionTypes, fallbackPolicy == null ? null + : async (ex) => + { + fallbackPolicy?.Invoke(ex); + await Task.CompletedTask.ConfigureAwait(false); + }, retryAction).GetAwaiter().GetResult(); + } + + /// + /// 重试有异常的方法,还可以指定特定异常 + /// + /// + /// 重试次数 + /// 重试间隔时间 + /// 是否最终抛异常 + /// 异常类型,可多个 + /// 重试失败回调 + /// 重试时调用方法 + /// + public static async Task InvokeAsync(Func action + , int numRetries + , int retryTimeout = 1000 + , bool finalThrow = true + , Type[] exceptionTypes = default + , Func fallbackPolicy = default + , Action retryAction = default) + { + if (action == null) throw new ArgumentNullException(nameof(action)); + + // 如果重试次数小于或等于 0,则直接调用 + if (numRetries <= 0) + { + await action().ConfigureAwait(false); + return; + } + + // 存储总的重试次数 + var totalNumRetries = numRetries; + + // 不断重试 + while (true) + { + try + { + await action().ConfigureAwait(false); + break; + } + catch (Exception ex) + { + // 如果可重试次数小于或等于0,则终止重试 + if (--numRetries < 0) + { + if (finalThrow) + { + if (fallbackPolicy != null) await fallbackPolicy.Invoke(ex).ConfigureAwait(false); + throw; + } + else return; + } + + // 如果填写了 exceptionTypes 且异常类型不在 exceptionTypes 之内,则终止重试 + if (exceptionTypes != null && exceptionTypes.Length > 0 && !exceptionTypes.Any(u => u.IsAssignableFrom(ex.GetType()))) + { + if (finalThrow) + { + if (fallbackPolicy != null) await fallbackPolicy.Invoke(ex).ConfigureAwait(false); + throw; + } + else return; + } + + // 重试调用委托 + retryAction?.Invoke(totalNumRetries, totalNumRetries - numRetries); + + // 如果可重试异常数大于 0,则间隔指定时间后继续执行 + if (retryTimeout > 0) await Task.Delay(retryTimeout).ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/GlobalUsings.cs b/src/Admin/ThingsGateway.Furion/GlobalUsings.cs new file mode 100644 index 000000000..f852f0f2e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/GlobalUsings.cs @@ -0,0 +1,14 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +global using System.Collections; + +global using ThingsGateway.DependencyInjection; diff --git a/src/Admin/ThingsGateway.Furion/JWT/Extensions/JWTAuthorizationServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/JWT/Extensions/JWTAuthorizationServiceCollectionExtensions.cs new file mode 100644 index 000000000..54c0de411 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JWT/Extensions/JWTAuthorizationServiceCollectionExtensions.cs @@ -0,0 +1,144 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Authorization; +using Microsoft.IdentityModel.Tokens; + +using System.Reflection; + +using ThingsGateway.Authorization; +using ThingsGateway.DataEncryption; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// JWT 授权服务拓展类 +/// +public static class JWTAuthorizationServiceCollectionExtensions +{ + /// + /// 添加 JWT 授权 + /// + /// + /// token 验证参数 + /// + /// 启动全局授权 + /// + public static AuthenticationBuilder AddJwt(this AuthenticationBuilder authenticationBuilder, object tokenValidationParameters = default, Action jwtBearerConfigure = null, bool enableGlobalAuthorize = false) + { + // 获取框架上下文 + _ = JWTEncryption.GetFrameworkContext(Assembly.GetCallingAssembly()); + + // 配置 JWT 选项 + ConfigureJWTOptions(authenticationBuilder.Services); + + // 添加授权 + authenticationBuilder.AddJwtBearer(options => + { + // 反射获取全局配置 + var jwtSettings = JWTEncryption.FrameworkApp.GetMethod("GetOptions").MakeGenericMethod(typeof(JWTSettingsOptions)).Invoke(null, new object[] { null }) as JWTSettingsOptions; + + // 配置 JWT 验证信息 + options.TokenValidationParameters = (tokenValidationParameters as TokenValidationParameters) ?? JWTEncryption.CreateTokenValidationParameters(jwtSettings); + + // 添加自定义配置 + jwtBearerConfigure?.Invoke(options); + }); + + //启用全局授权 + if (enableGlobalAuthorize) + { + authenticationBuilder.Services.Configure(options => + { + options.Filters.Add(new AuthorizeFilter()); + }); + } + + return authenticationBuilder; + } + + /// + /// 添加 JWT 授权 + /// + /// + /// 授权配置 + /// token 验证参数 + /// + /// + public static AuthenticationBuilder AddJwt(this IServiceCollection services, Action authenticationConfigure = null, object tokenValidationParameters = default, Action jwtBearerConfigure = null) + { + // 获取框架上下文 + _ = JWTEncryption.GetFrameworkContext(Assembly.GetCallingAssembly()); + + // 添加默认授权 + var authenticationBuilder = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + + // 添加自定义配置 + authenticationConfigure?.Invoke(options); + }); + + AddJwt(authenticationBuilder, tokenValidationParameters, jwtBearerConfigure); + + return authenticationBuilder; + } + + /// + /// 添加 JWT 授权 + /// + /// + /// + /// + /// + /// + /// + /// + public static AuthenticationBuilder AddJwt(this IServiceCollection services, Action authenticationConfigure = null, object tokenValidationParameters = default, Action jwtBearerConfigure = null, bool enableGlobalAuthorize = false) + where TAuthorizationHandler : class, IAuthorizationHandler + { + // 植入框架 + var furionAssembly = JWTEncryption.GetFrameworkContext(Assembly.GetCallingAssembly()); + + // 获取添加授权类型 + var authorizationServiceCollectionExtensionsType = furionAssembly.GetType("Microsoft.Extensions.DependencyInjection.AuthorizationServiceCollectionExtensions"); + var addAppAuthorizationMethod = authorizationServiceCollectionExtensionsType + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(u => u.Name == "AddAppAuthorization" && u.IsGenericMethod && u.GetParameters().Length > 0 && u.GetParameters()[0].ParameterType == typeof(IServiceCollection)).First(); + + // 添加策略授权服务 + addAppAuthorizationMethod.MakeGenericMethod(typeof(TAuthorizationHandler)).Invoke(null, new object[] { services, null, enableGlobalAuthorize }); + + // 添加授权 + return services.AddJwt(authenticationConfigure, tokenValidationParameters, jwtBearerConfigure); + } + + /// + /// 添加 JWT 授权 + /// + /// + private static void ConfigureJWTOptions(IServiceCollection services) + { + // 配置验证 + services.AddOptions() + .BindConfiguration("JWTSettings") + .ValidateDataAnnotations() + .PostConfigure(options => + { + _ = JWTEncryption.SetDefaultJwtSettings(options); + }); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JWT/JWTEncryption.cs b/src/Admin/ThingsGateway.Furion/JWT/JWTEncryption.cs new file mode 100644 index 000000000..64767e214 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JWT/JWTEncryption.cs @@ -0,0 +1,529 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +using System.Diagnostics; +using System.IdentityModel.Tokens.Jwt; +using System.Reflection; +using System.Runtime.Loader; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +using ThingsGateway.Authorization; + +using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames; + +namespace ThingsGateway.DataEncryption; + +/// +/// JWT 加解密 +/// +public class JWTEncryption +{ + /// + /// 刷新 Token 身份标识 + /// + private static readonly string[] _refreshTokenClaims = new[] { "f", "e", "s", "l", "k" }; + + /// + /// 生成 Token + /// + /// + /// 过期时间(分钟),最大支持 13 年 + /// + public static string Encrypt(IDictionary payload, long? expiredTime = null) + { + var (Payload, JWTSettings) = CombinePayload(payload, expiredTime); + return Encrypt(JWTSettings.IssuerSigningKey, Payload, JWTSettings.Algorithm); + } + private static readonly JsonSerializerOptions _jsonSerializerOptions = new JsonSerializerOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + /// + /// 生成 Token + /// + /// + /// + /// + /// + public static string Encrypt(string issuerSigningKey, IDictionary payload, string algorithm = SecurityAlgorithms.HmacSha256) + { + // 处理 JwtPayload 序列化不一致问题 + var stringPayload = payload is JwtPayload jwtPayload ? jwtPayload.SerializeToJson() : JsonSerializer.Serialize(payload, _jsonSerializerOptions); + return Encrypt(issuerSigningKey, stringPayload, algorithm); + } + + /// + /// 生成 Token + /// + /// + /// + /// + /// + public static string Encrypt(string issuerSigningKey, string payload, string algorithm = SecurityAlgorithms.HmacSha256) + { + SigningCredentials credentials = null; + + if (!string.IsNullOrWhiteSpace(issuerSigningKey)) + { + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(issuerSigningKey)); + credentials = new SigningCredentials(securityKey, algorithm); + } + + var tokenHandler = new JsonWebTokenHandler(); + return credentials == null ? tokenHandler.CreateToken(payload) : tokenHandler.CreateToken(payload, credentials); + } + + /// + /// 生成刷新 Token + /// + /// + /// 刷新 Token 有效期(分钟),最大支持 13 年 + /// + public static string GenerateRefreshToken(string accessToken, int expiredTime = 43200) + { + // 分割Token + var tokenParagraphs = accessToken.Split('.', StringSplitOptions.RemoveEmptyEntries); + + var s = RandomNumberGenerator.GetInt32(10, tokenParagraphs[1].Length / 2 + 2); + var l = RandomNumberGenerator.GetInt32(3, 13); + + var payload = new Dictionary + { + { "f",tokenParagraphs[0] }, + { "e",tokenParagraphs[2] }, + { "s",s }, + { "l",l }, + { "k",tokenParagraphs[1].Substring(s,l) } + }; + + return Encrypt(payload, expiredTime); + } + + /// + /// 通过过期Token 和 刷新Token 换取新的 Token + /// + /// + /// + /// 过期时间(分钟),最大支持 13 年 + /// 刷新token容差值,秒做单位 + /// + public static async Task Exchange(string expiredToken, string refreshToken, long? expiredTime = null, long clockSkew = 5) + { + // 交换刷新Token 必须原Token 已过期 + var (_isValid, _, _) = await Validate(expiredToken).ConfigureAwait(false); + if (_isValid) return default; + + // 判断刷新Token 是否过期 + var (isValid, refreshTokenObj, _) = await Validate(refreshToken).ConfigureAwait(false); + if (!isValid) return default; + + // 解析 HttpContext + var httpContext = GetCurrentHttpContext(); + + // 判断这个刷新Token 是否已刷新过 + var blacklistRefreshKey = "BLACKLIST_REFRESH_TOKEN:" + refreshToken; + var distributedCache = httpContext?.RequestServices?.GetRequiredService(); + + // 处理token并发容错问题 + var nowTime = DateTimeOffset.UtcNow; + var cachedValue = await distributedCache.GetStringAsync(blacklistRefreshKey).ConfigureAwait(false); + var isRefresh = !string.IsNullOrWhiteSpace(cachedValue); // 判断是否刷新过 + if (isRefresh) + { + var refreshTime = new DateTimeOffset(long.Parse(cachedValue), TimeSpan.Zero); + // 处理并发时容差值 + if ((nowTime - refreshTime).TotalSeconds > clockSkew) return default; + } + + // 分割过期Token + var tokenParagraphs = expiredToken.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (tokenParagraphs.Length < 3) return default; + + // 判断各个部分是否匹配 + if (!refreshTokenObj.GetPayloadValue("f").Equals(tokenParagraphs[0])) return default; + if (!refreshTokenObj.GetPayloadValue("e").Equals(tokenParagraphs[2])) return default; + if (!tokenParagraphs[1].Substring(refreshTokenObj.GetPayloadValue("s"), refreshTokenObj.GetPayloadValue("l")).Equals(refreshTokenObj.GetPayloadValue("k"))) return default; + + // 获取过期 Token 的存储信息 + var jwtSecurityToken = SecurityReadJwtToken(expiredToken); + var payload = jwtSecurityToken.Payload; + + // 移除 Iat,Nbf,Exp + foreach (var innerKey in DateTypeClaimTypes) + { + if (!payload.ContainsKey(innerKey)) continue; + + payload.Remove(innerKey); + } + + // 交换成功后登记刷新Token,标记失效 + if (!isRefresh) + { + await distributedCache.SetStringAsync(blacklistRefreshKey, nowTime.Ticks.ToString(), new DistributedCacheEntryOptions + { + AbsoluteExpiration = DateTimeOffset.FromUnixTimeSeconds(refreshTokenObj.GetPayloadValue(JwtRegisteredClaimNames.Exp)) + }).ConfigureAwait(false); + } + + return Encrypt(payload, expiredTime); + } + + /// + /// 自动刷新 Token 信息 + /// + /// + /// + /// 新 Token 过期时间(分钟),最大支持 13 年 + /// 新刷新 Token 有效期(分钟) + /// + /// + /// + public static async Task AutoRefreshToken(AuthorizationHandlerContext context, DefaultHttpContext httpContext, long? expiredTime = null, int refreshTokenExpiredTime = 43200, string tokenPrefix = "Bearer ", long clockSkew = 5) + { + // 如果验证有效,则跳过刷新 + if (context.User.Identity.IsAuthenticated) + { + // 禁止使用刷新 Token 进行单独校验 + if (_refreshTokenClaims.All(k => context.User.Claims.Any(c => c.Type == k))) + { + return false; + } + + return true; + } + + // 判断是否含有匿名特性 + if (httpContext.GetEndpoint()?.Metadata?.GetMetadata() != null) return true; + + // 获取过期Token 和 刷新Token + var expiredToken = GetJwtBearerToken(httpContext, tokenPrefix: tokenPrefix); + var refreshToken = GetJwtBearerToken(httpContext, "X-Authorization", tokenPrefix: tokenPrefix); + if (string.IsNullOrWhiteSpace(expiredToken) || string.IsNullOrWhiteSpace(refreshToken)) return false; + + // 交换新的 Token + var accessToken = await Exchange(expiredToken, refreshToken, expiredTime, clockSkew).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(accessToken)) return false; + + // 读取新的 Token Clamis + var claims = ReadJwtToken(accessToken)?.Claims; + if (claims == null) return false; + + // 创建身份信息 + var claimIdentity = new ClaimsIdentity("AuthenticationTypes.Federation"); + claimIdentity.AddClaims(claims); + var claimsPrincipal = new ClaimsPrincipal(claimIdentity); + + // 设置 HttpContext.User 并登录 + httpContext.User = claimsPrincipal; + await httpContext.SignInAsync(claimsPrincipal).ConfigureAwait(false); + + string accessTokenKey = "access-token" + , xAccessTokenKey = "x-access-token" + , accessControlExposeKey = "Access-Control-Expose-Headers"; + + // 返回新的 Token + httpContext.Response.Headers[accessTokenKey] = accessToken; + // 返回新的 刷新Token + httpContext.Response.Headers[xAccessTokenKey] = GenerateRefreshToken(accessToken, refreshTokenExpiredTime); + + // 处理 axios 问题 + httpContext.Response.Headers.TryGetValue(accessControlExposeKey, out var acehs); + httpContext.Response.Headers[accessControlExposeKey] = string.Join(',', StringValues.Concat(acehs, new StringValues(new[] { accessTokenKey, xAccessTokenKey })).Distinct()); + + return true; + } + + /// + /// 验证 Token + /// + /// + /// + public static async Task<(bool IsValid, JsonWebToken Token, TokenValidationResult validationResult)> Validate(string accessToken) + { + var jwtSettings = GetJWTSettings(); + if (jwtSettings == null) return (false, default, default); + + // 加密Key + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.IssuerSigningKey)); + var creds = new SigningCredentials(key, jwtSettings.Algorithm); + + // 创建Token验证参数 + var tokenValidationParameters = CreateTokenValidationParameters(jwtSettings); + tokenValidationParameters.IssuerSigningKey ??= creds.Key; + + // 验证 Token + var tokenHandler = new JsonWebTokenHandler(); + try + { + var tokenValidationResult = await tokenHandler.ValidateTokenAsync(accessToken, tokenValidationParameters).ConfigureAwait(false); + if (!tokenValidationResult.IsValid) return (false, null, tokenValidationResult); + + var jsonWebToken = tokenValidationResult.SecurityToken as JsonWebToken; + return (true, jsonWebToken, tokenValidationResult); + } + catch + { + return (false, default, default); + } + } + + /// + /// 验证 Token + /// + /// + /// + /// + /// + public static async Task<(bool, JsonWebToken)> ValidateJwtBearerToken(DefaultHttpContext httpContext, string headerKey = "Authorization", string tokenPrefix = "Bearer ") + { + // 获取 token + var accessToken = GetJwtBearerToken(httpContext, headerKey, tokenPrefix); + if (string.IsNullOrWhiteSpace(accessToken)) + { + return (false, null); + } + + // 验证token + var (IsValid, Token, _) = await Validate(accessToken).ConfigureAwait(false); + var token = IsValid ? Token : null; + + return (IsValid, token); + } + + /// + /// 读取 Token,不含验证 + /// + /// + /// + public static JsonWebToken ReadJwtToken(string accessToken) + { + var tokenHandler = new JsonWebTokenHandler(); + if (tokenHandler.CanReadToken(accessToken)) + { + return tokenHandler.ReadJsonWebToken(accessToken); + } + + return default; + } + + /// + /// 读取 Token,不含验证 + /// + /// + /// + public static JwtSecurityToken SecurityReadJwtToken(string accessToken) + { + var jwtSecurityTokenHandler = new JwtSecurityTokenHandler(); + var jwtSecurityToken = jwtSecurityTokenHandler.ReadJwtToken(accessToken); + return jwtSecurityToken; + } + + /// + /// 获取 JWT Bearer Token + /// + /// + /// + /// + /// + public static string GetJwtBearerToken(DefaultHttpContext httpContext, string headerKey = "Authorization", string tokenPrefix = "Bearer ") + { + // 判断请求报文头中是否有 "Authorization" 报文头 + var bearerToken = httpContext.Request.Headers[headerKey].ToString(); + if (string.IsNullOrWhiteSpace(bearerToken)) return default; + + var prefixLenght = tokenPrefix.Length; + return bearerToken.StartsWith(tokenPrefix, true, null) && bearerToken.Length > prefixLenght ? bearerToken[prefixLenght..].Trim() : default; + } + + /// + /// 获取 JWT 配置 + /// + /// + public static JWTSettingsOptions GetJWTSettings() + { + // 获取框架上下文 + _ = GetFrameworkContext(Assembly.GetCallingAssembly()); + + if (FrameworkApp == null) + { + Debug.WriteLine("No register the code `services.AddJwt()` on Startup.cs."); + } + + var jwtSettingsOptions = FrameworkApp.GetMethod("GetOptions").MakeGenericMethod(typeof(JWTSettingsOptions)).Invoke(null, new object[] { null }) as JWTSettingsOptions; + if (jwtSettingsOptions.Algorithm == null && jwtSettingsOptions.ExpiredTime == null) + { + SetDefaultJwtSettings(jwtSettingsOptions); + } + return jwtSettingsOptions; + } + + /// + /// 生成Token验证参数 + /// + /// + /// + public static TokenValidationParameters CreateTokenValidationParameters(JWTSettingsOptions jwtSettings) + { + return new TokenValidationParameters + { + // 验证签发方密钥 + ValidateIssuerSigningKey = jwtSettings.ValidateIssuerSigningKey.Value, + // 签发方密钥 + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.IssuerSigningKey)), + // 验证签发方 + ValidateIssuer = jwtSettings.ValidateIssuer.Value, + // 设置签发方 + ValidIssuer = jwtSettings.ValidIssuer, + // 验证签收方 + ValidateAudience = jwtSettings.ValidateAudience.Value, + // 设置接收方 + ValidAudience = jwtSettings.ValidAudience, + // 验证生存期 + ValidateLifetime = jwtSettings.ValidateLifetime.Value, + // 过期时间容错值 + ClockSkew = TimeSpan.FromSeconds(jwtSettings.ClockSkew.Value), + // 验证过期时间,设置 false 永不过期 + RequireExpirationTime = jwtSettings.RequireExpirationTime + }; + } + + /// + /// 组合 Claims 负荷 + /// + /// + /// 过期时间,单位:分钟,最大支持 13 年 + /// + private static (IDictionary Payload, JWTSettingsOptions JWTSettings) CombinePayload(IDictionary payload, long? expiredTime = null) + { + var jwtSettings = GetJWTSettings(); + var datetimeOffset = DateTimeOffset.UtcNow; + + if (!payload.ContainsKey(JwtRegisteredClaimNames.Iat)) + { + payload.Add(JwtRegisteredClaimNames.Iat, datetimeOffset.ToUnixTimeSeconds()); + } + + if (!payload.ContainsKey(JwtRegisteredClaimNames.Nbf)) + { + payload.Add(JwtRegisteredClaimNames.Nbf, datetimeOffset.ToUnixTimeSeconds()); + } + + if (!payload.ContainsKey(JwtRegisteredClaimNames.Exp)) + { + var minute = expiredTime ?? jwtSettings?.ExpiredTime ?? 20; + payload.Add(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddMinutes(minute).ToUnixTimeSeconds()); + } + + if (!payload.ContainsKey(JwtRegisteredClaimNames.Iss)) + { + payload.Add(JwtRegisteredClaimNames.Iss, jwtSettings?.ValidIssuer); + } + + if (!payload.ContainsKey(JwtRegisteredClaimNames.Aud)) + { + payload.Add(JwtRegisteredClaimNames.Aud, jwtSettings?.ValidAudience); + } + + return (payload, jwtSettings); + } + + /// + /// 设置默认 Jwt 配置 + /// + /// + /// + internal static JWTSettingsOptions SetDefaultJwtSettings(JWTSettingsOptions options) + { + options.ValidateIssuerSigningKey ??= true; + if (options.ValidateIssuerSigningKey == true) + { + options.IssuerSigningKey ??= "U2FsdGVkX1+6H3D8Q//yQMhInzTdRZI9DbUGetbyaag="; + } + options.ValidateIssuer ??= true; + if (options.ValidateIssuer == true) + { + options.ValidIssuer ??= "diego"; + } + options.ValidateAudience ??= true; + if (options.ValidateAudience == true) + { + options.ValidAudience ??= "powerby ThingsGateway"; + } + options.ValidateLifetime ??= true; + if (options.ValidateLifetime == true) + { + options.ClockSkew ??= 10; + } + options.ExpiredTime ??= 20; + options.Algorithm ??= SecurityAlgorithms.HmacSha256; + + return options; + } + + /// + /// 获取当前的 HttpContext + /// + /// + private static HttpContext GetCurrentHttpContext() + { + return FrameworkApp.GetProperty("HttpContext").GetValue(null) as HttpContext; + } + + /// + /// 日期类型的 Claim 类型 + /// + private static readonly string[] DateTypeClaimTypes = new[] { JwtRegisteredClaimNames.Iat, JwtRegisteredClaimNames.Nbf, JwtRegisteredClaimNames.Exp }; + + /// + /// 框架 App 静态类 + /// + internal static Type FrameworkApp { get; set; } + + /// + /// 获取框架上下文 + /// + /// + internal static Assembly GetFrameworkContext(Assembly callAssembly) + { + if (FrameworkApp != null) return FrameworkApp.Assembly; + + // 修复不注册 AddJwt 服务不能使用 JWT 加密问题 + var executeAssembly = callAssembly == typeof(JWTEncryption).Assembly + ? Assembly.GetEntryAssembly() + : callAssembly; + + // 获取 程序集名称 + var furionAssemblyName = executeAssembly.GetReferencedAssemblies() + .FirstOrDefault(u => u.Name == "ThingsGateway" || u.Name == "ThingsGateway.Furion") + ?? throw new InvalidOperationException("No `ThingsGateway` assembly installed in the current project was detected."); + + // 加载 程序集 + var furionAssembly = AssemblyLoadContext.Default.LoadFromAssemblyName(furionAssemblyName); + + // 获取 App 静态类 + FrameworkApp = furionAssembly.GetType("ThingsGateway.App"); + + return furionAssembly; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JWT/Options/JWTSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/JWT/Options/JWTSettingsOptions.cs new file mode 100644 index 000000000..50ace71ce --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JWT/Options/JWTSettingsOptions.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Authorization; + +/// +/// Jwt 配置 +/// +public sealed class JWTSettingsOptions +{ + /// + /// 验证签发方密钥 + /// + public bool? ValidateIssuerSigningKey { get; set; } + + /// + /// 签发方密钥 + /// + public string IssuerSigningKey { get; set; } + + /// + /// 验证签发方 + /// + public bool? ValidateIssuer { get; set; } + + /// + /// 签发方 + /// + public string ValidIssuer { get; set; } + + /// + /// 验证签收方 + /// + public bool? ValidateAudience { get; set; } + + /// + /// 签收方 + /// + public string ValidAudience { get; set; } + + /// + /// 验证生存期 + /// + public bool? ValidateLifetime { get; set; } + + /// + /// 过期时间容错值,解决服务器端时间不同步问题(秒) + /// + public long? ClockSkew { get; set; } + + /// + /// 过期时间(分钟) + /// + public long? ExpiredTime { get; set; } + + /// + /// 加密算法 + /// + public string Algorithm { get; set; } + + /// + /// 验证过期时间,设置 false 永不过期 + /// + public bool RequireExpirationTime { get; set; } = true; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonDateOnlyJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonDateOnlyJsonConverter.cs new file mode 100644 index 000000000..16c201386 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonDateOnlyJsonConverter.cs @@ -0,0 +1,126 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace ThingsGateway.JsonSerialization; + +/// +/// DateOnly 类型序列化 +/// +[SuppressSniffer] +public class NewtonsoftJsonDateOnlyJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public NewtonsoftJsonDateOnlyJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftJsonDateOnlyJsonConverter(string format = "yyyy-MM-dd") + { + Format = format; + } + + /// + /// 日期格式化格式 + /// + public string Format { get; private set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + public override DateOnly ReadJson(JsonReader reader, Type objectType, DateOnly existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var value = JValue.ReadFrom(reader).Value(); + return DateOnly.Parse(value); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, DateOnly value, JsonSerializer serializer) + { + writer.WriteValue(value.ToString(Format)); + } +} + +/// +/// DateOnly? 类型序列化 +/// +[SuppressSniffer] +public class NewtonsoftJsonNullableDateOnlyJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public NewtonsoftJsonNullableDateOnlyJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftJsonNullableDateOnlyJsonConverter(string format = "yyyy-MM-dd") + { + Format = format; + } + + /// + /// 日期格式化格式 + /// + public string Format { get; private set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + public override DateOnly? ReadJson(JsonReader reader, Type objectType, DateOnly? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var value = JValue.ReadFrom(reader).Value(); + return !string.IsNullOrWhiteSpace(value) ? DateOnly.Parse(value) : null; + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, DateOnly? value, JsonSerializer serializer) + { + if (value == null) writer.WriteNull(); + else writer.WriteValue(value.Value.ToString(Format)); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonDateTimeJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonDateTimeJsonConverter.cs new file mode 100644 index 000000000..b618fbb1b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonDateTimeJsonConverter.cs @@ -0,0 +1,166 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Newtonsoft.Json; + +namespace ThingsGateway.JsonSerialization; + +/// +/// DateTime 类型序列化 +/// +[SuppressSniffer] +public class NewtonsoftJsonDateTimeJsonConverter : JsonConverter +{ + /// + /// 默认构造函数 + /// + public NewtonsoftJsonDateTimeJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftJsonDateTimeJsonConverter(string format = "yyyy-MM-dd HH:mm:ss") + { + Format = format; + } + + /// + /// 构造函数 + /// + /// + /// + public NewtonsoftJsonDateTimeJsonConverter(string format = "yyyy-MM-dd HH:mm:ss", bool outputToLocalDateTime = false) + : this(format) + { + Localized = outputToLocalDateTime; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 是否输出为为当地时间 + /// + public bool Localized { get; private set; } = false; + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + /// + public override DateTime ReadJson(JsonReader reader, Type objectType, DateTime existingValue, bool hasExistingValue, JsonSerializer serializer) + { + return Penetrates.ConvertToDateTime(ref reader); + } + + /// + /// 序列化 + /// + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, DateTime value, JsonSerializer serializer) + { + // 判断是否序列化成当地时间 + var formatDateTime = Localized ? value.ToLocalTime() : value; + writer.WriteValue(formatDateTime.ToString(Format)); + } +} + +/// +/// DateTime 类型序列化 +/// +[SuppressSniffer] +public class NewtonsoftNullableJsonDateTimeJsonConverter : JsonConverter +{ + /// + /// 默认构造函数 + /// + public NewtonsoftNullableJsonDateTimeJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftNullableJsonDateTimeJsonConverter(string format = "yyyy-MM-dd HH:mm:ss") + { + Format = format; + } + + /// + /// 构造函数 + /// + /// + /// + public NewtonsoftNullableJsonDateTimeJsonConverter(string format = "yyyy-MM-dd HH:mm:ss", bool outputToLocalDateTime = false) + : this(format) + { + Localized = outputToLocalDateTime; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 是否输出为为当地时间 + /// + public bool Localized { get; private set; } = false; + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + /// + public override DateTime? ReadJson(JsonReader reader, Type objectType, DateTime? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + return Penetrates.ConvertToDateTime(ref reader); + } + + /// + /// 序列化 + /// + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, DateTime? value, JsonSerializer serializer) + { + if (value == null) writer.WriteNull(); + else + { + // 判断是否序列化成当地时间 + var formatDateTime = Localized ? value.Value.ToLocalTime() : value.Value; + writer.WriteValue(formatDateTime.ToString(Format)); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonDateTimeOffsetJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonDateTimeOffsetJsonConverter.cs new file mode 100644 index 000000000..57287cfd3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonDateTimeOffsetJsonConverter.cs @@ -0,0 +1,166 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Newtonsoft.Json; + +namespace ThingsGateway.JsonSerialization; + +/// +/// DateTimeOffset 类型序列化 +/// +[SuppressSniffer] +public class NewtonsoftJsonDateTimeOffsetJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public NewtonsoftJsonDateTimeOffsetJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftJsonDateTimeOffsetJsonConverter(string format = "yyyy-MM-dd HH:mm:ss") + { + Format = format; + } + + /// + /// 构造函数 + /// + /// + /// + public NewtonsoftJsonDateTimeOffsetJsonConverter(string format = "yyyy-MM-dd HH:mm:ss", bool outputToLocalDateTime = false) + : this(format) + { + Localized = outputToLocalDateTime; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 是否输出为为当地时间 + /// + public bool Localized { get; private set; } = false; + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + /// + public override DateTimeOffset ReadJson(JsonReader reader, Type objectType, DateTimeOffset existingValue, bool hasExistingValue, JsonSerializer serializer) + { + return DateTime.SpecifyKind(Penetrates.ConvertToDateTime(ref reader), Localized ? DateTimeKind.Local : DateTimeKind.Utc); + } + + /// + /// 序列化 + /// + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, DateTimeOffset value, JsonSerializer serializer) + { + // 判断是否序列化成当地时间 + var formatDateTime = Localized ? value.ToLocalTime() : value; + writer.WriteValue(formatDateTime.ToString(Format)); + } +} + +/// +/// DateTimeOffset 类型序列化 +/// +[SuppressSniffer] +public class NewtonsoftJsonNullableDateTimeOffsetJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public NewtonsoftJsonNullableDateTimeOffsetJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftJsonNullableDateTimeOffsetJsonConverter(string format = "yyyy-MM-dd HH:mm:ss") + { + Format = format; + } + + /// + /// 构造函数 + /// + /// + /// + public NewtonsoftJsonNullableDateTimeOffsetJsonConverter(string format = "yyyy-MM-dd HH:mm:ss", bool outputToLocalDateTime = false) + : this(format) + { + Localized = outputToLocalDateTime; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 是否输出为为当地时间 + /// + public bool Localized { get; private set; } = false; + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + /// + public override DateTimeOffset? ReadJson(JsonReader reader, Type objectType, DateTimeOffset? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + return DateTime.SpecifyKind(Penetrates.ConvertToDateTime(ref reader), Localized ? DateTimeKind.Local : DateTimeKind.Utc); + } + + /// + /// 序列化 + /// + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, DateTimeOffset? value, JsonSerializer serializer) + { + if (value == null) writer.WriteNull(); + else + { + // 判断是否序列化成当地时间 + var formatDateTime = Localized ? value.Value.ToLocalTime() : value.Value; + writer.WriteValue(formatDateTime.ToString(Format)); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonLongToStringJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonLongToStringJsonConverter.cs new file mode 100644 index 000000000..723e2d59b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonLongToStringJsonConverter.cs @@ -0,0 +1,141 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace ThingsGateway.JsonSerialization; + +/// +/// 解决 long 精度问题 +/// +[SuppressSniffer] +public class NewtonsoftJsonLongToStringJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public NewtonsoftJsonLongToStringJsonConverter() + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftJsonLongToStringJsonConverter(bool overMaxLengthOf17 = false) + { + OverMaxLengthOf17 = overMaxLengthOf17; + } + + /// + /// 是否超过最大长度 17 再处理 + /// + public bool OverMaxLengthOf17 { get; set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + public override long ReadJson(JsonReader reader, Type objectType, long existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var jt = JValue.ReadFrom(reader); + + return jt.Type == JTokenType.Null // 处理 public long? Property { get; set;} = 0; 情况,也就是类型是 long? 但是也给了默认值 + ? existingValue + : jt.Value(); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, long value, JsonSerializer serializer) + { + if (OverMaxLengthOf17) + { + if (value.ToString().Length <= 17) writer.WriteValue(value); + else writer.WriteValue(value.ToString()); + } + else writer.WriteValue(value.ToString()); + } +} + +/// +/// 解决 long? 精度问题 +/// +[SuppressSniffer] +public class NewtonsoftJsonNullableLongToStringJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public NewtonsoftJsonNullableLongToStringJsonConverter() + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftJsonNullableLongToStringJsonConverter(bool overMaxLengthOf17 = false) + { + OverMaxLengthOf17 = overMaxLengthOf17; + } + + /// + /// 是否超过最大长度 17 再处理 + /// + public bool OverMaxLengthOf17 { get; set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + public override long? ReadJson(JsonReader reader, Type objectType, long? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var jt = JValue.ReadFrom(reader); + return jt.Value(); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, long? value, JsonSerializer serializer) + { + if (value == null) writer.WriteNull(); + else + { + var newValue = value.Value; + if (OverMaxLengthOf17) + { + if (newValue.ToString().Length <= 17) writer.WriteValue(newValue); + else writer.WriteValue(newValue.ToString()); + } + else writer.WriteValue(newValue.ToString()); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonTimeOnlyJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonTimeOnlyJsonConverter.cs new file mode 100644 index 000000000..4d6007f96 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/NewtonsoftJson/NewtonsoftJsonTimeOnlyJsonConverter.cs @@ -0,0 +1,126 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace ThingsGateway.JsonSerialization; + +/// +/// TimeOnly 类型序列化 +/// +[SuppressSniffer] +public class NewtonsoftJsonTimeOnlyJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public NewtonsoftJsonTimeOnlyJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftJsonTimeOnlyJsonConverter(string format = "HH:mm:ss") + { + Format = format; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + public override TimeOnly ReadJson(JsonReader reader, Type objectType, TimeOnly existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var value = JValue.ReadFrom(reader).Value(); + return TimeOnly.Parse(value); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, TimeOnly value, JsonSerializer serializer) + { + writer.WriteValue(value.ToString(Format)); + } +} + +/// +/// TimeOnly? 类型序列化 +/// +[SuppressSniffer] +public class NewtonsoftJsonNullableTimeOnlyJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public NewtonsoftJsonNullableTimeOnlyJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public NewtonsoftJsonNullableTimeOnlyJsonConverter(string format = "HH:mm:ss") + { + Format = format; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + public override TimeOnly? ReadJson(JsonReader reader, Type objectType, TimeOnly? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var value = JValue.ReadFrom(reader).Value(); + return !string.IsNullOrWhiteSpace(value) ? TimeOnly.Parse(value) : null; + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, TimeOnly? value, JsonSerializer serializer) + { + if (value == null) writer.WriteNull(); + else writer.WriteValue(value.Value.ToString(Format)); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonDateOnlyJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonDateOnlyJsonConverter.cs new file mode 100644 index 000000000..a267976c7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonDateOnlyJsonConverter.cs @@ -0,0 +1,120 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingsGateway.JsonSerialization; + +/// +/// DateOnly 类型序列化 +/// +[SuppressSniffer] +public class SystemTextJsonDateOnlyJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public SystemTextJsonDateOnlyJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonDateOnlyJsonConverter(string format = "yyyy-MM-dd") + { + Format = format; + } + + /// + /// 日期格式化格式 + /// + public string Format { get; private set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return DateOnly.Parse(reader.GetString()); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(Format)); + } +} + +/// +/// DateOnly? 类型序列化 +/// +[SuppressSniffer] +public class SystemTextJsonNullableDateOnlyJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public SystemTextJsonNullableDateOnlyJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonNullableDateOnlyJsonConverter(string format = "yyyy-MM-dd") + { + Format = format; + } + + /// + /// 日期格式化格式 + /// + public string Format { get; private set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override DateOnly? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return DateOnly.TryParse(reader.GetString(), out var date) ? date : null; + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateOnly? value, JsonSerializerOptions options) + { + if (value == null) writer.WriteNullValue(); + else writer.WriteStringValue(value.Value.ToString(Format)); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonDateTimeJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonDateTimeJsonConverter.cs new file mode 100644 index 000000000..5bbfbadd9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonDateTimeJsonConverter.cs @@ -0,0 +1,159 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingsGateway.JsonSerialization; + +/// +/// DateTime 类型序列化 +/// +[SuppressSniffer] +public class SystemTextJsonDateTimeJsonConverter : JsonConverter +{ + /// + /// 默认构造函数 + /// + public SystemTextJsonDateTimeJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonDateTimeJsonConverter(string format = "yyyy-MM-dd HH:mm:ss") + { + Format = format; + } + + /// + /// 构造函数 + /// + /// + /// + public SystemTextJsonDateTimeJsonConverter(string format = "yyyy-MM-dd HH:mm:ss", bool outputToLocalDateTime = false) + : this(format) + { + Localized = outputToLocalDateTime; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 是否输出为为当地时间 + /// + public bool Localized { get; private set; } = false; + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return Penetrates.ConvertToDateTime(ref reader); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + // 判断是否序列化成当地时间 + var formatDateTime = Localized ? value.ToLocalTime() : value; + writer.WriteStringValue(formatDateTime.ToString(Format)); + } +} + +/// +/// DateTime? 类型序列化 +/// +[SuppressSniffer] +public class SystemTextJsonNullableDateTimeJsonConverter : JsonConverter +{ + /// + /// 默认构造函数 + /// + public SystemTextJsonNullableDateTimeJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonNullableDateTimeJsonConverter(string format = "yyyy-MM-dd HH:mm:ss") + { + Format = format; + } + + /// + /// 构造函数 + /// + /// + /// + public SystemTextJsonNullableDateTimeJsonConverter(string format = "yyyy-MM-dd HH:mm:ss", bool outputToLocalDateTime = false) + : this(format) + { + Localized = outputToLocalDateTime; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 是否输出为为当地时间 + /// + public bool Localized { get; private set; } = false; + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return Penetrates.ConvertToDateTime(ref reader); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value == null) writer.WriteNullValue(); + else + { + // 判断是否序列化成当地时间 + var formatDateTime = Localized ? value.Value.ToLocalTime() : value.Value; + writer.WriteStringValue(formatDateTime.ToString(Format)); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonDateTimeOffsetJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonDateTimeOffsetJsonConverter.cs new file mode 100644 index 000000000..76bb01f23 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonDateTimeOffsetJsonConverter.cs @@ -0,0 +1,159 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingsGateway.JsonSerialization; + +/// +/// DateTimeOffset 类型序列化 +/// +[SuppressSniffer] +public class SystemTextJsonDateTimeOffsetJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public SystemTextJsonDateTimeOffsetJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonDateTimeOffsetJsonConverter(string format = "yyyy-MM-dd HH:mm:ss") + { + Format = format; + } + + /// + /// 构造函数 + /// + /// + /// + public SystemTextJsonDateTimeOffsetJsonConverter(string format = "yyyy-MM-dd HH:mm:ss", bool outputToLocalDateTime = false) + : this(format) + { + Localized = outputToLocalDateTime; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 是否输出为为当地时间 + /// + public bool Localized { get; private set; } = false; + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return DateTime.SpecifyKind(Penetrates.ConvertToDateTime(ref reader), Localized ? DateTimeKind.Local : DateTimeKind.Utc); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + // 判断是否序列化成当地时间 + var formatDateTime = Localized ? value.ToLocalTime() : value; + writer.WriteStringValue(formatDateTime.ToString(Format)); + } +} + +/// +/// DateTimeOffset? 类型序列化 +/// +[SuppressSniffer] +public class SystemTextJsonNullableDateTimeOffsetJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public SystemTextJsonNullableDateTimeOffsetJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonNullableDateTimeOffsetJsonConverter(string format = "yyyy-MM-dd HH:mm:ss") + { + Format = format; + } + + /// + /// 构造函数 + /// + /// + /// + public SystemTextJsonNullableDateTimeOffsetJsonConverter(string format = "yyyy-MM-dd HH:mm:ss", bool outputToLocalDateTime = false) + : this(format) + { + Localized = outputToLocalDateTime; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 是否输出为为当地时间 + /// + public bool Localized { get; private set; } = false; + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return DateTime.SpecifyKind(Penetrates.ConvertToDateTime(ref reader), Localized ? DateTimeKind.Local : DateTimeKind.Utc); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options) + { + if (value == null) writer.WriteNullValue(); + else + { + // 判断是否序列化成当地时间 + var formatDateTime = Localized ? value.Value.ToLocalTime() : value.Value; + writer.WriteStringValue(formatDateTime.ToString(Format)); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonLongToStringJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonLongToStringJsonConverter.cs new file mode 100644 index 000000000..3e2800ac3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonLongToStringJsonConverter.cs @@ -0,0 +1,136 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingsGateway.JsonSerialization; + +/// +/// 解决 long 精度问题 +/// +[SuppressSniffer] +public class SystemTextJsonLongToStringJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public SystemTextJsonLongToStringJsonConverter() + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonLongToStringJsonConverter(bool overMaxLengthOf17 = false) + { + OverMaxLengthOf17 = overMaxLengthOf17; + } + + /// + /// 是否超过最大长度 17 再处理 + /// + public bool OverMaxLengthOf17 { get; set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType == JsonTokenType.String + ? long.Parse(reader.GetString()) + : reader.GetInt64(); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + { + if (OverMaxLengthOf17) + { + if (value.ToString().Length <= 17) writer.WriteNumberValue(value); + else writer.WriteStringValue(value.ToString()); + } + else writer.WriteStringValue(value.ToString()); + } +} + +/// +/// 解决 long? 精度问题 +/// +[SuppressSniffer] +public class SystemTextJsonNullableLongToStringJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public SystemTextJsonNullableLongToStringJsonConverter() + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonNullableLongToStringJsonConverter(bool overMaxLengthOf17 = false) + { + OverMaxLengthOf17 = overMaxLengthOf17; + } + + /// + /// 是否超过最大长度 17 再处理 + /// + public bool OverMaxLengthOf17 { get; set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType == JsonTokenType.String + ? long.Parse(reader.GetString()) + : reader.GetInt64(); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options) + { + if (value == null) writer.WriteNullValue(); + else + { + var newValue = value.Value; + if (OverMaxLengthOf17) + { + if (newValue.ToString().Length <= 17) writer.WriteNumberValue(newValue); + else writer.WriteStringValue(newValue.ToString()); + } + else writer.WriteStringValue(newValue.ToString()); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonTimeOnlyJsonConverter.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonTimeOnlyJsonConverter.cs new file mode 100644 index 000000000..6aa298f2b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Converters/SystemTextJson/SystemTextJsonTimeOnlyJsonConverter.cs @@ -0,0 +1,120 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingsGateway.JsonSerialization; + +/// +/// TimeOnly 类型序列化 +/// +[SuppressSniffer] +public class SystemTextJsonTimeOnlyJsonConverter : JsonConverter +{ + /// + /// 构造函数 + /// + public SystemTextJsonTimeOnlyJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonTimeOnlyJsonConverter(string format = "HH:mm:ss") + { + Format = format; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return TimeOnly.Parse(reader.GetString()); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(Format)); + } +} + +/// +/// TimeOnly? 类型序列化 +/// +[SuppressSniffer] +public class SystemTextJsonNullableTimeOnlyJsonConverter : JsonConverter +{ + /// + /// 默认构造函数 + /// + public SystemTextJsonNullableTimeOnlyJsonConverter() + : this(default) + { + } + + /// + /// 构造函数 + /// + /// + public SystemTextJsonNullableTimeOnlyJsonConverter(string format = "HH:mm:ss") + { + Format = format; + } + + /// + /// 时间格式化格式 + /// + public string Format { get; private set; } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public override TimeOnly? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return TimeOnly.TryParse(reader.GetString(), out var time) ? time : null; + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void Write(Utf8JsonWriter writer, TimeOnly? value, JsonSerializerOptions options) + { + if (value == null) writer.WriteNullValue(); + else writer.WriteStringValue(value.Value.ToString(Format)); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Extensions/JsonSerializationServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Extensions/JsonSerializationServiceCollectionExtensions.cs new file mode 100644 index 000000000..c10e57abf --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Extensions/JsonSerializationServiceCollectionExtensions.cs @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; + +using ThingsGateway.JsonSerialization; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Json 序列化服务拓展类 +/// +[SuppressSniffer] +public static class JsonSerializationServiceCollectionExtensions +{ + /// + /// 配置 Json 序列化提供器 + /// + /// + /// + /// + public static IServiceCollection AddJsonSerialization(this IServiceCollection services) + where TJsonSerializerProvider : class, IJsonSerializerProvider + { + services.AddSingleton(); + return services; + } + + /// + /// 配置 JsonOptions 序列化选项 + /// 主要给非 Web 环境使用 + /// + /// + /// + /// + public static IServiceCollection AddJsonOptions(this IServiceCollection services, Action configure) + { + // 手动添加配置 + services.Configure(options => + { + configure?.Invoke(options); + }); + + return services; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Extensions/NewtonsoftJsonExtensions.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Extensions/NewtonsoftJsonExtensions.cs new file mode 100644 index 000000000..dc6f05d96 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Extensions/NewtonsoftJsonExtensions.cs @@ -0,0 +1,82 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.JsonSerialization; + +namespace Newtonsoft.Json; + +/// +/// Newtonsoft.Json 拓展 +/// +[SuppressSniffer] +public static class NewtonsoftJsonExtensions +{ + /// + /// 添加 DateTime/DateTime?/DateTimeOffset/DateTimeOffset? 类型序列化处理 + /// + /// + /// + /// 自动转换 DateTime/DateTimeOffset 为当地时间 + /// + public static IList AddDateTimeTypeConverters(this IList converters, string outputFormat = "yyyy-MM-dd HH:mm:ss", bool localized = false) + { + converters.Add(new NewtonsoftJsonDateTimeJsonConverter(outputFormat, localized)); + converters.Add(new NewtonsoftNullableJsonDateTimeJsonConverter(outputFormat, localized)); + + converters.Add(new NewtonsoftJsonDateTimeOffsetJsonConverter(outputFormat, localized)); + converters.Add(new NewtonsoftJsonNullableDateTimeOffsetJsonConverter(outputFormat, localized)); + + return converters; + } + + /// + /// 添加 long/long? 类型序列化处理 + /// + /// + /// 是否超过最大长度 17 再处理 + /// + public static IList AddLongTypeConverters(this IList converters, bool overMaxLengthOf17 = false) + { + converters.Add(new NewtonsoftJsonLongToStringJsonConverter(overMaxLengthOf17)); + converters.Add(new NewtonsoftJsonNullableLongToStringJsonConverter(overMaxLengthOf17)); + + return converters; + } + + + /// + /// 添加 DateOnly/DateOnly? 类型序列化处理 + /// + /// + /// + /// + public static IList AddDateOnlyConverters(this IList converters, string outputFormat = "yyyy-MM-dd") + { + converters.Add(new NewtonsoftJsonDateOnlyJsonConverter(outputFormat)); + converters.Add(new NewtonsoftJsonNullableDateOnlyJsonConverter(outputFormat)); + + return converters; + } + + /// + /// 添加 TimeOnly/TimeOnly? 类型序列化处理 + /// + /// + /// + /// + public static IList AddTimeOnlyConverters(this IList converters, string outputFormat = "HH:mm:ss") + { + converters.Add(new NewtonsoftJsonTimeOnlyJsonConverter(outputFormat)); + converters.Add(new NewtonsoftJsonNullableTimeOnlyJsonConverter(outputFormat)); + + return converters; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Extensions/SystemTextJsonExtensions.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Extensions/SystemTextJsonExtensions.cs new file mode 100644 index 000000000..22bffa330 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Extensions/SystemTextJsonExtensions.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; + +using ThingsGateway.JsonSerialization; + +namespace System.Text.Json; + +/// +/// System.Text.Json 拓展 +/// +[SuppressSniffer] +public static class SystemTextJsonExtensions +{ + /// + /// 添加 DateTime/DateTime?/DateTimeOffset/DateTimeOffset? 类型序列化处理 + /// + /// + /// + /// 自动转换 DateTime/DateTimeOffset 为当地时间 + /// + public static IList AddDateTimeTypeConverters(this IList converters, string outputFormat = "yyyy-MM-dd HH:mm:ss", bool localized = false) + { + converters.Add(new SystemTextJsonDateTimeJsonConverter(outputFormat, localized)); + converters.Add(new SystemTextJsonNullableDateTimeJsonConverter(outputFormat, localized)); + + converters.Add(new SystemTextJsonDateTimeOffsetJsonConverter(outputFormat, localized)); + converters.Add(new SystemTextJsonNullableDateTimeOffsetJsonConverter(outputFormat, localized)); + + return converters; + } + + /// + /// 添加 long/long? 类型序列化处理 + /// + /// + /// 是否超过最大长度 17 再处理 + /// + public static IList AddLongTypeConverters(this IList converters, bool overMaxLengthOf17 = false) + { + converters.Add(new SystemTextJsonLongToStringJsonConverter(overMaxLengthOf17)); + converters.Add(new SystemTextJsonNullableLongToStringJsonConverter(overMaxLengthOf17)); + + return converters; + } + + /// + /// 添加 DateOnly/DateOnly? 类型序列化处理 + /// + /// + /// + /// + public static IList AddDateOnlyConverters(this IList converters, string outputFormat = "yyyy-MM-dd") + { + converters.Add(new SystemTextJsonDateOnlyJsonConverter(outputFormat)); + converters.Add(new SystemTextJsonNullableDateOnlyJsonConverter(outputFormat)); + + return converters; + } + + /// + /// 添加 TimeOnly/TimeOnly? 类型序列化处理 + /// + /// + /// + /// + public static IList AddTimeOnlyConverters(this IList converters, string outputFormat = "HH:mm:ss") + { + converters.Add(new SystemTextJsonTimeOnlyJsonConverter(outputFormat)); + converters.Add(new SystemTextJsonNullableTimeOnlyJsonConverter(outputFormat)); + + return converters; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Internal/Penetrates.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Internal/Penetrates.cs new file mode 100644 index 000000000..df3130fbc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Internal/Penetrates.cs @@ -0,0 +1,72 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using System.Text.Json; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.JsonSerialization; + +/// +/// 常量、公共方法配置类 +/// +internal static class Penetrates +{ + /// + /// 转换 + /// + /// + /// + internal static DateTime ConvertToDateTime(ref Utf8JsonReader reader) + { + // 处理时间戳自动转换 + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt64(out var longValue)) + { + return longValue.ConvertToDateTime(); + }; + + var stringValue = reader.GetString(); + + // 处理时间戳自动转换 + if (long.TryParse(stringValue, out var longValue2)) + { + return longValue2.ConvertToDateTime(); + } + + return Convert.ToDateTime(stringValue); + } + + /// + /// 转换 + /// + /// + /// + internal static DateTime ConvertToDateTime(ref JsonReader reader) + { + if (reader.TokenType == JsonToken.Integer) + { + return JValue.ReadFrom(reader).Value().ConvertToDateTime(); + } + + var stringValue = JValue.ReadFrom(reader).Value(); + + // 处理时间戳自动转换 + if (long.TryParse(stringValue, out var longValue2)) + { + return longValue2.ConvertToDateTime(); + } + + return Convert.ToDateTime(stringValue); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/JSON.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/JSON.cs new file mode 100644 index 000000000..dfcf54c2c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/JSON.cs @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json; + +namespace ThingsGateway.JsonSerialization; + +/// +/// JSON 静态帮助类 +/// +[SuppressSniffer] +public static class JSON +{ + /// + /// 获取 JSON 序列化提供器 + /// + /// + public static IJsonSerializerProvider GetJsonSerializer() + { + return App.GetService(App.RootServices); + } + + /// + /// 序列化对象 + /// + /// + /// + /// + public static string Serialize(object value, object jsonSerializerOptions = default) + { + return GetJsonSerializer().Serialize(value, jsonSerializerOptions); + } + + /// + /// 反序列化字符串 + /// + /// + /// + /// + /// + public static T Deserialize(string json, object jsonSerializerOptions = default) + { + return GetJsonSerializer().Deserialize(json, jsonSerializerOptions); + } + + /// + /// 获取 JSON 配置选项 + /// + /// + /// + public static TOptions GetSerializerOptions() + where TOptions : class + { + return GetJsonSerializer().GetSerializerOptions() as TOptions; + } + + /// + /// 检查 JSON 字符串是否有效 + /// + /// JSON 字符串 + /// 标准 JSON + /// + public static bool IsValid(string jsonString, bool standard = false) + { + if (string.IsNullOrWhiteSpace(jsonString)) return false; + + try + { + using var document = JsonDocument.Parse(jsonString); + return !standard || document.RootElement.ValueKind == JsonValueKind.Object || document.RootElement.ValueKind == JsonValueKind.Array; + } + catch (JsonException) + { + return false; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Providers/IJsonSerializerProvider.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Providers/IJsonSerializerProvider.cs new file mode 100644 index 000000000..308e0dca0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Providers/IJsonSerializerProvider.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.JsonSerialization; + +/// +/// Json 序列化提供器 +/// +public interface IJsonSerializerProvider +{ + /// + /// 序列化对象 + /// + /// + /// + /// + string Serialize(object value, object jsonSerializerOptions = default); + + /// + /// 反序列化字符串 + /// + /// + /// + /// + /// + T Deserialize(string json, object jsonSerializerOptions = default); + + /// + /// 反序列化字符串 + /// + /// + /// + /// + /// + object Deserialize(string json, Type returnType, object jsonSerializerOptions = default); + + /// + /// 返回读取全局配置的 JSON 选项 + /// + /// + object GetSerializerOptions(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/JsonSerialization/Providers/SystemTextJsonSerializerProvider.cs b/src/Admin/ThingsGateway.Furion/JsonSerialization/Providers/SystemTextJsonSerializerProvider.cs new file mode 100644 index 000000000..413047e90 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/JsonSerialization/Providers/SystemTextJsonSerializerProvider.cs @@ -0,0 +1,105 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +using System.Text.Json; + +namespace ThingsGateway.JsonSerialization; + +/// +/// System.Text.Json 序列化提供器(默认实现) +/// +[Injection(Order = -999)] +public class SystemTextJsonSerializerProvider : IJsonSerializerProvider, ISingleton +{ + /// + /// 获取 JSON 配置选项 + /// + private readonly JsonOptions _jsonOptions; + + /// + /// 构造函数 + /// + /// + public SystemTextJsonSerializerProvider(IOptions options) + { + _jsonOptions = options.Value; + } + + /// + /// 序列化对象 + /// + /// + /// + /// + public string Serialize(object value, object jsonSerializerOptions = null) + { + return JsonSerializer.Serialize(value, GetJsonSerializerOptions(jsonSerializerOptions)); + } + + /// + /// 反序列化字符串 + /// + /// + /// + /// + /// + public T Deserialize(string json, object jsonSerializerOptions = null) + { + return JsonSerializer.Deserialize(json, GetJsonSerializerOptions(jsonSerializerOptions)); + } + + /// + /// 反序列化字符串 + /// + /// + /// + /// + /// + public object Deserialize(string json, Type returnType, object jsonSerializerOptions = null) + { + return JsonSerializer.Deserialize(json, returnType, GetJsonSerializerOptions(jsonSerializerOptions)); + } + + /// + /// 返回读取全局配置的 JSON 选项 + /// + /// + public object GetSerializerOptions() + { + return _jsonOptions?.JsonSerializerOptions; + } + + /// + /// 获取默认的序列化配置 + /// + /// + /// + private JsonSerializerOptions GetJsonSerializerOptions(object jsonSerializerOptions = null) + { + var jsonSerializerOptionsValue = (jsonSerializerOptions ?? GetSerializerOptions() ?? new JsonSerializerOptions()) as JsonSerializerOptions; + +#if !NET6_0 && !NET7_0 + if (!jsonSerializerOptionsValue.IsReadOnly && !jsonSerializerOptionsValue.PropertyNameCaseInsensitive) + { + // 默认不区分大小写匹配 + jsonSerializerOptionsValue.PropertyNameCaseInsensitive = true; + } +#else + // 默认不区分大小写匹配 + if (!jsonSerializerOptionsValue.PropertyNameCaseInsensitive) jsonSerializerOptionsValue.PropertyNameCaseInsensitive = true; +#endif + + return jsonSerializerOptionsValue; + } +} diff --git a/src/Admin/ThingsGateway.Furion/Logging/Extensions/ILoggerExtensions.cs b/src/Admin/ThingsGateway.Furion/Logging/Extensions/ILoggerExtensions.cs new file mode 100644 index 000000000..eae1707fb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Extensions/ILoggerExtensions.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Logging; + +namespace Microsoft.Extensions.Logging; + +/// +/// 拓展 +/// +[SuppressSniffer] +public static class ILoggerExtensions +{ + /// + /// 设置日志上下文 + /// + /// + /// 建议使用 ConcurrentDictionary 类型 + /// + public static IDisposable ScopeContext(this ILogger logger, IDictionary properties) + { + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + return logger.BeginScope(new LogContext { Properties = properties }); + } + + /// + /// 设置日志上下文 + /// + /// + /// + /// + public static IDisposable ScopeContext(this ILogger logger, Action configure) + { + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + var logContext = new LogContext(); + configure?.Invoke(logContext); + + return logger.BeginScope(logContext); + } + + /// + /// 设置日志上下文 + /// + /// + /// + /// + public static IDisposable ScopeContext(this ILogger logger, LogContext context) + { + if (logger == null) throw new ArgumentNullException(nameof(logger)); + + return logger.BeginScope(context); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Extensions/ILoggerFactoryExtensions.cs b/src/Admin/ThingsGateway.Furion/Logging/Extensions/ILoggerFactoryExtensions.cs new file mode 100644 index 000000000..f63166db4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Extensions/ILoggerFactoryExtensions.cs @@ -0,0 +1,150 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Logging; + +namespace Microsoft.Extensions.Logging; + +/// +/// 拓展 +/// +[SuppressSniffer] +public static class ILoggerFactoryExtensions +{ + /// + /// 添加文件日志记录器 + /// + /// 日志工厂 + /// 日志文件完整路径或文件名,推荐 .log 作为拓展名 + /// 追加到已存在日志文件或覆盖它们 + /// + public static ILoggerFactory AddFile(this ILoggerFactory factory, string fileName, bool append = true) + { + // 添加文件日志记录器提供程序 + factory.AddProvider(new FileLoggerProvider(fileName ?? "application.log", append)); + + return factory; + } + + /// + /// 添加文件日志记录器 + /// + /// 日志工厂 + /// 日志文件完整路径或文件名,推荐 .log 作为拓展名 + /// + /// + public static ILoggerFactory AddFile(this ILoggerFactory factory, string fileName, Action configure) + { + var options = new FileLoggerOptions(); + configure?.Invoke(options); + + // 添加文件日志记录器提供程序 + factory.AddProvider(new FileLoggerProvider(fileName ?? "application.log", options)); + + return factory; + } + + /// + /// 添加文件日志记录器 + /// + /// 日志工厂 + /// 文件日志记录器配置选项委托 + /// + public static ILoggerFactory AddFile(this ILoggerFactory factory, Action configure = default) + { + return factory.AddFile(() => "Logging:File", configure); + } + + /// + /// 添加文件日志记录器 + /// + /// 日志工厂 + /// 获取配置文件对应的 Key + /// 文件日志记录器配置选项委托 + /// + public static ILoggerFactory AddFile(this ILoggerFactory factory, Func configuraionKey, Action configure = default) + { + // 添加文件日志记录器提供程序 + factory.AddProvider(Penetrates.CreateFromConfiguration(configuraionKey, configure)); + + return factory; + } + + /// + /// 添加数据库日志记录器 + /// + /// 实现自 + /// 日志工厂 + /// 服务提供器 + /// 数据库日志记录器配置选项委托 + /// + public static ILoggerFactory AddDatabase(this ILoggerFactory factory, IServiceProvider serviceProvider, Action configure) + where TDatabaseLoggingWriter : class, IDatabaseLoggingWriter + { + var options = new DatabaseLoggerOptions(); + configure?.Invoke(options); + + var databaseLoggerProvider = new DatabaseLoggerProvider(options); + + // 解决数据库写入器中循环引用数据库仓储问题 + if (databaseLoggerProvider._serviceScope == null) + { + databaseLoggerProvider.SetServiceProvider(serviceProvider, typeof(TDatabaseLoggingWriter)); + } + + // 添加数据库日志记录器提供程序 + factory.AddProvider(databaseLoggerProvider); + + return factory; + } + + /// + /// 添加数据库日志记录器 + /// + /// 实现自 + /// 日志工厂 + /// 服务提供器 + /// 配置文件对于的 Key + /// 数据库日志记录器配置选项委托 + /// + public static ILoggerFactory AddDatabase(this ILoggerFactory factory, IServiceProvider serviceProvider, string configuraionKey = default, Action configure = default) + where TDatabaseLoggingWriter : class, IDatabaseLoggingWriter + { + return factory.AddDatabase(() => configuraionKey ?? "Logging:Database", serviceProvider, configure); + } + + /// + /// 添加数据库日志记录器 + /// + /// 实现自 + /// 日志工厂 + /// 获取配置文件对应的 Key + /// 服务提供器 + /// 数据库日志记录器配置选项委托 + /// + public static ILoggerFactory AddDatabase(this ILoggerFactory factory, Func configuraionKey, IServiceProvider serviceProvider, Action configure = default) + where TDatabaseLoggingWriter : class, IDatabaseLoggingWriter + { + // 创建数据库日志记录器提供程序 + var databaseLoggerProvider = Penetrates.CreateFromConfiguration(configuraionKey, configure); + + // 解决数据库写入器中循环引用数据库仓储问题 + if (databaseLoggerProvider._serviceScope == null) + { + databaseLoggerProvider.SetServiceProvider(serviceProvider, typeof(TDatabaseLoggingWriter)); + } + + // 添加数据库日志记录器提供程序 + factory.AddProvider(databaseLoggerProvider); + + return factory; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Extensions/ILoggingBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/Logging/Extensions/ILoggingBuilderExtensions.cs new file mode 100644 index 000000000..dd8671cab --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Extensions/ILoggingBuilderExtensions.cs @@ -0,0 +1,198 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using System.Diagnostics; + +using ThingsGateway.Logging; + +namespace Microsoft.Extensions.Logging; + +/// +/// 日志构建器拓展类 +/// +[SuppressSniffer] +public static class ILoggingBuilderExtensions +{ + /// + /// 添加控制台默认格式化器 + /// + /// + /// + /// + public static ILoggingBuilder AddConsoleFormatter(this ILoggingBuilder builder, Action configure = default) + { + configure ??= (options) => { }; + + return builder.AddConsole(options => options.FormatterName = "console-format") + .AddConsoleFormatter(configure); + } + + /// + /// 添加文件日志记录器 + /// + /// 日志构建器 + /// 日志文件完整路径或文件名,推荐 .log 作为拓展名 + /// 追加到已存在日志文件或覆盖它们 + /// + public static ILoggingBuilder AddFile(this ILoggingBuilder builder, string fileName, bool append = true) + { + // 注册文件日志记录器提供器 + builder.Services.Add(ServiceDescriptor.Singleton((serviceProvider) => + { + return new FileLoggerProvider(fileName ?? "application.log", append); + })); + + return builder; + } + + /// + /// 添加文件日志记录器 + /// + /// 日志构建器 + /// 日志文件完整路径或文件名,推荐 .log 作为拓展名 + /// 文件日志记录器配置选项委托 + /// + public static ILoggingBuilder AddFile(this ILoggingBuilder builder, string fileName, Action configure) + { + // 注册文件日志记录器提供器 + builder.Services.Add(ServiceDescriptor.Singleton((serviceProvider) => + { + var options = new FileLoggerOptions(); + configure?.Invoke(options); + + return new FileLoggerProvider(fileName ?? "application.log", options); + })); + + return builder; + } + + /// + /// 添加文件日志记录器(从配置文件中)默认 Key 为:"Logging:File" + /// + /// 日志构建器 + /// 文件日志记录器配置选项委托 + /// + public static ILoggingBuilder AddFile(this ILoggingBuilder builder, Action configure = default) + { + return builder.AddFile(() => "Logging:File", configure); + } + + /// + /// 添加文件日志记录器(从配置文件中) + /// + /// 日志构建器 + /// 获取配置文件对应的 Key + /// 文件日志记录器配置选项委托 + /// + public static ILoggingBuilder AddFile(this ILoggingBuilder builder, Func configuraionKey, Action configure = default) + { + // 注册文件日志记录器提供器 + builder.Services.Add(ServiceDescriptor.Singleton((serviceProvider) => + { + return Penetrates.CreateFromConfiguration(configuraionKey, configure); + })); + + return builder; + } + + /// + /// 添加数据库日志记录器 + /// + /// 实现自 + /// 日志构建器 + /// 数据库日志记录器配置选项委托 + /// + public static ILoggingBuilder AddDatabase(this ILoggingBuilder builder, Action configure) + where TDatabaseLoggingWriter : class, IDatabaseLoggingWriter + { + // 注册数据库日志写入器 + builder.Services.TryAddTransient(); + + // 注册数据库日志记录器提供器 + builder.Services.Add(ServiceDescriptor.Singleton((serviceProvider) => + { + // 解决在 IDatabaseLoggingWriter 实现类直接注册仓储导致死循环的问题 + var stackTrace = new System.Diagnostics.StackTrace(); + var frames = stackTrace.GetFrames(); + + if (frames.Any(u => u.HasMethod() && u.GetMethod().Name == "ResolveDbContext") + || frames.Count(u => u.HasMethod() && u.GetMethod().Name.StartsWith("")) > 1) + { + return new EmptyLoggerProvider(); + } + + var options = new DatabaseLoggerOptions(); + configure?.Invoke(options); + + // 数据库日志记录器提供程序 + var databaseLoggerProvider = new DatabaseLoggerProvider(options); + databaseLoggerProvider.SetServiceProvider(serviceProvider, typeof(TDatabaseLoggingWriter)); + + return databaseLoggerProvider; + })); + + return builder; + } + + /// + /// 添加数据库日志记录器 + /// + /// 实现自 + /// 日志构建器 + /// 配置文件对于的 Key + /// 数据库日志记录器配置选项委托 + /// + public static ILoggingBuilder AddDatabase(this ILoggingBuilder builder, string configuraionKey = default, Action configure = default) + where TDatabaseLoggingWriter : class, IDatabaseLoggingWriter + { + return builder.AddDatabase(() => configuraionKey ?? "Logging:Database", configure); + } + + /// + /// 添加数据库日志记录器(从配置文件中) + /// + /// 实现自 + /// 日志构建器 + /// 获取配置文件对于的 Key + /// 数据库日志记录器配置选项委托 + /// + public static ILoggingBuilder AddDatabase(this ILoggingBuilder builder, Func configuraionKey, Action configure = default) + where TDatabaseLoggingWriter : class, IDatabaseLoggingWriter + { + // 注册数据库日志写入器 + builder.Services.TryAddTransient(); + + // 注册数据库日志记录器提供器 + builder.Services.Add(ServiceDescriptor.Singleton((serviceProvider) => + { + // 解决在 IDatabaseLoggingWriter 实现类直接注册仓储导致死循环的问题 + var stackTrace = new System.Diagnostics.StackTrace(); + var frames = stackTrace.GetFrames(); + + if (frames.Any(u => u.HasMethod() && u.GetMethod().Name == "ResolveDbContext") + || frames.Count(u => u.HasMethod() && u.GetMethod().Name.StartsWith("")) > 1) + { + return new EmptyLoggerProvider(); + } + + // 创建数据库日志记录器提供程序 + var databaseLoggerProvider = Penetrates.CreateFromConfiguration(configuraionKey, configure); + databaseLoggerProvider.SetServiceProvider(serviceProvider, typeof(TDatabaseLoggingWriter)); + + return databaseLoggerProvider; + })); + + return builder; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Extensions/LogContextExtensions.cs b/src/Admin/ThingsGateway.Furion/Logging/Extensions/LogContextExtensions.cs new file mode 100644 index 000000000..b8943c942 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Extensions/LogContextExtensions.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Extensions; + +namespace ThingsGateway.Logging; + +/// +/// LogContext 拓展 +/// +[SuppressSniffer] +public static class LogContextExtensions +{ + /// + /// 设置上下文数据 + /// + /// + /// 键 + /// 值 + /// + public static LogContext Set(this LogContext logContext, object key, object value) + { + if (logContext == null || key == null) return logContext; + + logContext.Properties ??= new Dictionary(); + + logContext.Properties.Remove(key); + logContext.Properties.Add(key, value); + return logContext; + } + + /// + /// 批量设置上下文数据 + /// + /// + /// + /// + public static LogContext SetRange(this LogContext logContext, IDictionary properties) + { + if (logContext == null + || properties == null + || properties.Count == 0) return logContext; + + foreach (var (key, value) in properties) + { + logContext.Set(key, value); + } + + return logContext; + } + + /// + /// 获取上下文数据 + /// + /// + /// 键 + /// + public static object Get(this LogContext logContext, object key) + { + if (logContext == null + || key == null + || logContext.Properties == null + || logContext.Properties.Count == 0) return default; + + var isExists = logContext.Properties.TryGetValue(key, out var value); + return isExists ? value : null; + } + + /// + /// 获取上下文数据 + /// + /// + /// 键 + /// + public static T Get(this LogContext logContext, object key) + { + var value = logContext.Get(key); + return value.ChangeType(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Extensions/LogMessageExtensions.cs b/src/Admin/ThingsGateway.Furion/Logging/Extensions/LogMessageExtensions.cs new file mode 100644 index 000000000..f560eca5b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Extensions/LogMessageExtensions.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace ThingsGateway.Logging; + +/// +/// 拓展 +/// +[SuppressSniffer] +public static class LogMessageExtensions +{ + /// + /// 高性能创建 JSON 对象字符串 + /// + /// + /// + /// 是否对 JSON 格式化 + /// + public static string Write(this LogMessage _, Action writeAction, bool writeIndented = false) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions + { + // 解决中文乱码问题 + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + Indented = writeIndented + }); + + writeAction?.Invoke(writer); + + writer.Flush(); + + return Encoding.UTF8.GetString(stream.ToArray()); + } + + /// + /// 高性能创建 JSON 数组字符串 + /// + /// + /// + /// 是否对 JSON 格式化 + /// + public static string WriteArray(this LogMessage logMsg, Action writeAction, bool writeIndented = false) + { + return logMsg.Write(writer => + { + writer.WriteStartArray(); + + writeAction?.Invoke(writer); + + writer.WriteEndArray(); + }, writeIndented); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Extensions/LoggingServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/Logging/Extensions/LoggingServiceCollectionExtensions.cs new file mode 100644 index 000000000..b52cea6f1 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Extensions/LoggingServiceCollectionExtensions.cs @@ -0,0 +1,155 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +using ThingsGateway; +using ThingsGateway.Logging; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 日志服务拓展类 +/// +[SuppressSniffer] +public static class LoggingServiceCollectionExtensions +{ + /// + /// 添加控制台默认格式化器 + /// + /// + /// 添加更多配置 + /// + public static IServiceCollection AddConsoleFormatter(this IServiceCollection services, Action configure = default) + { + return services.AddLogging(builder => builder.AddConsoleFormatter(configure)); + } + + /// + /// 添加日志监视器服务 + /// + /// + /// 添加更多配置 + /// 配置文件对于的 Key,默认为 Logging:Monitor + /// + public static IServiceCollection AddMonitorLogging(this IServiceCollection services, Action configure = default, string jsonKey = "Logging:Monitor") + { + // 读取配置 + var settings = App.GetConfig(jsonKey) + ?? new LoggingMonitorSettings(); + settings.IsMvcFilterRegister = false; // 解决过去 Mvc Filter 全局注册的问题 + settings.FromGlobalFilter = true; // 解决局部和全局触发器同时配置触发两次问题 + settings.IncludeOfMethods ??= Array.Empty(); + settings.ExcludeOfMethods ??= Array.Empty(); + settings.MethodsSettings ??= Array.Empty(); + + // 添加外部配置 + configure?.Invoke(settings); + + // 配置日志过滤器 + LoggingMonitorSettings.InternalWriteFilter = settings.WriteFilter; + + // 如果配置 GlobalEnabled = false 且 IncludeOfMethods 和 ExcludeOfMethods 都为空,则不注册服务 + if (settings.GlobalEnabled == false + && settings.IncludeOfMethods.Length == 0 + && settings.ExcludeOfMethods.Length == 0) return services; + + // 注册日志监视器过滤器 + services.AddMvcFilter(new LoggingMonitorAttribute(settings)); + + return services; + } + + /// + /// 添加文件日志服务 + /// + /// + /// 日志文件完整路径或文件名,推荐 .log 作为拓展名 + /// 追加到已存在日志文件或覆盖它们 + /// + public static IServiceCollection AddFileLogging(this IServiceCollection services, string fileName, bool append = true) + { + return services.AddLogging(builder => builder.AddFile(fileName, append)); + } + + /// + /// 添加文件日志服务 + /// + /// + /// 日志文件完整路径或文件名,推荐 .log 作为拓展名 + /// 文件日志记录器配置选项委托 + /// + public static IServiceCollection AddFileLogging(this IServiceCollection services, string fileName, Action configure) + { + return services.AddLogging(builder => builder.AddFile(fileName, configure)); + } + + /// + /// 添加文件日志服务(从配置文件中读取配置) + /// + /// + /// 文件日志记录器配置选项委托 + /// + public static IServiceCollection AddFileLogging(this IServiceCollection services, Action configure = default) + { + return services.AddLogging(builder => builder.AddFile(configure)); + } + + /// + /// 添加文件日志服务(从配置文件中读取配置) + /// + /// + /// 获取配置文件对应的 Key + /// 文件日志记录器配置选项委托 + /// + public static IServiceCollection AddFileLogging(this IServiceCollection services, Func configuraionKey, Action configure = default) + { + return services.AddLogging(builder => builder.AddFile(configuraionKey, configure)); + } + + /// + /// 添加数据库日志服务 + /// + /// + /// 数据库日志记录器配置选项委托 + /// + public static IServiceCollection AddDatabaseLogging(this IServiceCollection services, Action configure) + where TDatabaseLoggingWriter : class, IDatabaseLoggingWriter + { + return services.AddLogging(builder => builder.AddDatabase(configure)); + } + + /// + /// 添加数据库日志服务 + /// + /// + /// 配置文件对于的 Key + /// 数据库日志记录器配置选项委托 + /// + public static IServiceCollection AddDatabaseLogging(this IServiceCollection services, string configuraionKey = default, Action configure = default) + where TDatabaseLoggingWriter : class, IDatabaseLoggingWriter + { + return services.AddLogging(builder => builder.AddDatabase(configuraionKey, configure)); + } + + /// + /// 添加数据库日志服务 + /// + /// + /// 获取配置文件对于的 Key + /// 数据库日志记录器配置选项委托 + /// + public static IServiceCollection AddDatabaseLogging(this IServiceCollection services, Func configuraionKey, Action configure = default) + where TDatabaseLoggingWriter : class, IDatabaseLoggingWriter + { + return services.AddLogging(builder => builder.AddDatabase(configuraionKey, configure)); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Extensions/StringLoggingExtensions.cs b/src/Admin/ThingsGateway.Furion/Logging/Extensions/StringLoggingExtensions.cs new file mode 100644 index 000000000..ffc395b11 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Extensions/StringLoggingExtensions.cs @@ -0,0 +1,665 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging.Extensions; + +/// +/// 字符串日志输出拓展 +/// +[SuppressSniffer] +public static class StringLoggingExtensions +{ + /// + /// 设置消息格式化参数 + /// + /// + /// + public static StringLoggingPart SetArgs(this string message, params object[] args) + { + return StringLoggingPart.Default().SetMessage(message).SetArgs(args); + } + + /// + /// 设置日志级别 + /// + /// + /// + public static StringLoggingPart SetLevel(this string message, LogLevel level) + { + return StringLoggingPart.Default().SetMessage(message).SetLevel(level); + } + + /// + /// 设置事件 Id + /// + /// + /// + public static StringLoggingPart SetEventId(this string message, EventId eventId) + { + return StringLoggingPart.Default().SetMessage(message).SetEventId(eventId); + } + + /// + /// 设置日志分类 + /// + /// + /// + public static StringLoggingPart SetCategory(this string message) + { + return StringLoggingPart.Default().SetMessage(message).SetCategory(); + } + + /// + /// 设置异常对象 + /// + public static StringLoggingPart SetException(this string message, Exception exception) + { + return StringLoggingPart.Default().SetMessage(message).SetException(exception); + } + + /// + /// 设置日志服务作用域 + /// + /// + /// + /// + public static StringLoggingPart SetLoggerScoped(this string message, IServiceProvider serviceProvider) + { + return StringLoggingPart.Default().SetMessage(message).SetLoggerScoped(serviceProvider); + } + + /// + /// 配置日志上下文 + /// + /// + /// 建议使用 ConcurrentDictionary 类型 + /// + public static StringLoggingPart ScopeContext(this string message, IDictionary properties) + { + return StringLoggingPart.Default().SetMessage(message).ScopeContext(properties); + } + + /// + /// 配置日志上下文 + /// + /// + /// + /// + public static StringLoggingPart ScopeContext(this string message, Action configure) + { + return StringLoggingPart.Default().SetMessage(message).ScopeContext(configure); + } + + /// + /// 配置日志上下文 + /// + /// + /// + /// + public static StringLoggingPart ScopeContext(this string message, LogContext context) + { + return StringLoggingPart.Default().SetMessage(message).ScopeContext(context); + } + + /// + /// LogInformation + /// + /// + /// + public static void LogInformation(this string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + public static void LogInformation(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + public static void LogInformation(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + /// + public static void LogInformation(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + public static void LogInformation(this string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + /// + public static void LogInformation(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + /// + public static void LogInformation(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + /// + /// + public static void LogInformation(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogInformation(); + } + + /// + /// LogWarning + /// + /// + /// + public static void LogWarning(this string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + public static void LogWarning(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + public static void LogWarning(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + /// + public static void LogWarning(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + public static void LogWarning(this string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + /// + public static void LogWarning(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + /// + public static void LogWarning(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + /// + /// + public static void LogWarning(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogWarning(); + } + + /// + /// LogError + /// + /// + /// + public static void LogError(this string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + public static void LogError(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + public static void LogError(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + /// + public static void LogError(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + public static void LogError(this string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + /// + public static void LogError(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + /// + public static void LogError(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + /// + /// + public static void LogError(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogError(); + } + + /// + /// LogDebug + /// + /// + /// + public static void LogDebug(this string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + public static void LogDebug(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + public static void LogDebug(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + /// + public static void LogDebug(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + public static void LogDebug(this string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + /// + public static void LogDebug(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + /// + public static void LogDebug(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + /// + /// + public static void LogDebug(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogDebug(); + } + + /// + /// LogTrace + /// + /// + /// + public static void LogTrace(this string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + public static void LogTrace(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + public static void LogTrace(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + /// + public static void LogTrace(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + public static void LogTrace(this string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + /// + public static void LogTrace(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + /// + public static void LogTrace(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + /// + /// + public static void LogTrace(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogTrace(); + } + + /// + /// LogCritical + /// + /// + /// + public static void LogCritical(this string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + public static void LogCritical(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + public static void LogCritical(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + /// + public static void LogCritical(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + public static void LogCritical(this string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + /// + public static void LogCritical(this string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + /// + public static void LogCritical(this string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + /// + /// + public static void LogCritical(this string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogCritical(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Console/ConsoleColors.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Console/ConsoleColors.cs new file mode 100644 index 000000000..a46dc61b7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Console/ConsoleColors.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Logging; + +/// +/// 控制台颜色结构 +/// +internal readonly struct ConsoleColors +{ + /// + /// 构造函数 + /// + /// + /// + public ConsoleColors(ConsoleColor? foreground, ConsoleColor? background) + { + Foreground = foreground; + Background = background; + } + + /// + /// 前景色 + /// + public ConsoleColor? Foreground { get; } + + /// + /// 背景色 + /// + public ConsoleColor? Background { get; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Console/ConsoleFormatterExtend.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Console/ConsoleFormatterExtend.cs new file mode 100644 index 000000000..801d21324 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Console/ConsoleFormatterExtend.cs @@ -0,0 +1,141 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Logging.Console; +using Microsoft.Extensions.Options; + +namespace ThingsGateway.Logging; + +/// +/// 控制台默认格式化程序拓展 +/// +[SuppressSniffer] +public sealed class ConsoleFormatterExtend : ConsoleFormatter, IDisposable +{ + /// + /// 日志格式化选项刷新 Token + /// + private readonly IDisposable _formatOptionsReloadToken; + + /// + /// 日志格式化配置选项 + /// + private ConsoleFormatterExtendOptions _formatterOptions; + + /// + /// 是否启用控制台颜色 + /// + private bool _disableColors; + + /// + /// 构造函数 + /// + /// + public ConsoleFormatterExtend(IOptionsMonitor formatterOptions) + : base("console-format") + { + (_formatOptionsReloadToken, _formatterOptions) = (formatterOptions.OnChange(ReloadFormatterOptions), formatterOptions.CurrentValue); + _disableColors = _formatterOptions.ColorBehavior == LoggerColorBehavior.Disabled || (_formatterOptions.ColorBehavior == LoggerColorBehavior.Default && Console.IsOutputRedirected); + } + + /// + /// 写入日志 + /// + /// + /// + /// + /// + public override void Write(in LogEntry logEntry, IExternalScopeProvider scopeProvider, TextWriter textWriter) + { + // 获取格式化后的消息 + var message = logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception); + + // 日志消息内容转换(如脱敏处理) + if (_formatterOptions.MessageProcess != null) + { + message = _formatterOptions.MessageProcess(message); + } + + // 创建日志消息 + var logDateTime = _formatterOptions.UseUtcTimestamp ? DateTime.UtcNow : DateTime.Now; + var logMsg = new LogMessage(logEntry.Category, logEntry.LogLevel, logEntry.EventId, message, logEntry.Exception, null, logEntry.State, logDateTime, Environment.CurrentManagedThreadId, _formatterOptions.UseUtcTimestamp, App.GetTraceId()) + { + // 设置日志上下文 + Context = Penetrates.SetLogContext(scopeProvider, _formatterOptions.IncludeScopes) + }; + + string standardMessage; + + // 是否自定义了自定义日志格式化程序,如果是则使用 + if (_formatterOptions.MessageFormat != null) + { + // 设置日志消息模板 + standardMessage = _formatterOptions.MessageFormat(logMsg); + } + else + { + // 获取标准化日志消息 + standardMessage = Penetrates.OutputStandardMessage(logMsg + , _formatterOptions.DateFormat + , true + , _disableColors + , _formatterOptions.WithTraceId + , _formatterOptions.WithStackFrame); + } + + // 判断是否自定义了日志筛选器,如果是则检查是否符合条件 + if (_formatterOptions.WriteFilter?.Invoke(logMsg) == false) + { + logMsg.Context?.Dispose(); + return; + } + + // 空检查 + if (message is null) + { + logMsg.Context?.Dispose(); + return; + } + + // 判断是否自定义了日志格式化程序 + if (_formatterOptions.WriteHandler != null) + { + _formatterOptions.WriteHandler?.Invoke(logMsg, scopeProvider, textWriter, standardMessage, _formatterOptions); + } + else + { + // 写入控制台 + textWriter.WriteLine(standardMessage); + } + + logMsg.Context?.Dispose(); + } + + /// + /// 释放非托管资源 + /// + public void Dispose() + { + _formatOptionsReloadToken?.Dispose(); + } + + /// + /// 刷新日志格式化选项 + /// + /// + private void ReloadFormatterOptions(ConsoleFormatterExtendOptions options) + { + _formatterOptions = options; + _disableColors = options.ColorBehavior == LoggerColorBehavior.Disabled || (options.ColorBehavior == LoggerColorBehavior.Default && Console.IsOutputRedirected); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Console/ConsoleFormatterExtendOptions.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Console/ConsoleFormatterExtendOptions.cs new file mode 100644 index 000000000..aac123fd4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Console/ConsoleFormatterExtendOptions.cs @@ -0,0 +1,72 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; + +namespace ThingsGateway.Logging; + +/// +/// 控制台默认格式化选项拓展 +/// +[SuppressSniffer] +public sealed class ConsoleFormatterExtendOptions : ConsoleFormatterOptions +{ + /// + /// 构造函数 + /// + public ConsoleFormatterExtendOptions() + : base() + { + // 默认启用控制台日志上下文功能 + IncludeScopes = true; + } + + /// + /// 控制是否启用颜色 + /// + public LoggerColorBehavior ColorBehavior { get; set; } + + /// + /// 自定义日志消息格式化程序 + /// + public Func MessageFormat { get; set; } + + /// + /// 日期格式化 + /// + public string DateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss.fffffff zzz dddd"; + + /// + /// 自定义日志筛选器 + /// + public Func WriteFilter { get; set; } + + /// + /// 自定义格式化日志处理程序 + /// + public Action WriteHandler { get; set; } + + /// + /// 显示跟踪/请求 Id + /// + public bool WithTraceId { get; set; } = false; + + /// + /// 显示堆栈框架(程序集和方法签名) + /// + public bool WithStackFrame { get; set; } = false; + + /// + /// 日志消息内容转换(如脱敏处理) + /// + public Func MessageProcess { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLogger.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLogger.cs new file mode 100644 index 000000000..6c6ab23a4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLogger.cs @@ -0,0 +1,144 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +using System.Diagnostics; + +namespace ThingsGateway.Logging; + +/// +/// 数据库日志记录器 +/// +/// https://docs.microsoft.com/zh-cn/dotnet/core/extensions/custom-logging-provider +[SuppressSniffer] +public sealed class DatabaseLogger : ILogger +{ + /// + /// 记录器类别名称 + /// + private readonly string _logName; + + /// + /// 数据库日志记录器提供器 + /// + private readonly DatabaseLoggerProvider _databaseLoggerProvider; + + /// + /// 日志配置选项 + /// + private readonly DatabaseLoggerOptions _options; + + /// + /// 构造函数 + /// + /// 记录器类别名称 + /// 数据库日志记录器提供器 + public DatabaseLogger(string logName, DatabaseLoggerProvider databaseLoggerProvider) + { + _logName = logName; + _databaseLoggerProvider = databaseLoggerProvider; + _options = databaseLoggerProvider.LoggerOptions; + } + + /// + /// 开始逻辑操作范围 + /// + /// 标识符类型参数 + /// 要写入的项/对象 + /// + public IDisposable BeginScope(TState state) + { + return _databaseLoggerProvider.ScopeProvider?.Push(state); + } + + /// + /// 检查是否已启用给定日志级别 + /// + /// 日志级别 + /// + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= _options.MinimumLevel; + } + + /// + /// 写入日志项 + /// + /// 标识符类型参数 + /// 日志级别 + /// 事件 Id + /// 要写入的项/对象 + /// 异常对象 + /// 日志格式化器 + /// + public void Log(LogLevel logLevel + , EventId eventId + , TState state + , Exception exception + , Func formatter) + { + // 判断日志级别是否有效 + if (!IsEnabled(logLevel)) return; + + // 检查日志格式化器 + if (formatter == null) throw new ArgumentNullException(nameof(formatter)); + + // 获取格式化后的消息 + var message = formatter(state, exception); + + // 日志消息内容转换(如脱敏处理) + if (_options.MessageProcess != null) + { + message = _options.MessageProcess(message); + } + + var logDateTime = _options.UseUtcTimestamp ? DateTime.UtcNow : DateTime.Now; + var logMsg = new LogMessage(_logName, logLevel, eventId, message, exception, null, state, logDateTime, Environment.CurrentManagedThreadId, _options.UseUtcTimestamp, App.GetTraceId()) + { + // 设置日志上下文 + Context = Penetrates.SetLogContext(_databaseLoggerProvider.ScopeProvider, _options.IncludeScopes) + }; + + // 判断是否自定义了日志筛选器,如果是则检查是否符合条件 + if (_options.WriteFilter?.Invoke(logMsg) == false) + { + logMsg.Context?.Dispose(); + return; + } + + // 设置日志消息模板 + logMsg.Message = _options.MessageFormat != null + ? _options.MessageFormat(logMsg) + : Penetrates.OutputStandardMessage(logMsg, _options.DateFormat, withTraceId: _options.WithTraceId, withStackFrame: _options.WithStackFrame); + + // 空检查 + if (logMsg.Message is null) + { + logMsg.Context?.Dispose(); + return; + } + + // 判断是否忽略循环输出日志,解决数据库日志提供程序中也输出日志导致写入递归问题 + if (_options.IgnoreReferenceLoop) + { + var stackTrace = new StackTrace(); + if (stackTrace.GetFrames().Any(u => u.HasMethod() && typeof(IDatabaseLoggingWriter).IsAssignableFrom(u.GetMethod().DeclaringType))) + { + logMsg.Context?.Dispose(); + return; + } + } + + // 写入日志队列 + _databaseLoggerProvider.WriteToQueue(logMsg); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLoggerOptions.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLoggerOptions.cs new file mode 100644 index 000000000..a3b4511aa --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLoggerOptions.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 数据库记录器配置选项 +/// +[SuppressSniffer] +public sealed class DatabaseLoggerOptions +{ + /// + /// 自定义日志筛选器 + /// + public Func WriteFilter { get; set; } + + /// + /// 最低日志记录级别 + /// + public LogLevel MinimumLevel { get; set; } = LogLevel.Trace; + + /// + /// 自定义日志消息格式化程序 + /// + public Func MessageFormat { get; set; } + + /// + /// 自定义数据库日志写入错误程序 + /// + /// 主要解决日志在写入过程出现异常问题 + /// + /// options.HandleWriteError = (err) => { + /// // do anything + /// }; + /// + public Action HandleWriteError { get; set; } + + /// + /// 是否使用 UTC 时间戳,默认 false + /// + public bool UseUtcTimestamp { get; set; } + + /// + /// 日期格式化 + /// + public string DateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss.fffffff zzz dddd"; + + /// + /// 是否启用日志上下文 + /// + public bool IncludeScopes { get; set; } = true; + + /// + /// 忽略日志循环输出 + /// + /// 对性能有些许影响 + public bool IgnoreReferenceLoop { get; set; } = true; + + /// + /// 显示跟踪/请求 Id + /// + public bool WithTraceId { get; set; } = false; + + /// + /// 显示堆栈框架(程序集和方法签名) + /// + public bool WithStackFrame { get; set; } = false; + + /// + /// 日志消息内容转换(如脱敏处理) + /// + public Func MessageProcess { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLoggerProvider.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLoggerProvider.cs new file mode 100644 index 000000000..e9d3b6d29 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLoggerProvider.cs @@ -0,0 +1,190 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +using System.Collections.Concurrent; + +namespace ThingsGateway.Logging; + +/// +/// 数据库日志记录器提供程序 +/// +/// https://docs.microsoft.com/zh-cn/dotnet/core/extensions/custom-logging-provider +[SuppressSniffer, ProviderAlias("Database")] +public sealed class DatabaseLoggerProvider : ILoggerProvider, ISupportExternalScope +{ + /// + /// 存储多日志分类日志记录器 + /// + private readonly ConcurrentDictionary _databaseLoggers = new(); + + /// + /// 日志消息队列(线程安全) + /// + private readonly BlockingCollection _logMessageQueue = new(12000); + + /// + /// 日志作用域提供器 + /// + private IExternalScopeProvider _scopeProvider; + + /// + /// 数据库日志写入器作用域范围 + /// + internal IServiceScope _serviceScope; + + /// + /// 数据库日志写入器 + /// + private IDatabaseLoggingWriter _databaseLoggingWriter; + + /// + /// 长时间运行的后台任务 + /// + /// 实现不间断写入 + private Task _processQueueTask; + + /// + /// 构造函数 + /// + /// 数据库日志记录器配置选项 + public DatabaseLoggerProvider(DatabaseLoggerOptions databaseLoggerOptions) + { + LoggerOptions = databaseLoggerOptions; + } + + /// + /// 数据库日志记录器配置选项 + /// + internal DatabaseLoggerOptions LoggerOptions { get; private set; } + + /// + /// 日志作用域提供器 + /// + internal IExternalScopeProvider ScopeProvider => _scopeProvider; + + /// + /// 创建数据库日志记录器 + /// + /// 日志分类名 + /// + public ILogger CreateLogger(string categoryName) + { + return _databaseLoggers.GetOrAdd(categoryName, name => new DatabaseLogger(name, this)); + } + + /// + /// 设置作用域提供器 + /// + /// + public void SetScopeProvider(IExternalScopeProvider scopeProvider) + { + _scopeProvider = scopeProvider; + } + + /// + /// 释放非托管资源 + /// + /// 控制日志消息队列 + public void Dispose() + { + // 标记日志消息队列停止写入 + _logMessageQueue.CompleteAdding(); + + try + { + // 设置 1.5秒的缓冲时间,避免还有日志消息没有完成写入数据库中 + _processQueueTask?.Wait(1500); + } + catch (TaskCanceledException) { } + catch (AggregateException ex) when (ex.InnerExceptions.Count == 1 && ex.InnerExceptions[0] is TaskCanceledException) { } + catch { } + + // 清空数据库日志记录器 + _databaseLoggers.Clear(); + + // 释放数据库写入器作用域范围 + _serviceScope?.Dispose(); + } + + /// + /// 将日志消息写入队列中等待后台任务出队写入数据库 + /// + /// 结构化日志消息 + internal void WriteToQueue(LogMessage logMsg) + { + // 只有队列可持续入队才写入 + if (!_logMessageQueue.IsAddingCompleted) + { + try + { + _logMessageQueue.Add(logMsg); + return; + } + catch (InvalidOperationException) { } + catch { } + } + } + + /// + /// 设置服务提供器 + /// + /// + /// + internal void SetServiceProvider(IServiceProvider serviceProvider, Type databaseLoggingWriterType) + { + // 解析服务作用域工厂服务 + var serviceScopeFactory = serviceProvider.GetRequiredService(); + + // 创建服务作用域 + _serviceScope = serviceScopeFactory.CreateScope(); + + // 基于当前作用域创建数据库日志写入器 + _databaseLoggingWriter = _serviceScope.ServiceProvider.GetRequiredService(databaseLoggingWriterType) as IDatabaseLoggingWriter; + + // 创建长时间运行的后台任务,并将日志消息队列中数据写入存储中 + _processQueueTask = Task.Factory.StartNew(async state => await ((DatabaseLoggerProvider)state).ProcessQueueAsync().ConfigureAwait(false) + , this, TaskCreationOptions.LongRunning); + } + + /// + /// 将日志消息写入数据库中 + /// + /// + private async Task ProcessQueueAsync() + { + foreach (var logMsg in _logMessageQueue.GetConsumingEnumerable()) + { + try + { + // 调用数据库写入器写入数据库方法 + await _databaseLoggingWriter.WriteAsync(logMsg, _logMessageQueue.Count == 0).ConfigureAwait(false); + } + catch (Exception ex) + { + // 处理数据库写入错误 + if (LoggerOptions.HandleWriteError != null) + { + var databaseWriteError = new DatabaseWriteError(ex); + LoggerOptions.HandleWriteError(databaseWriteError); + } + // 这里不抛出异常,避免中断日志写入 + else { } + } + finally + { + logMsg.Context?.Dispose(); + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLoggerSettings.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLoggerSettings.cs new file mode 100644 index 000000000..593b26eb5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseLoggerSettings.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 数据库日志配置类 +/// +[SuppressSniffer] +public sealed class DatabaseLoggerSettings +{ + /// + /// 最低日志记录级别 + /// + public LogLevel MinimumLevel { get; set; } = LogLevel.Trace; + + /// + /// 是否使用 UTC 时间戳,默认 false + /// + public bool UseUtcTimestamp { get; set; } + + /// + /// 日期格式化 + /// + public string DateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss.fffffff zzz dddd"; + + /// + /// 是否启用日志上下文 + /// + public bool IncludeScopes { get; set; } = true; + + /// + /// 忽略日志循环输出 + /// + /// 对性能有些许影响 + public bool IgnoreReferenceLoop { get; set; } = true; + + /// + /// 显示跟踪/请求 Id + /// + public bool WithTraceId { get; set; } = false; + + /// + /// 显示堆栈框架(程序集和方法签名) + /// + public bool WithStackFrame { get; set; } = false; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseWriteError.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseWriteError.cs new file mode 100644 index 000000000..751cc11b7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/DatabaseWriteError.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Logging; + +/// +/// 数据库写入错误信息上下文 +/// +[SuppressSniffer] +public sealed class DatabaseWriteError +{ + /// + /// 构造函数 + /// + /// 异常对象 + internal DatabaseWriteError(Exception exception) + { + Exception = exception; + } + + /// + /// 引起数据库写入异常信息 + /// + public Exception Exception { get; private set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/IDatabaseLoggingWriter.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/IDatabaseLoggingWriter.cs new file mode 100644 index 000000000..5842ed90a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Database/IDatabaseLoggingWriter.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Logging; + +/// +/// 数据库日志写入器 +/// +public interface IDatabaseLoggingWriter +{ + /// + /// 写入数据库 + /// + /// 结构化日志消息 + /// 清除缓冲区 + /// + Task WriteAsync(LogMessage logMsg, bool flush); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Empty/EmptyLogger.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Empty/EmptyLogger.cs new file mode 100644 index 000000000..5377e470f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Empty/EmptyLogger.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 空日志记录器 +/// +/// https://docs.microsoft.com/zh-cn/dotnet/core/extensions/custom-logging-provider +[SuppressSniffer] +public sealed class EmptyLogger : ILogger +{ + /// + /// 开始逻辑操作范围 + /// + /// 标识符类型参数 + /// 要写入的项/对象 + /// + public IDisposable BeginScope(TState state) + { + return default; + } + + /// + /// 检查是否已启用给定日志级别 + /// + /// 日志级别 + /// + public bool IsEnabled(LogLevel logLevel) + { + return false; + } + + /// + /// 写入日志项 + /// + /// 标识符类型参数 + /// 日志级别 + /// 事件 Id + /// 要写入的项/对象 + /// 异常对象 + /// 日志格式化器 + /// + public void Log(LogLevel logLevel + , EventId eventId + , TState state + , Exception exception + , Func formatter) + { + // 判断日志级别是否有效 + if (!IsEnabled(logLevel)) return; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Empty/EmptyLoggerProvider.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Empty/EmptyLoggerProvider.cs new file mode 100644 index 000000000..1fc25e0cf --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Empty/EmptyLoggerProvider.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +using System.Collections.Concurrent; + +namespace ThingsGateway.Logging; + +/// +/// 空日志记录器提供程序 +/// +/// https://docs.microsoft.com/zh-cn/dotnet/core/extensions/custom-logging-provider +[SuppressSniffer, ProviderAlias("Empty")] +public sealed class EmptyLoggerProvider : ILoggerProvider +{ + /// + /// 存储多日志分类日志记录器 + /// + private readonly ConcurrentDictionary _emptyLoggers = new(); + + /// + /// 创建空日志记录器 + /// + /// 日志分类名 + /// + public ILogger CreateLogger(string categoryName) + { + return _emptyLoggers.GetOrAdd(categoryName, name => new EmptyLogger()); + } + + /// + /// 释放非托管资源 + /// + /// 控制日志消息队列 + public void Dispose() + { + // 清空空日志记录器 + _emptyLoggers.Clear(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLogger.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLogger.cs new file mode 100644 index 000000000..b50a9a455 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLogger.cs @@ -0,0 +1,131 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 文件日志记录器 +/// +/// https://docs.microsoft.com/zh-cn/dotnet/core/extensions/custom-logging-provider +[SuppressSniffer] +public sealed class FileLogger : ILogger +{ + /// + /// 记录器类别名称 + /// + private readonly string _logName; + + /// + /// 文件日志记录器提供器 + /// + private readonly FileLoggerProvider _fileLoggerProvider; + + /// + /// 日志配置选项 + /// + private readonly FileLoggerOptions _options; + + /// + /// 构造函数 + /// + /// 记录器类别名称 + /// 文件日志记录器提供器 + public FileLogger(string logName, FileLoggerProvider fileLoggerProvider) + { + _logName = logName; + _fileLoggerProvider = fileLoggerProvider; + _options = fileLoggerProvider.LoggerOptions; + } + + /// + /// 开始逻辑操作范围 + /// + /// 标识符类型参数 + /// 要写入的项/对象 + /// + public IDisposable BeginScope(TState state) + { + return _fileLoggerProvider.ScopeProvider?.Push(state); + } + + /// + /// 检查是否已启用给定日志级别 + /// + /// 日志级别 + /// + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= _options.MinimumLevel; + } + + /// + /// 写入日志项 + /// + /// 标识符类型参数 + /// 日志级别 + /// 事件 Id + /// 要写入的项/对象 + /// 异常对象 + /// 日志格式化器 + /// + public void Log(LogLevel logLevel + , EventId eventId + , TState state + , Exception exception + , Func formatter) + { + // 判断日志级别是否有效 + if (!IsEnabled(logLevel)) return; + + // 检查日志格式化器 + if (formatter == null) throw new ArgumentNullException(nameof(formatter)); + + // 获取格式化后的消息 + var message = formatter(state, exception); + + // 日志消息内容转换(如脱敏处理) + if (_options.MessageProcess != null) + { + message = _options.MessageProcess(message); + } + + var logDateTime = _options.UseUtcTimestamp ? DateTime.UtcNow : DateTime.Now; + var logMsg = new LogMessage(_logName, logLevel, eventId, message, exception, null, state, logDateTime, Environment.CurrentManagedThreadId, _options.UseUtcTimestamp, App.GetTraceId()) + { + // 设置日志上下文 + Context = Penetrates.SetLogContext(_fileLoggerProvider.ScopeProvider, _options.IncludeScopes) + }; + + // 判断是否自定义了日志筛选器,如果是则检查是否符合条件 + if (_options.WriteFilter?.Invoke(logMsg) == false) + { + logMsg.Context?.Dispose(); + return; + } + + // 设置日志消息模板 + logMsg.Message = _options.MessageFormat != null + ? _options.MessageFormat(logMsg) + : Penetrates.OutputStandardMessage(logMsg, _options.DateFormat, withTraceId: _options.WithTraceId, withStackFrame: _options.WithStackFrame); + + // 空检查 + if (logMsg.Message is null) + { + logMsg.Context?.Dispose(); + return; + } + + // 写入日志队列 + _fileLoggerProvider.WriteToQueue(logMsg); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggerOptions.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggerOptions.cs new file mode 100644 index 000000000..5d9291e88 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggerOptions.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 文件日志记录器配置选项 +/// +[SuppressSniffer] +public sealed class FileLoggerOptions +{ + /// + /// 追加到已存在日志文件或覆盖它们 + /// + public bool Append { get; set; } = true; + + /// + /// 控制每一个日志文件最大存储大小,默认无限制,单位是 B,也就是 1024 才等于 1KB + /// + /// 如果指定了该值,那么日志文件大小超出了该配置就会创建的日志文件,新创建的日志文件命名规则:文件名+[递增序号].log + public long FileSizeLimitBytes { get; set; } = 0; + + /// + /// 控制最大创建的日志文件数量,默认无限制,配合 使用 + /// + /// 如果指定了该值,那么超出该值将从最初日志文件中从头写入覆盖 + public int MaxRollingFiles { get; set; } = 0; + + /// + /// 最低日志记录级别 + /// + public LogLevel MinimumLevel { get; set; } = LogLevel.Trace; + + /// + /// 是否使用 UTC 时间戳,默认 false + /// + public bool UseUtcTimestamp { get; set; } + + /// + /// 自定义日志消息格式化程序 + /// + public Func MessageFormat { get; set; } + + /// + /// 自定义日志筛选器 + /// + public Func WriteFilter { get; set; } + + /// + /// 自定义日志文件名格式化程序(规则) + /// + /// + /// options.FileNameRule = (fileName) => { + /// return String.Format(Path.GetFileNameWithoutExtension(fileName) + "_{0:yyyy}-{0:MM}-{0:dd}" + Path.GetExtension(fileName), DateTime.UtcNow); + /// + /// // 或者每天创建一个文件 + /// // return String.Format(fileName, DateTime.UtcNow); + /// } + /// + public Func FileNameRule { get; set; } + + /// + /// 自定义日志文件写入错误程序 + /// + /// 主要解决日志在写入过程中文件被打开或其他应用程序占用的情况,一旦出现上述情况可创建备用日志文件继续写入 + /// + /// options.HandleWriteError = (err) => { + /// err.UseRollbackFileName(Path.GetFileNameWithoutExtension(err.CurrentFileName)+ "_alt" + Path.GetExtension(err.CurrentFileName)); + /// }; + /// + public Action HandleWriteError { get; set; } + + /// + /// 日期格式化 + /// + public string DateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss.fffffff zzz dddd"; + + /// + /// 是否启用日志上下文 + /// + public bool IncludeScopes { get; set; } = true; + + /// + /// 显示跟踪/请求 Id + /// + public bool WithTraceId { get; set; } = false; + + /// + /// 显示堆栈框架(程序集和方法签名) + /// + public bool WithStackFrame { get; set; } = false; + + /// + /// 日志消息内容转换(如脱敏处理) + /// + public Func MessageProcess { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggerProvider.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggerProvider.cs new file mode 100644 index 000000000..fc71a69a3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggerProvider.cs @@ -0,0 +1,187 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +using System.Collections.Concurrent; + +namespace ThingsGateway.Logging; + +/// +/// 文件日志记录器提供程序 +/// +/// https://docs.microsoft.com/zh-cn/dotnet/core/extensions/custom-logging-provider +[SuppressSniffer, ProviderAlias("File")] +public sealed class FileLoggerProvider : ILoggerProvider, ISupportExternalScope +{ + /// + /// 存储多日志分类日志记录器 + /// + private readonly ConcurrentDictionary _fileLoggers = new(); + + /// + /// 日志消息队列(线程安全) + /// + private readonly BlockingCollection _logMessageQueue = new(12000); + + /// + /// 日志作用域提供器 + /// + private IExternalScopeProvider _scopeProvider; + + /// + /// 记录日志所有滚动文件名 + /// + /// 只有 MaxRollingFiles 和 FileSizeLimitBytes 大于 0 有效 + internal readonly ConcurrentDictionary _rollingFileNames = new(); + + /// + /// 文件日志写入器 + /// + private readonly FileLoggingWriter _fileLoggingWriter; + + /// + /// 长时间运行的后台任务 + /// + /// 实现不间断写入 + private readonly Task _processQueueTask; + + /// + /// 构造函数 + /// + /// 日志文件名 + public FileLoggerProvider(string fileName) + : this(fileName, true) + { + } + + /// + /// 构造函数 + /// + /// 日志文件名 + /// 追加到已存在日志文件或覆盖它们 + public FileLoggerProvider(string fileName, bool append) + : this(fileName, new FileLoggerOptions() { Append = append }) + { + } + + /// + /// 构造函数 + /// + /// 日志文件名 + /// 文件日志记录器配置选项 + public FileLoggerProvider(string fileName, FileLoggerOptions fileLoggerOptions) + { + // 支持文件名嵌入系统环境变量,格式为:%SystemDrive%,%SystemRoot%,处理 Windows 和 Linux 路径分隔符不一致问题 + FileName = Environment.ExpandEnvironmentVariables(fileName).Replace('\\', '/'); + LoggerOptions = fileLoggerOptions; + + // 创建文件日志写入器 + _fileLoggingWriter = new FileLoggingWriter(this); + + // 创建长时间运行的后台任务,并将日志消息队列中数据写入文件中 + _processQueueTask = Task.Factory.StartNew(async state => await ((FileLoggerProvider)state).ProcessQueueAsync().ConfigureAwait(false) + , this, TaskCreationOptions.LongRunning); + } + + /// + /// 文件名 + /// + internal string FileName; + + /// + /// 文件日志记录器配置选项 + /// + internal FileLoggerOptions LoggerOptions { get; private set; } + + /// + /// 日志作用域提供器 + /// + internal IExternalScopeProvider ScopeProvider => _scopeProvider; + + /// + /// 创建文件日志记录器 + /// + /// 日志分类名 + /// + public ILogger CreateLogger(string categoryName) + { + return _fileLoggers.GetOrAdd(categoryName, name => new FileLogger(name, this)); + } + + /// + /// 设置作用域提供器 + /// + /// + public void SetScopeProvider(IExternalScopeProvider scopeProvider) + { + _scopeProvider = scopeProvider; + } + + /// + /// 释放非托管资源 + /// + /// 控制日志消息队列 + public void Dispose() + { + // 标记日志消息队列停止写入 + _logMessageQueue.CompleteAdding(); + + try + { + // 设置 1.5秒的缓冲时间,避免还有日志消息没有完成写入文件中 + _processQueueTask?.Wait(1500); + } + catch (TaskCanceledException) { } + catch (AggregateException ex) when (ex.InnerExceptions.Count == 1 && ex.InnerExceptions[0] is TaskCanceledException) { } + catch { } + + // 清空文件日志记录器 + _fileLoggers.Clear(); + + // 清空滚动文件名记录器 + _rollingFileNames.Clear(); + + // 释放内部文件写入器 + Task.Run(_fileLoggingWriter.CloseAsync); + } + + /// + /// 将日志消息写入队列中等待后台任务出队写入文件 + /// + /// 日志消息 + internal void WriteToQueue(LogMessage logMsg) + { + // 只有队列可持续入队才写入 + if (!_logMessageQueue.IsAddingCompleted) + { + try + { + _logMessageQueue.Add(logMsg); + return; + } + catch (InvalidOperationException) { } + catch { } + } + } + + /// + /// 将日志消息写入文件中 + /// + /// + private async Task ProcessQueueAsync() + { + foreach (var logMsg in _logMessageQueue.GetConsumingEnumerable()) + { + await _fileLoggingWriter.WriteAsync(logMsg, _logMessageQueue.Count == 0).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggerSettings.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggerSettings.cs new file mode 100644 index 000000000..ccf56652c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggerSettings.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 文件日志配置类 +/// +[SuppressSniffer] +public sealed class FileLoggerSettings +{ + /// + /// 日志文件完整路径或文件名,推荐 .log 作为拓展名 + /// + public string FileName { get; set; } = "application.log"; + + /// + /// 追加到已存在日志文件或覆盖它们 + /// + public bool Append { get; set; } = true; + + /// + /// 控制每一个日志文件最大存储大小,默认无限制,单位是 B,也就是 1024 才等于 1KB + /// + /// 如果指定了该值,那么日志文件大小超出了该配置就会创建的日志文件,新创建的日志文件命名规则:文件名+[递增序号].log + public long FileSizeLimitBytes { get; set; } = 0; + + /// + /// 控制最大创建的日志文件数量,默认无限制,配合 使用 + /// + /// 如果指定了该值,那么超出该值将从最初日志文件中从头写入覆盖 + public int MaxRollingFiles { get; set; } = 0; + + /// + /// 最低日志记录级别 + /// + public LogLevel MinimumLevel { get; set; } = LogLevel.Trace; + + /// + /// 是否使用 UTC 时间戳,默认 false + /// + public bool UseUtcTimestamp { get; set; } + + /// + /// 日期格式化 + /// + public string DateFormat { get; set; } = "yyyy-MM-dd HH:mm:ss.fffffff zzz dddd"; + + /// + /// 是否启用日志上下文 + /// + public bool IncludeScopes { get; set; } = true; + + /// + /// 显示跟踪/请求 Id + /// + public bool WithTraceId { get; set; } = false; + + /// + /// 显示堆栈框架(程序集和方法签名) + /// + public bool WithStackFrame { get; set; } = false; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggingWriter.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggingWriter.cs new file mode 100644 index 000000000..8bf992b62 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileLoggingWriter.cs @@ -0,0 +1,363 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Logging; + +/// +/// 文件日志写入器 +/// +internal sealed class FileLoggingWriter +{ + /// + /// 文件日志记录器提供程序 + /// + private readonly FileLoggerProvider _fileLoggerProvider; + + /// + /// 日志配置选项 + /// + private readonly FileLoggerOptions _options; + + /// + /// 日志文件名 + /// + private string _fileName; + + /// + /// 文件流 + /// + private FileStream _fileStream; + + /// + /// 文本写入器 + /// + private StreamWriter _textWriter; + + /// + /// 缓存上次返回的基本日志文件名,避免重复解析 + /// + private string __LastBaseFileName = null; + + /// + /// 判断是否启动滚动日志功能 + /// + private readonly bool _isEnabledRollingFiles; + + /// + /// 构造函数 + /// + /// 文件日志记录器提供程序 + internal FileLoggingWriter(FileLoggerProvider fileLoggerProvider) + { + _fileLoggerProvider = fileLoggerProvider; + _options = fileLoggerProvider.LoggerOptions; + _isEnabledRollingFiles = _options.MaxRollingFiles > 0 && _options.FileSizeLimitBytes > 0; + + // 解析当前写入日志的文件名 + GetCurrentFileName(); + + // 打开文件并持续写入,调用 .Wait() 确保文件流创建完毕 + Task.Run(async () => await OpenFileAsync(_options.Append).ConfigureAwait(false)).Wait(); + } + + /// + /// 获取日志基础文件名 + /// + /// 日志文件名 + private string GetBaseFileName() + { + var fileName = _fileLoggerProvider.FileName; + + // 如果配置了日志文件名格式化程序,则先处理再返回 + if (_options.FileNameRule != null) + fileName = _options.FileNameRule(fileName); + + return fileName; + } + + /// + /// 解析当前写入日志的文件名 + /// + private void GetCurrentFileName() + { + // 获取日志基础文件名并将其缓存 + var baseFileName = GetBaseFileName(); + __LastBaseFileName = baseFileName; + + // 是否配置了日志文件最大存储大小 + if (_options.FileSizeLimitBytes > 0) + { + // 定义文件查找通配符 + var logFileMask = Path.GetFileNameWithoutExtension(baseFileName) + "*" + Path.GetExtension(baseFileName); + + // 获取文件路径 + var logDirName = Path.GetDirectoryName(baseFileName); + + // 如果没有配置文件路径则默认放置根目录 + if (string.IsNullOrEmpty(logDirName)) logDirName = Directory.GetCurrentDirectory(); + + // 在当前目录下根据文件通配符查找所有匹配的文件 + var logFiles = Directory.Exists(logDirName) + ? Directory.GetFiles(logDirName, logFileMask, SearchOption.TopDirectoryOnly) + : Array.Empty(); + + // 处理已有日志文件存在情况 + if (logFiles.Length > 0) + { + // 根据文件名和最后更新时间获取最近操作的文件 + var lastFileInfo = logFiles + .Select(fName => new FileInfo(fName)) + .OrderByDescending(fInfo => fInfo.Name) + .OrderByDescending(fInfo => fInfo.LastWriteTime).First(); + + _fileName = lastFileInfo.FullName; + } + // 没有任何匹配的日志文件直接使用当前基础文件名 + else _fileName = baseFileName; + } + else _fileName = baseFileName; + } + + /// + /// 获取下一个匹配的日志文件名 + /// + /// 只有配置了 有效 + /// 新的文件名 + private string GetNextFileName() + { + // 获取日志基础文件名 + var baseFileName = GetBaseFileName(); + + // 如果文件不存在或没有达到 FileSizeLimitBytes 限制大小,则返回基础文件名 + if (!System.IO.File.Exists(baseFileName) + || _options.FileSizeLimitBytes <= 0 + || new FileInfo(baseFileName).Length < _options.FileSizeLimitBytes) return baseFileName; + + // 获取日志基础文件名和当前日志文件名 + var currentFileIndex = 0; + var baseFileNameOnly = Path.GetFileNameWithoutExtension(baseFileName); + var currentFileNameOnly = Path.GetFileNameWithoutExtension(_fileName); + + // 解析日志文件名【递增】部分 + var suffix = currentFileNameOnly[baseFileNameOnly.Length..]; + if (suffix.Length > 0 && int.TryParse(suffix, out var parsedIndex)) + { + currentFileIndex = parsedIndex; + } + + // 【递增】部分 +1 + var nextFileIndex = currentFileIndex + 1; + + // 如果配置了最大【递增】数,则超出自动从头开始(覆盖写入) + if (_options.MaxRollingFiles > 0) + { + nextFileIndex %= _options.MaxRollingFiles; + } + + // 返回下一个匹配的日志文件名(完整路径) + var nextFileName = baseFileNameOnly + (nextFileIndex > 0 ? nextFileIndex.ToString() : "") + Path.GetExtension(baseFileName); + return Path.Combine(Path.GetDirectoryName(baseFileName), nextFileName); + } + + /// + /// 打开文件 + /// + /// + /// + private Task OpenFileAsync(bool append) + { + try + { + CreateFileStream(); + } + catch (Exception ex) + { + // 处理文件写入错误 + if (_options.HandleWriteError != null) + { + var fileWriteError = new FileWriteError(_fileName, ex); + _options.HandleWriteError(fileWriteError); + + // 如果配置了备用文件名,则重新写入 + if (fileWriteError.RollbackFileName != null) + { + _fileLoggerProvider.FileName = fileWriteError.RollbackFileName; + + // 递归操作,直到应用程序停止 + GetCurrentFileName(); + CreateFileStream(); + } + } + // 其他直接抛出异常 + else throw; + } + + // 初始化文本写入器 + _textWriter = new StreamWriter(_fileStream); + + // 创建文件流 + void CreateFileStream() + { + var fileInfo = new FileInfo(_fileName); + + // 判断文件目录是否存在,不存在则自动创建 + fileInfo.Directory.Create(); + + // 创建文件流,采用共享锁方式 + _fileStream = new FileStream(_fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite, 4096, FileOptions.WriteThrough); + + // 删除超出滚动日志限制的文件 + DropFilesIfOverLimit(fileInfo); + + // 判断是否追加还是覆盖 + if (append) _fileStream.Seek(0, SeekOrigin.End); + else _fileStream.SetLength(0); + } + + return Task.CompletedTask; + } + + /// + /// 判断是否需要创建新文件写入 + /// + /// + private async Task CheckForNewLogFileAsync() + { + var openNewFile = false; + if (isMaxFileSizeThresholdReached() || isBaseFileNameChanged()) + openNewFile = true; + + // 重新创建新文件并写入 + if (openNewFile) + { + await CloseAsync().ConfigureAwait(false); + + // 计算新文件名 + _fileName = GetNextFileName(); + + // 打开新文件并写入 + await OpenFileAsync(false).ConfigureAwait(false); + } + + // 是否超出限制的最大大小 + bool isMaxFileSizeThresholdReached() => _options.FileSizeLimitBytes > 0 + && _fileStream.Length > _options.FileSizeLimitBytes; + + // 是否重新自定义了文件名 + bool isBaseFileNameChanged() + { + if (_options.FileNameRule != null) + { + var baseFileName = GetBaseFileName(); + + if (baseFileName != __LastBaseFileName) + { + __LastBaseFileName = baseFileName; + return true; + } + + return false; + } + + return false; + } + } + + /// + /// 删除超出滚动日志限制的文件 + /// + /// + private void DropFilesIfOverLimit(FileInfo fileInfo) + { + // 判断是否启用滚动文件功能 + if (!_isEnabledRollingFiles) return; + + // 处理 Windows 和 Linux 路径分隔符不一致问题 + var fName = fileInfo.FullName.Replace('\\', '/'); + + // 将当前文件名存储到集合中 + var succeed = _fileLoggerProvider._rollingFileNames.TryAdd(fName, fileInfo); + + // 判断超出限制的文件自动删除 + if (succeed && _fileLoggerProvider._rollingFileNames.Count > _options.MaxRollingFiles) + { + // 根据最后写入时间删除过时日志 + var dropFiles = _fileLoggerProvider._rollingFileNames + .OrderBy(u => u.Value.LastWriteTimeUtc) + .Take(_fileLoggerProvider._rollingFileNames.Count - _options.MaxRollingFiles); + + // 遍历所有需要删除的文件 + foreach (var rollingFile in dropFiles) + { + var removeSucceed = _fileLoggerProvider._rollingFileNames.TryRemove(rollingFile.Key, out _); + if (!removeSucceed) continue; + + // 执行删除 + Task.Run(() => + { + if (File.Exists(rollingFile.Key)) File.Delete(rollingFile.Key); + }); + } + } + } + + /// + /// 写入文件 + /// + /// 日志消息 + /// + /// + internal async Task WriteAsync(LogMessage logMsg, bool flush) + { + if (_textWriter == null) return; + + try + { + await CheckForNewLogFileAsync().ConfigureAwait(false); + await _textWriter.WriteLineAsync(logMsg.Message).ConfigureAwait(false); + + if (flush) + { + await _textWriter.FlushAsync().ConfigureAwait(false); + } + } + catch (Exception ex) + { + // 处理文件写入错误 + if (_options.HandleWriteError != null) + { + var fileWriteError = new FileWriteError(_fileName, ex); + _options.HandleWriteError(fileWriteError); + } + } + finally + { + logMsg.Context?.Dispose(); + } + } + + /// + /// 关闭文本写入器并释放 + /// + /// + internal async Task CloseAsync() + { + if (_textWriter == null) return; + + var textloWriter = _textWriter; + _textWriter = null; + + await textloWriter.DisposeAsync().ConfigureAwait(false); + await _fileStream.DisposeAsync().ConfigureAwait(false); + + _fileStream = null; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileWriteError.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileWriteError.cs new file mode 100644 index 000000000..d14ee95bf --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/File/FileWriteError.cs @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Logging; + +/// +/// 文件写入错误信息上下文 +/// +[SuppressSniffer] +public sealed class FileWriteError +{ + /// + /// 构造函数 + /// + /// 当前日志文件名 + /// 异常对象 + internal FileWriteError(string currentFileName, Exception exception) + { + CurrentFileName = currentFileName; + Exception = exception; + } + + /// + /// 当前日志文件名 + /// + public string CurrentFileName { get; private set; } + + /// + /// 引起文件写入异常信息 + /// + public Exception Exception { get; private set; } + + /// + /// 备用日志文件名 + /// + internal string RollbackFileName { get; private set; } + + /// + /// 配置日志文件写入错误后新的备用日志文件名 + /// + /// 备用日志文件名 + public void UseRollbackFileName(string rollbackFileName) + { + RollbackFileName = rollbackFileName; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/LogContext.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/LogContext.cs new file mode 100644 index 000000000..38f66345a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/LogContext.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Logging; + +/// +/// 日志上下文 +/// +[SuppressSniffer] +public sealed class LogContext : IDisposable +{ + + /// + /// 日志上下文数据 + /// + public IDictionary Properties { get; set; } + + /// + /// 原生日志上下文数据 + /// + public IList Scopes { get; set; } + + /// + public void Dispose() + { + Properties?.Clear(); + Scopes?.Clear(); + Properties = null; + Scopes = null; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/LogMessage.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/LogMessage.cs new file mode 100644 index 000000000..20da875fa --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/LogMessage.cs @@ -0,0 +1,125 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 日志结构化消息 +/// +[SuppressSniffer] +public struct LogMessage +{ + /// + /// 构造函数 + /// + /// 记录器类别名称 + /// 日志级别 + /// 事件 Id + /// 日志消息 + /// 异常对象 + /// 日志上下文 + /// 当前状态值 + /// 日志记录时间 + /// 线程 Id + /// 是否使用 UTC 时间戳 + /// 请求/跟踪 Id + internal LogMessage(string logName + , LogLevel logLevel + , EventId eventId + , string message + , Exception exception + , LogContext context + , object state + , DateTime logDateTime + , int threadId + , bool useUtcTimestamp + , string traceId) + { + LogName = logName; + Message = message; + LogLevel = logLevel; + EventId = eventId; + Exception = exception; + Context = context; + State = state; + LogDateTime = logDateTime; + ThreadId = threadId; + UseUtcTimestamp = useUtcTimestamp; + TraceId = traceId; + } + + /// + /// 记录器类别名称 + /// + public string LogName { get; } + + /// + /// 日志级别 + /// + public LogLevel LogLevel { get; } + + /// + /// 事件 Id + /// + public EventId EventId { get; } + + /// + /// 日志消息 + /// + public string Message { get; internal set; } + + /// + /// 异常对象 + /// + public Exception Exception { get; } + + /// + /// 当前状态值 + /// + /// 可以是任意类型 + public object State { get; } + + /// + /// 日志记录时间 + /// + public DateTime LogDateTime { get; } + + /// + /// 线程 Id + /// + public int ThreadId { get; } + + /// + /// 是否使用 UTC 时间戳 + /// + public bool UseUtcTimestamp { get; } + + /// + /// 请求/跟踪 Id + /// + public string TraceId { get; } + + /// + /// 日志上下文 + /// + public LogContext Context { get; set; } + + /// + /// 重写默认输出 + /// + /// + public override readonly string ToString() + { + return Penetrates.OutputStandardMessage(this); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/ContractResolverTypes.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/ContractResolverTypes.cs new file mode 100644 index 000000000..744b576af --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/ContractResolverTypes.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Logging; + +/// +/// LoggingMonitor 序列化属性命名规则选项 +/// +[SuppressSniffer] +public enum ContractResolverTypes +{ + /// + /// CamelCase 小驼峰 + /// + /// 默认值 + CamelCase = 0, + + /// + /// 保持原样 + /// + Default = 1 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/JsonBehavior.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/JsonBehavior.cs new file mode 100644 index 000000000..5dfbdf723 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/JsonBehavior.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel; + +namespace ThingsGateway.Logging; + +/// +/// LoggingMonitor JSON 输出行为 +/// +[SuppressSniffer] +public enum JsonBehavior +{ + /// + /// 不输出 JSON 格式 + /// + /// 默认值,输出文本日志 + [Description("不输出 JSON 格式")] + None = 0, + + /// + /// 只输出 JSON 格式 + /// + [Description("只输出 JSON 格式")] + OnlyJson = 1, + + /// + /// 输出 JSON 格式和文本日志 + /// + [Description("输出 JSON 格式和文本日志")] + All = 2 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/JsonElementConverter.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/JsonElementConverter.cs new file mode 100644 index 000000000..1a3da7e06 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/JsonElementConverter.cs @@ -0,0 +1,64 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +using System.Text.Encodings.Web; + +using ThingsGateway.JsonSerialization; + +namespace ThingsGateway.Logging; + +/// +/// 解决 JsonElement 问题 +/// +[SuppressSniffer] +public class JsonElementConverter : JsonConverter +{ + private static readonly System.Text.Json.JsonSerializerOptions CachedJsonSerializerOptions = new System.Text.Json.JsonSerializerOptions() + { + ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + static JsonElementConverter() + { + CachedJsonSerializerOptions.Converters.Add(new SystemTextJsonLongToStringJsonConverter()); + CachedJsonSerializerOptions.Converters.Add(new SystemTextJsonNullableLongToStringJsonConverter()); + } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + /// + /// + public override System.Text.Json.JsonElement ReadJson(JsonReader reader, Type objectType, System.Text.Json.JsonElement existingValue, bool hasExistingValue, JsonSerializer serializer) + { + var value = JValue.ReadFrom(reader).Value(); + return (System.Text.Json.JsonElement)System.Text.Json.JsonSerializer.Deserialize(value); + } + + /// + /// 序列化 + /// + /// + /// + /// + public override void WriteJson(JsonWriter writer, System.Text.Json.JsonElement value, JsonSerializer serializer) + { + serializer.Serialize(writer, System.Text.Json.JsonSerializer.Serialize(value, CachedJsonSerializerOptions)); + } +} diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/Logging.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/Logging.cs new file mode 100644 index 000000000..d810ccad5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/Logging.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace System.Logging; + +/// +/// LoggingMonitor 日志拓展默认分类名 +/// +internal sealed class LoggingMonitor +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorAttribute.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorAttribute.cs new file mode 100644 index 000000000..8de7cd80d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorAttribute.cs @@ -0,0 +1,1138 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +using System.ComponentModel; +using System.Diagnostics; +using System.Logging; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Claims; +using System.Text; +using System.Text.Json; + +using ThingsGateway; +using ThingsGateway.DataValidation; +using ThingsGateway.Extensions; +using ThingsGateway.FriendlyException; +using ThingsGateway.Logging; +using ThingsGateway.Templates; +using ThingsGateway.UnifyResult; + +namespace System; + +/// +/// 强大的日志监听器 +/// +/// 主要用于将请求的信息打印出来 +[SuppressSniffer, AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = true, AllowMultiple = false)] +public sealed class LoggingMonitorAttribute : Attribute, IAsyncActionFilter, IAsyncPageFilter, IOrderedFilter +{ + /// + /// 过滤器排序 + /// + private const int FilterOrder = -2000; + + /// + /// 排序属性 + /// + public int Order => FilterOrder; + + /// + /// 构造函数 + /// + public LoggingMonitorAttribute() + : this(new LoggingMonitorSettings()) + { + } + + /// + /// 构造函数 + /// + /// + internal LoggingMonitorAttribute(LoggingMonitorSettings settings) + { + Settings = settings; + } + + /// + /// 日志标题 + /// + public string Title { get; set; } = "Logging Monitor"; + + /// + /// 是否记录返回值 + /// + /// bool 类型,默认输出 + public object WithReturnValue { get; set; } = null; + + /// + /// 设置返回值阈值 + /// + /// 配置返回值字符串阈值,超过这个阈值将截断,默认全量输出 + public object ReturnValueThreshold { get; set; } = null; + + /// + /// 配置 Json 输出行为 + /// + public object JsonBehavior { get; set; } = null; + + /// + /// 配置序列化忽略的属性名称 + /// + public string[] IgnorePropertyNames { get; set; } + + /// + /// 配置序列化忽略的属性类型 + /// + public Type[] IgnorePropertyTypes { get; set; } + + /// + /// JSON 输出格式化 + /// + /// bool 类型,默认输出 + public object JsonIndented { get; set; } = null; + + /// + /// 是否处理 Long 转 String + /// + /// bool 类型,默认 false + public object LongTypeConverter { get; set; } = null; + + /// + /// 序列化属性命名规则(返回值) + /// + public object ContractResolver { get; set; } = null; + + /// + /// 配置信息 + /// + private LoggingMonitorSettings Settings { get; set; } + + /// + /// 监视 Action 执行 + /// + /// + /// + /// + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + // 获取动作方法描述器 + var actionMethod = (context.ActionDescriptor as ControllerActionDescriptor)?.MethodInfo; + + // 处理 Blazor Server + if (actionMethod == null) + { + _ = await next.Invoke().ConfigureAwait(false); + return; + } + + await MonitorAsync(actionMethod, context.ActionArguments, context, next).ConfigureAwait(false); + } + + /// + /// 模型绑定拦截 + /// + /// + /// + public Task OnPageHandlerSelectionAsync(PageHandlerSelectedContext context) + { + return Task.CompletedTask; + } + + /// + /// 拦截请求 + /// + /// + /// + /// + /// + public async Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next) + { + // 获取动作方法描述器 + var actionMethod = context.HandlerMethod?.MethodInfo; + + // 处理 Blazor Server + if (actionMethod == null) + { + _ = await next.Invoke().ConfigureAwait(false); + return; + } + + await MonitorAsync(actionMethod, context.HandlerArguments, context, next).ConfigureAwait(false); + } + + /// + /// 生成 JWT 授权信息日志模板 + /// + /// + /// + /// + /// + private static List GenerateAuthorizationTemplate(Utf8JsonWriter writer, ClaimsPrincipal claimsPrincipal, StringValues authorization) + { + var templates = new List(); + + if (!claimsPrincipal.Claims.Any()) return templates; + + templates.AddRange(new[] + { + $"━━━━━━━━━━━━━━━ 授权信息 ━━━━━━━━━━━━━━━" + , $"##JWT Token## {authorization}" + , $"" + }); + + // 遍历身份信息 + writer.WritePropertyName("authorizationClaims"); + writer.WriteStartArray(); + foreach (var claim in claimsPrincipal.Claims) + { + var valueType = claim.ValueType.Replace("http://www.w3.org/2001/XMLSchema#", ""); + var value = claim.Value; + + // 解析时间戳并转换 + if (!string.IsNullOrEmpty(value) && (claim.Type == "iat" || claim.Type == "nbf" || claim.Type == "exp")) + { + var succeed = long.TryParse(value, out var seconds); + if (succeed) + { + value = $"{value} ({DateTimeOffset.FromUnixTimeSeconds(seconds).ToLocalTime():yyyy-MM-dd HH:mm:ss:ffff(zzz) dddd} L)"; + } + } + + writer.WriteStartObject(); + templates.Add($"##{claim.Type} ({valueType})## {value}"); + writer.WriteString("type", claim.Type); + writer.WriteString("valueType", valueType); + writer.WriteString("value", value); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + + return templates; + } + + /// + /// 生成请求头日志模板 + /// + /// + /// + /// + private static List GenerateRequestHeadersTemplate(Utf8JsonWriter writer, IHeaderDictionary headers) + { + var templates = new List(); + + if (!headers.Any()) return templates; + + templates.AddRange(new[] + { + $"━━━━━━━━━━━━━━━ 请求头信息 ━━━━━━━━━━━━━━━" + , $"" + }); + + // 遍历请求头列表 + writer.WritePropertyName("requestHeaders"); + writer.WriteStartArray(); + foreach (var (key, value) in headers) + { + writer.WriteStartObject(); + templates.Add($"##{key}## {value}"); + writer.WriteString("key", key); + writer.WriteString("value", value); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + + return templates; + } + + /// + /// 生成请求参数信息日志模板 + /// + /// + /// + /// + /// + /// + /// + private List GenerateParameterTemplate(Utf8JsonWriter writer, IDictionary parameterValues, MethodInfo method, StringValues contentType, LoggingMonitorMethod monitorMethod) + { + var templates = new List(); + writer.WritePropertyName("parameters"); + + if (parameterValues.Count == 0) + { + writer.WriteStartArray(); + writer.WriteEndArray(); + return templates; + } + + templates.AddRange(new[] + { + $"━━━━━━━━━━━━━━━ 参数列表 ━━━━━━━━━━━━━━━" + , $"##Content-Type## {contentType}" + , $"" + }); + + var parameters = method.GetParameters(); + + writer.WriteStartArray(); + foreach (var parameter in parameters) + { + // 判断是否禁用记录特定参数 + if (parameter.IsDefined(typeof(SuppressMonitorAttribute), false)) continue; + + // 排除标记 [FromServices] 的解析 + if (parameter.IsDefined(typeof(FromServicesAttribute), false)) continue; + + var name = parameter.Name; + var parameterType = parameter.ParameterType; + + _ = parameterValues.TryGetValue(name, out var value); + writer.WriteStartObject(); + writer.WriteString("name", name); + writer.WriteString("type", HandleGenericType(parameterType)); + + object rawValue = default; + + // 文件类型参数 + if (value is IFormFile || value is List) + { + writer.WritePropertyName("value"); + + // 单文件 + if (value is IFormFile formFile) + { + var fileSize = Math.Round(formFile.Length / 1024D); + templates.Add($"##{name} ({parameterType.Name})## [name]: {formFile.FileName}; [size]: {fileSize}KB; [content-type]: {formFile.ContentType}"); + + writer.WriteStartObject(); + writer.WriteString(name, formFile.Name); + writer.WriteString("fileName", formFile.FileName); + writer.WriteNumber("length", formFile.Length); + writer.WriteString("contentType", formFile.ContentType); + writer.WriteEndObject(); + + goto writeEndObject; + } + // 多文件 + else if (value is List formFiles) + { + writer.WriteStartArray(); + for (var i = 0; i < formFiles.Count; i++) + { + var file = formFiles[i]; + var size = Math.Round(file.Length / 1024D); + templates.Add($"##{name}[{i}] ({nameof(IFormFile)})## [name]: {file.FileName}; [size]: {size}KB; [content-type]: {file.ContentType}"); + + writer.WriteStartObject(); + writer.WriteString(name, file.Name); + writer.WriteString("fileName", file.FileName); + writer.WriteNumber("length", file.Length); + writer.WriteString("contentType", file.ContentType); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + + goto writeEndObject; + } + } + // 处理 byte[] 参数类型 + else if (value is byte[] byteArray) + { + writer.WritePropertyName("value"); + templates.Add($"##{name} ({parameterType.Name})## [Length]: {byteArray.Length}"); + + writer.WriteStartObject(); + writer.WriteNumber("length", byteArray.Length); + writer.WriteEndObject(); + + goto writeEndObject; + } + // 处理基元类型,字符串类型和空值 + else if (parameterType.IsPrimitive || value is string || value == null) + { + writer.WritePropertyName("value"); + rawValue = value; + + if (value == null) writer.WriteNullValue(); + else if (value is string str) writer.WriteStringValue(str); + else if (double.TryParse(value.ToString(), out var r)) writer.WriteNumberValue(r); + else writer.WriteStringValue(value.ToString()); + } + // 其他类型统一进行序列化 + else + { + writer.WritePropertyName("value"); + rawValue = TrySerializeObject(value, monitorMethod, out var succeed); + + if (succeed) writer.WriteRawValue(rawValue?.ToString()); + else writer.WriteNullValue(); + } + + templates.Add($"##{name} ({parameterType.Name})## {rawValue}"); + + writeEndObject: writer.WriteEndObject(); + } + writer.WriteEndArray(); + + return templates; + } + + /// + /// 生成返回值信息日志模板 + /// + /// + /// + /// + /// + /// + private List GenerateReturnInfomationTemplate(Utf8JsonWriter writer, dynamic resultContext, MethodInfo method, LoggingMonitorMethod monitorMethod) + { + var templates = new List(); + + object returnValue = null; + Type finalReturnType; + var result = resultContext.Result as IActionResult; + + // 解析返回值 + if (UnifyContext.CheckVaildResult(result, out var data)) + { + returnValue = data; + finalReturnType = data?.GetType(); + } + // 处理文件类型 + else if (result is FileResult fresult) + { + returnValue = new + { + FileName = fresult.FileDownloadName, + fresult.ContentType, + Length = fresult is FileContentResult cresult ? (object)cresult.FileContents.Length : null + }; + finalReturnType = fresult?.GetType(); + } + else finalReturnType = result?.GetType(); + + // 获取最终呈现值(字符串类型) + var displayValue = TrySerializeObject(returnValue, monitorMethod, out var succeed); + var originValue = displayValue; + + // 获取返回值阈值 + var threshold = GetReturnValueThreshold(monitorMethod); + if (threshold > 0) + { + displayValue = displayValue.Length <= threshold ? displayValue : displayValue[..threshold]; + } + + var returnTypeName = HandleGenericType(method.ReturnType); + var finalReturnTypeName = HandleGenericType(finalReturnType); + + // 获取请求返回的响应状态码 + var httpStatusCode = (resultContext as FilterContext).HttpContext.Response.StatusCode; + + templates.AddRange(new[] + { + $"━━━━━━━━━━━━━━━ 返回信息 ━━━━━━━━━━━━━━━" + , $"##HTTP响应状态码## {httpStatusCode}" + , $"##原始类型## {returnTypeName}" + , $"##最终类型## {finalReturnTypeName}" + , $"##最终返回值## {displayValue}" + }); + + writer.WritePropertyName("returnInformation"); + writer.WriteStartObject(); + writer.WriteString("type", finalReturnTypeName); + writer.WriteNumber(nameof(httpStatusCode), httpStatusCode); + writer.WriteString("actType", returnTypeName); + writer.WritePropertyName("value"); + if (succeed && method.ReturnType != typeof(void) && returnValue != null) + { + // 解决返回值被截断后 json 验证失败异常问题 + if (threshold > 0 && originValue != displayValue) + { + writer.WriteStringValue(displayValue); + } + else writer.WriteRawValue(displayValue); + } + else writer.WriteNullValue(); + + writer.WriteEndObject(); + + return templates; + } + + /// + /// 生成异常信息日志模板 + /// + /// + /// + /// 是否是验证异常 + /// + private static List GenerateExcetpionInfomationTemplate(Utf8JsonWriter writer, Exception exception, bool isValidationException) + { + var templates = new List(); + + if (exception == null) + { + writer.WritePropertyName("exception"); + writer.WriteNullValue(); + + writer.WritePropertyName("validation"); + writer.WriteNullValue(); + return templates; + } + + // 处理不是验证异常情况 + if (!isValidationException) + { + var exceptionTypeName = HandleGenericType(exception.GetType()); + templates.AddRange(new[] + { + $"━━━━━━━━━━━━━━━ 异常信息 ━━━━━━━━━━━━━━━" + , $"##类型## {exceptionTypeName}" + , $"##消息## {exception.Message}" + , $"##错误堆栈## {exception.StackTrace}" + }); + + writer.WritePropertyName("exception"); + writer.WriteStartObject(); + writer.WriteString("type", exceptionTypeName); + writer.WriteString("message", exception.Message); + writer.WriteString("stackTrace", exception.StackTrace?.ToString()); + writer.WriteEndObject(); + + writer.WritePropertyName("validation"); + writer.WriteNullValue(); + } + else + { + var friendlyException = exception as AppFriendlyException; + templates.AddRange(new[] + { + $"━━━━━━━━━━━━━━━ 业务异常 ━━━━━━━━━━━━━━━" + , $"##业务码## {friendlyException?.ErrorCode}" + , $"##业务码(原)## {friendlyException?.OriginErrorCode}" + , $"##业务消息## {friendlyException?.ErrorMessage}" + }); + + writer.WritePropertyName("exception"); + writer.WriteNullValue(); + + writer.WritePropertyName("validation"); + writer.WriteStartObject(); + writer.WriteString("errorCode", friendlyException?.ErrorCode?.ToString()); + writer.WriteString("originErrorCode", friendlyException?.OriginErrorCode?.ToString()); + writer.WriteString("message", friendlyException?.Message); + writer.WriteEndObject(); + } + + return templates; + } + + /// + /// 生成附加信息日志模板 + /// + /// + /// + /// + private static List GenerateExtraTemplate(Utf8JsonWriter writer, HttpContext httpContext) + { + if (!httpContext.Items.TryGetValue(LoggingMonitorContext.KEY, out var values)) + { + return null; + } + + if (values is not Dictionary extras || extras.Count == 0) + { + return null; + } + + var templates = new List(); + + templates.AddRange(new[] + { + $"━━━━━━━━━━━━━━━ 附加信息 ━━━━━━━━━━━━━━━" + }); + + // 遍历附加信息 + writer.WritePropertyName("loggingExtras"); + writer.WriteStartObject(); + foreach (var (key, value) in extras) + { + templates.Add($"##{key}## {value}"); + writer.WriteString(key, value?.ToString()); + } + writer.WriteEndObject(); + + // 移除内存占用 + httpContext.Items.Remove(LoggingMonitorContext.KEY); + + return templates; + } + + /// + /// 序列化对象 + /// + /// + /// + /// + /// + private string TrySerializeObject(object obj, LoggingMonitorMethod monitorMethod, out bool succeed) + { + // 排除 IQueryable<> 泛型 + if (obj != null && obj.GetType().HasImplementedRawGeneric(typeof(IQueryable<>))) + { + succeed = true; + return "{}"; + } + + try + { + var contractResolver = GetContractResolver(ContractResolver, monitorMethod); + + // 序列化默认配置 + var jsonSerializerSettings = new JsonSerializerSettings() + { + // 解决属性忽略问题 + ContractResolver = contractResolver == ContractResolverTypes.CamelCase + ? new CamelCasePropertyNamesContractResolverWithIgnoreProperties(GetIgnorePropertyNames(monitorMethod), GetIgnorePropertyTypes(monitorMethod)) + : new DefaultContractResolverWithIgnoreProperties(GetIgnorePropertyNames(monitorMethod), GetIgnorePropertyTypes(monitorMethod)), + + // 解决循环引用问题 + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + + // 解决 DateTimeOffset 序列化/反序列化问题 + MetadataPropertyHandling = MetadataPropertyHandling.Ignore, + DateParseHandling = DateParseHandling.None, + }; + + if (CheckIsSetLongTypeConverter(monitorMethod)) + { + // 解决 long 精度问题 + jsonSerializerSettings.Converters.AddLongTypeConverters(); + } + + // 解决 JsonElement 序列化问题 + jsonSerializerSettings.Converters.Add(new JsonElementConverter()); + + // 解决 DateTimeOffset 序列化/反序列化问题 + if (obj is DateTimeOffset) + { + jsonSerializerSettings.Converters.Add(new IsoDateTimeConverter { DateTimeStyles = Globalization.DateTimeStyles.AssumeUniversal }); + } + + var result = Newtonsoft.Json.JsonConvert.SerializeObject(obj, jsonSerializerSettings); + + succeed = true; + return result; + } + catch + { + succeed = true; + return "{}"; + } + } + + /// + /// 检查是否开启启用返回值 + /// + /// + /// + private bool CheckIsSetWithReturnValue(LoggingMonitorMethod monitorMethod) + { + return WithReturnValue == null + ? (monitorMethod?.WithReturnValue ?? Settings.WithReturnValue) + : Convert.ToBoolean(WithReturnValue); + } + + /// + /// 检查是否开启 JSON 格式化 + /// + /// + /// + private bool CheckIsSetJsonIndented(LoggingMonitorMethod monitorMethod) + { + return JsonIndented == null + ? (monitorMethod?.JsonIndented ?? Settings.JsonIndented) + : Convert.ToBoolean(JsonIndented); + } + + /// + /// 检查是否开启 long 转 string + /// + /// + /// + private bool CheckIsSetLongTypeConverter(LoggingMonitorMethod monitorMethod) + { + return LongTypeConverter == null + ? (monitorMethod?.LongTypeConverter ?? Settings.LongTypeConverter) + : Convert.ToBoolean(LongTypeConverter); + } + + /// + /// 获取返回值阈值 + /// + /// + /// + private int GetReturnValueThreshold(LoggingMonitorMethod monitorMethod) + { + return ReturnValueThreshold == null + ? (monitorMethod?.ReturnValueThreshold ?? Settings.ReturnValueThreshold) + : Convert.ToInt32(ReturnValueThreshold); + } + + /// + /// 获取 Json 输出行为 + /// + /// + /// + /// + private JsonBehavior GetJsonBehavior(object jsonBehavior, LoggingMonitorMethod monitorMethod) + { + return jsonBehavior == null + ? (monitorMethod?.JsonBehavior ?? Settings.JsonBehavior) + : (JsonBehavior)jsonBehavior; + } + + /// + /// 获取 序列化属性命名规则 + /// + /// + /// + /// + private ContractResolverTypes GetContractResolver(object contractResolver, LoggingMonitorMethod monitorMethod) + { + return contractResolver == null + ? (monitorMethod?.ContractResolver ?? Settings.ContractResolver) + : (ContractResolverTypes)contractResolver; + } + + /// + /// 获取忽略序列化属性名称集合 + /// + /// + /// + private string[] GetIgnorePropertyNames(LoggingMonitorMethod monitorMethod) + { + IEnumerable ignorePropertyNamesList = IgnorePropertyNames ?? Array.Empty(); + + return ignorePropertyNamesList.Concat(monitorMethod?.IgnorePropertyNames ?? Array.Empty()) + .Concat(Settings.IgnorePropertyNames ?? Array.Empty()) + .ToArray(); + } + + /// + /// 获取忽略序列化属性类型集合 + /// + /// + /// + private Type[] GetIgnorePropertyTypes(LoggingMonitorMethod monitorMethod) + { + IEnumerable ignorePropertyTypesList = IgnorePropertyTypes ?? Array.Empty(); + + return ignorePropertyTypesList.Concat(monitorMethod?.IgnorePropertyTypes ?? Array.Empty()) + .Concat(Settings.IgnorePropertyTypes ?? Array.Empty()) + .ToArray(); + } + + /// + /// 处理泛型类型转字符串打印问题 + /// + /// + /// + private static string HandleGenericType(Type type) + { + if (type == null) return string.Empty; + + var typeName = type.FullName ?? (!string.IsNullOrEmpty(type.Namespace) ? type.Namespace + "." : string.Empty) + type.Name; + + // 处理泛型类型问题 + if (type.IsConstructedGenericType) + { + var prefix = type.GetGenericArguments() + .Select(genericArg => HandleGenericType(genericArg)) + .Aggregate((previous, current) => previous + ", " + current); + + typeName = typeName.Split('`').First() + "<" + prefix + ">"; + } + + return typeName; + } + + private async Task MonitorAsync(MethodInfo actionMethod, IDictionary parameterValues, FilterContext context, dynamic next) + { + // 排除 WebSocket 请求处理 + if (context.HttpContext.IsWebSocketRequest()) + { + _ = await next(); + return; + } + + // 判断是否是 Razor Pages + var isPageDescriptor = context.ActionDescriptor is CompiledPageActionDescriptor; + + // 如果贴了 [SuppressMonitor] 特性则跳过 + if (actionMethod.IsDefined(typeof(SuppressMonitorAttribute), true) + || actionMethod.DeclaringType.IsDefined(typeof(SuppressMonitorAttribute), true)) + { + _ = await next(); + return; + } + + // 判断是否自定义了日志筛选器,如果是则检查是否符合条件 + if (LoggingMonitorSettings.InternalWriteFilter?.Invoke(context) == false) + { + _ = await next(); + return; + } + + // 获取方法完整名称 + var methodFullName = actionMethod.DeclaringType.FullName + "." + actionMethod.Name; + + // 只有方法没有贴有 [LoggingMonitor] 特性才判断全局,贴了特性优先级最大 + var isDefinedScopedAttribute = actionMethod.IsDefined(typeof(LoggingMonitorAttribute), true); + + // 解决局部和全局触发器同时配置触发两次问题 + if (isDefinedScopedAttribute && Settings.FromGlobalFilter == true) + { + _ = await next(); + return; + } + + if (!isDefinedScopedAttribute) + { + // 解决通过 AddMvcFilter 的问题 + if (!Settings.IsMvcFilterRegister) + { + // 处理不启用但排除的情况 + if (!Settings.GlobalEnabled + && !Settings.IncludeOfMethods.Contains(methodFullName, StringComparer.OrdinalIgnoreCase)) + { + // 查找是否包含匹配,忽略大小写 + _ = await next(); + return; + } + + // 处理启用但排除的情况 + if (Settings.GlobalEnabled + && Settings.ExcludeOfMethods.Contains(methodFullName, StringComparer.OrdinalIgnoreCase)) + { + _ = await next(); + return; + } + } + } + + // 获取全局 LoggingMonitorMethod 配置 + var monitorMethod = Settings.MethodsSettings.FirstOrDefault(m => m.FullName.Equals(methodFullName, StringComparison.OrdinalIgnoreCase)); + + // 创建 json 写入器 + using var stream = new MemoryStream(); + var jsonWriterOptions = Settings.JsonWriterOptions; + + // 配置 JSON 格式化行为,是否美化 + jsonWriterOptions.Indented = CheckIsSetJsonIndented(monitorMethod); + + // 创建 JSON 写入器 + using var writer = new Utf8JsonWriter(stream, jsonWriterOptions); + writer.WriteStartObject(); + writer.WriteString("title", Title); + + // 创建日志上下文 + var logContext = new LogContext(); + + // 获取路由表信息 + var routeData = context.RouteData; + var controllerName = routeData.Values["controller"]; + var actionName = routeData.Values["action"]; + var areaName = routeData.DataTokens["area"]; + writer.WriteString(nameof(controllerName), controllerName?.ToString()); + writer.WriteString("controllerTypeName", actionMethod.DeclaringType.Name); + writer.WriteString(nameof(actionName), actionName?.ToString()); + writer.WriteString("actionTypeName", actionMethod.Name); + writer.WriteString("areaName", areaName?.ToString()); + + // 调用呈现链名称 + var displayName = methodFullName; + writer.WriteString(nameof(displayName), displayName); + + // [DisplayName] 特性 + var displayNameAttribute = actionMethod.IsDefined(typeof(DisplayNameAttribute), true) + ? actionMethod.GetCustomAttribute(true) + : default; + writer.WriteString("displayTitle", displayNameAttribute?.DisplayName); + + // 获取 HttpContext 和 HttpRequest 对象 + var httpContext = context.HttpContext; + var httpRequest = httpContext.Request; + + // 获取服务端 IPv4 地址 + var localIPv4 = httpContext.GetLocalIpAddressToIPv4(); + writer.WriteString(nameof(localIPv4), localIPv4); + + // 获取服务端源端口 + var localPort = httpContext.Connection.LocalPort; + writer.WriteNumber(nameof(localPort), localPort); + + // 获取客户端 IPv4 地址 + var remoteIPv4 = httpContext.GetRemoteIpAddressToIPv4(); + writer.WriteString(nameof(remoteIPv4), remoteIPv4); + + // 获取客户端远程端口 + var remotePort = httpContext.Connection.RemotePort; + writer.WriteNumber(nameof(remotePort), remotePort); + + // 获取请求方式 + var httpMethod = httpContext.Request.Method; + writer.WriteString(nameof(httpMethod), httpMethod); + + // 客户端连接 ID + var traceId = App.GetTraceId(); + writer.WriteString(nameof(traceId), traceId); + + // 线程 Id + var threadId = App.GetThreadId(); + writer.WriteNumber(nameof(threadId), threadId); + + // 获取请求的 Url 地址 + var requestUrl = Uri.UnescapeDataString(httpRequest.GetRequestUrlAddress()); + writer.WriteString(nameof(requestUrl), requestUrl); + + // 获取请求 HTTP 协议 + var protocol = Uri.UnescapeDataString(httpRequest.Protocol); + writer.WriteString(nameof(protocol), protocol); + + // 获取来源 Url 地址 + var refererUrl = Uri.UnescapeDataString(httpRequest.GetRefererUrlAddress()); + writer.WriteString(nameof(refererUrl), refererUrl); + + // 客户端浏览器信息 + var userAgent = httpRequest.Headers["User-Agent"]; + writer.WriteString(nameof(userAgent), userAgent); + + // 客户端请求区域语言 + var acceptLanguage = httpRequest.Headers["accept-language"]; + writer.WriteString(nameof(acceptLanguage), acceptLanguage); + + // 请求来源(swagger还是其他) + var requestFrom = httpRequest.Headers["request-from"].ToString(); + requestFrom = string.IsNullOrWhiteSpace(requestFrom) ? "client" : requestFrom; + writer.WriteString(nameof(requestFrom), requestFrom); + + // 获取授权用户 + var user = httpContext.User; + + // 获取请求 cookies 信息 + var requestHeaderCookies = Uri.UnescapeDataString(httpRequest.Headers["cookie"].ToString()); + writer.WriteString(nameof(requestHeaderCookies), requestHeaderCookies); + + // 计算接口执行时间 + var timeOperation = Stopwatch.StartNew(); + var resultContext = await next(); + timeOperation.Stop(); + writer.WriteNumber("timeOperationElapsedMilliseconds", timeOperation.ElapsedMilliseconds); + + var resultHttpContext = (resultContext as FilterContext).HttpContext; + + // token 信息 + // 判断是否是授权访问 + var isAuth = actionMethod.GetFoundAttribute(true) == null + && resultHttpContext.User != null + && resultHttpContext.User.Identity.IsAuthenticated; + // 获取响应头信息 + var accessToken = resultHttpContext.Response.Headers["access-token"].ToString(); + var authorization = string.IsNullOrWhiteSpace(accessToken) + ? httpRequest.Headers["Authorization"].ToString() + : "Bearer " + accessToken; + writer.WriteString("accessToken", isAuth ? authorization : default); + + // 获取响应 cookies 信息 + var responseHeaderCookies = Uri.UnescapeDataString(resultHttpContext.Response.Headers["Set-Cookie"].ToString()); + writer.WriteString(nameof(responseHeaderCookies), responseHeaderCookies); + + // 获取系统信息 + var osDescription = RuntimeInformation.OSDescription; + var osArchitecture = RuntimeInformation.OSArchitecture.ToString(); + var frameworkDescription = RuntimeInformation.FrameworkDescription; + var basicFrameworkDescription = typeof(App).Assembly.GetName(); + var basicFramework = basicFrameworkDescription.Name; + var basicFrameworkVersion = basicFrameworkDescription.Version?.ToString(); + writer.WriteString(nameof(osDescription), osDescription); + writer.WriteString(nameof(osArchitecture), osArchitecture); + writer.WriteString(nameof(frameworkDescription), frameworkDescription); + writer.WriteString(nameof(basicFramework), basicFramework); + writer.WriteString(nameof(basicFrameworkVersion), basicFrameworkVersion); + + // 获取启动信息 + var entryAssemblyName = Assembly.GetEntryAssembly().GetName().Name; + writer.WriteString(nameof(entryAssemblyName), entryAssemblyName); + + // 获取进程信息 + var process = Process.GetCurrentProcess(); + var processName = process.ProcessName; + writer.WriteString(nameof(processName), processName); + + // 获取部署程序 + var deployServer = processName == entryAssemblyName ? "Kestrel" : processName; + writer.WriteString(nameof(deployServer), deployServer); + + // 获取主机启动地址 + var iServer = httpContext.RequestServices.GetRequiredService(); + var startUrls = string.Join(";", iServer.GetServerAddresses()); + writer.WriteString(nameof(startUrls), startUrls); + + // 服务器环境 + var environment = httpContext.RequestServices.GetRequiredService().EnvironmentName; + writer.WriteString(nameof(environment), environment); + + // 获取异常对象情况 + Exception exception = resultContext.Exception; + if (exception == null) + { + // 解析存储的验证信息 + var validationFailedKey = nameof(DataValidationFilter) + nameof(ValidationMetadata); + var validationMetadata = !httpContext.Items.ContainsKey(validationFailedKey) + ? default + : httpContext.Items[validationFailedKey] as ValidationMetadata; + + if (validationMetadata != null) + { + // 创建全局验证友好异常 + var error = TrySerializeObject(validationMetadata.ValidationResult, monitorMethod, out _); + exception = new AppFriendlyException(error, validationMetadata.OriginErrorCode) + { + ErrorCode = validationMetadata.ErrorCode, + StatusCode = validationMetadata.StatusCode ?? StatusCodes.Status400BadRequest, + ValidationException = true + }; + } + } + + // 判断是否是验证异常 + var isValidationException = exception is AppFriendlyException friendlyException && friendlyException.ValidationException; + + var monitorItems = new List() + { + $"##控制器名称## {actionMethod.DeclaringType.Name}" + , $"##操作名称## {actionMethod.Name}" + , $"##显示名称## {displayNameAttribute?.DisplayName}" + , $"##路由信息## [area]: {areaName}; [controller]: {controllerName}; [action]: {actionName}" + , $"##请求方式## {httpMethod}" + , $"##请求地址## {requestUrl}" + , $"##HTTP 协议## {protocol}" + , $"##来源地址## {refererUrl}" + , $"##请求端源## {requestFrom}" + , $"##浏览器标识## {userAgent}" + , $"##客户端区域语言## {acceptLanguage}" + , $"##客户端 IP 地址## {remoteIPv4}" + , $"##客户端源端口## {remotePort}" + , $"##服务端 IP 地址## {localIPv4}" + , $"##服务端源端口## {localPort}" + , $"##客户端连接 ID## {traceId}" + , $"##服务线程 ID## #{threadId}" + , $"##执行耗时## {timeOperation.ElapsedMilliseconds}ms" + ,"━━━━━━━━━━━━━━━ Cookies ━━━━━━━━━━━━━━━" + , $"##请求端## {requestHeaderCookies}" + , $"##响应端## {responseHeaderCookies}" + ,"━━━━━━━━━━━━━━━ 系统信息 ━━━━━━━━━━━━━━━" + , $"##系统名称## {osDescription}" + , $"##系统架构## {osArchitecture}" + , $"##基础框架## {basicFramework} v{basicFrameworkVersion}" + , $"##.NET 架构## {frameworkDescription}" + ,"━━━━━━━━━━━━━━━ 启动信息 ━━━━━━━━━━━━━━━" + , $"##Web 启动地址## {startUrls}" + , $"##运行环境## {environment}" + , $"##启动程序集## {entryAssemblyName}" + , $"##进程名称## {processName}" + , $"##托管程序## {deployServer}" + }; + + // 如果用户实际授权才打印 + if (isAuth) + { + // 添加 JWT 授权信息日志模板 + monitorItems.AddRange(GenerateAuthorizationTemplate(writer, user, authorization)); + } + + // 生成请求头日志模板 + monitorItems.AddRange(GenerateRequestHeadersTemplate(writer, httpRequest.Headers)); + + // 添加请求参数信息日志模板 + monitorItems.AddRange(GenerateParameterTemplate(writer, parameterValues, actionMethod, httpRequest.Headers["Content-Type"], monitorMethod)); + + // 判断是否启用返回值打印 + if (CheckIsSetWithReturnValue(monitorMethod)) + { + // 添加返回值信息日志模板 + monitorItems.AddRange(GenerateReturnInfomationTemplate(writer, resultContext, actionMethod, monitorMethod)); + } + + // 添加附加信息模板 + monitorItems.AddRange(GenerateExtraTemplate(writer, resultHttpContext) ?? Enumerable.Empty()); + + // 添加异常信息日志模板 + monitorItems.AddRange(GenerateExcetpionInfomationTemplate(writer, exception, isValidationException)); + + // 生成最终模板 + var monitorMessage = TP.Wrapper(Title, displayName, monitorItems.ToArray()); + + // 创建日志记录器 + var logger = httpContext.RequestServices.GetRequiredService>(); + + // 调用外部配置 + LoggingMonitorSettings.Configure?.Invoke(logger, logContext, resultContext as FilterContext); + + writer.WriteEndObject(); + await writer.FlushAsync().ConfigureAwait(false); + + // 获取 json 字符串 + var jsonString = Encoding.UTF8.GetString(stream.ToArray()); + logContext.Set("loggingMonitor", jsonString); + + // 设置日志上下文 + using var scope = logger.ScopeContext(logContext); + + // 获取最终写入日志消息格式 + var finalMessage = GetJsonBehavior(JsonBehavior, monitorMethod) == ThingsGateway.Logging.JsonBehavior.OnlyJson ? jsonString : monitorMessage; + + // 写入日志,如果没有异常默认使用 LogInformation,否则使用 LogError + if (exception == null) + { + logger.Log(Settings.LogLevel, finalMessage); + } + else + { + // 如果不是验证异常,写入 Error + if (!isValidationException) logger.LogError(exception, finalMessage); + else + { + // 读取配置的日志级别并写入 + logger.Log(Settings.BahLogLevel, finalMessage); + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorContext.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorContext.cs new file mode 100644 index 000000000..5b678a116 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorContext.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; + +namespace ThingsGateway.Logging; + +/// +/// LoggingMonitor 上下文 +/// +[SuppressSniffer] +public static class LoggingMonitorContext +{ + internal const string KEY = nameof(LoggingMonitorContext); + + /// + /// 追加附加信息 + /// + /// + public static void Append(Dictionary items) + { + var httpContextItems = App.HttpContext?.Items; + if (httpContextItems == null) + { + return; + } + + if (httpContextItems.ContainsKey(KEY)) + { + httpContextItems.Remove(KEY); + } + + httpContextItems.Add(KEY, items); + } + + /// + /// 追加附加信息 + /// + /// + public static void Append(Action, HttpContext> action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var httpContext = App.HttpContext; + if (httpContext == null) + { + return; + } + + var items = new Dictionary(); + action?.Invoke(items, httpContext); + + Append(items); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorMethod.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorMethod.cs new file mode 100644 index 000000000..93152bd4c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorMethod.cs @@ -0,0 +1,67 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Logging; + +/// +/// LoggingMonitor 方法配置 +/// +[SuppressSniffer] +public sealed class LoggingMonitorMethod +{ + /// + /// 方法名称 + /// + /// 完全限定名 + public string FullName { get; set; } + + /// + /// 是否记录返回值 + /// + /// bool 类型,默认输出 + public bool WithReturnValue { get; set; } = true; + + /// + /// 设置返回值阈值 + /// + /// 配置返回值字符串阈值,超过这个阈值将截断,默认全量输出 + public int ReturnValueThreshold { get; set; } = 0; + + /// + /// 配置 Json 输出行为 + /// + public JsonBehavior JsonBehavior { get; set; } = JsonBehavior.None; + + /// + /// 配置序列化忽略的属性名称 + /// + public string[] IgnorePropertyNames { get; set; } + + /// + /// 配置序列化忽略的属性类型 + /// + public Type[] IgnorePropertyTypes { get; set; } + + /// + /// JSON 输出格式化 + /// + public bool JsonIndented { get; set; } = false; + + /// + /// 是否处理 Long 转 String + /// + public bool LongTypeConverter { get; set; } = false; + + /// + /// 序列化属性命名规则(返回值) + /// + public ContractResolverTypes ContractResolver { get; set; } = ContractResolverTypes.CamelCase; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorSettings.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorSettings.cs new file mode 100644 index 000000000..d83770ca7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/LoggingMonitorSettings.cs @@ -0,0 +1,146 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +using System.Text.Encodings.Web; +using System.Text.Json; + +namespace ThingsGateway.Logging; + +/// +/// 日志监视器配置 +/// +/// 默认配置节点:Logging:Monitor,支持自定义 +[SuppressSniffer] +public sealed class LoggingMonitorSettings +{ + /// + /// 全局启用 + /// + public bool GlobalEnabled { get; set; } = false; + + /// + /// 配置包含拦截的方法名列表(完全限定名格式:程序集名称.类名.方法名),注意无需添加参数签名 + /// + /// 结合 使用,当 为 false 时有效, + public string[] IncludeOfMethods { get; set; } = Array.Empty(); + + /// + /// 配置排除拦截的方法名列表(完全限定名格式:程序集名称.类名.方法名),注意无需添加参数签名 + /// + /// 结合 使用,当 为 true 时有效, + public string[] ExcludeOfMethods { get; set; } = Array.Empty(); + + /// + /// 配置方法更多信息 + /// + public LoggingMonitorMethod[] MethodsSettings { get; set; } = Array.Empty(); + + /// + /// 业务日志消息级别 + /// + /// 控制 Oops.Oh 或 Oops.Bah 日志记录位置,默认写入 + public LogLevel BahLogLevel { get; set; } = LogLevel.Information; + + /// + /// 默认输出日志级别 + /// + public LogLevel LogLevel { get; set; } = LogLevel.Information; + + /// + /// 是否记录返回值 + /// + /// bool 类型,默认输出 + public bool WithReturnValue { get; set; } = true; + + /// + /// 设置返回值阈值 + /// + /// 配置返回值字符串阈值,超过这个阈值将截断,默认全量输出 + public int ReturnValueThreshold { get; set; } = 0; + + /// + /// 配置 Json 输出行为 + /// + public JsonBehavior JsonBehavior { get; set; } = JsonBehavior.None; + + /// + /// 配置 序列化属性命名规则(返回值) + /// + public ContractResolverTypes ContractResolver { get; set; } = ContractResolverTypes.CamelCase; + + /// + /// 配置序列化忽略的属性名称 + /// + public string[] IgnorePropertyNames { get; set; } + + /// + /// 配置序列化忽略的属性类型 + /// + public Type[] IgnorePropertyTypes { get; set; } + + /// + /// 自定义日志筛选器 + /// + public Func WriteFilter { get; set; } + + /// + /// 是否 Mvc Filter 方式注册 + /// + /// 解决过去 Mvc Filter 全局注册的问题 + internal bool IsMvcFilterRegister { get; set; } = true; + + /// + /// 是否来自全局触发器 + /// + /// 解决局部和全局触发器同时配置触发两次问题 + internal bool FromGlobalFilter { get; set; } = false; + + /// + /// 添加日志更多配置 + /// + internal static Action Configure { get; private set; } + + /// + /// 自定义日志筛选器 + /// + internal static Func InternalWriteFilter { get; set; } + + /// + /// 配置日志更多功能 + /// + /// + public void ConfigureLogger(Action configure) + { + Configure = configure; + } + + /// + /// JSON 输出格式化 + /// + public bool JsonIndented { get; set; } = false; + + /// + /// 是否处理 Long 转 String + /// + public bool LongTypeConverter { get; set; } = false; + + /// + /// 配置 Json 写入选项 + /// + public JsonWriterOptions JsonWriterOptions { get; set; } = new JsonWriterOptions + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + SkipValidation = true + }; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/PropertyNamesContractResolver.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/PropertyNamesContractResolver.cs new file mode 100644 index 000000000..23f59329c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/PropertyNamesContractResolver.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace ThingsGateway.Logging; + +/// +/// 支持忽略特定属性的 CamelCase 序列化 +/// +internal sealed class CamelCasePropertyNamesContractResolverWithIgnoreProperties : CamelCasePropertyNamesContractResolver +{ + /// + /// 被忽略的属性名称 + /// + private readonly string[] _names; + + /// + /// 被忽略的属性类型 + /// + private readonly Type[] _type; + + /// + /// 构造函数 + /// + /// + /// + public CamelCasePropertyNamesContractResolverWithIgnoreProperties(string[] names, Type[] types) + { + _names = names ?? Array.Empty(); + _type = types ?? Array.Empty(); + } + + /// + /// 重写需要序列化的属性名 + /// + /// + /// + /// + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) + { + var allProperties = base.CreateProperties(type, memberSerialization); + + return allProperties.Where(p => + !_names.Contains(p.PropertyName, StringComparer.OrdinalIgnoreCase) + && !_type.Contains(p.PropertyType)).ToList(); + } +} + +/// +/// 支持忽略特定属性的 Default 序列化 +/// +internal sealed class DefaultContractResolverWithIgnoreProperties : DefaultContractResolver +{ + /// + /// 被忽略的属性名称 + /// + private readonly string[] _names; + + /// + /// 被忽略的属性类型 + /// + private readonly Type[] _type; + + /// + /// 构造函数 + /// + /// + /// + public DefaultContractResolverWithIgnoreProperties(string[] names, Type[] types) + { + _names = names ?? Array.Empty(); + _type = types ?? Array.Empty(); + } + + /// + /// 重写需要序列化的属性名 + /// + /// + /// + /// + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) + { + var allProperties = base.CreateProperties(type, memberSerialization); + + return allProperties.Where(p => + !_names.Contains(p.PropertyName, StringComparer.OrdinalIgnoreCase) + && !_type.Contains(p.PropertyType)).ToList(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/SuppressMonitorAttribute.cs b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/SuppressMonitorAttribute.cs new file mode 100644 index 000000000..b2bc7f50a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Implantations/Monitors/SuppressMonitorAttribute.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace System; + +/// +/// 控制跳过日志监视 +/// +/// 作用于全局 +[SuppressSniffer, AttributeUsage(AttributeTargets.Method | AttributeTargets.Class | AttributeTargets.Parameter, Inherited = true, AllowMultiple = false)] +public sealed class SuppressMonitorAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Internal/Logging.cs b/src/Admin/ThingsGateway.Furion/Logging/Internal/Logging.cs new file mode 100644 index 000000000..a9410dc9b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Internal/Logging.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace System.Logging; + +/// +/// 字符串日志拓展默认分类名 +/// +internal sealed class StringLogging +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Internal/Penetrates.cs b/src/Admin/ThingsGateway.Furion/Logging/Internal/Penetrates.cs new file mode 100644 index 000000000..5f5cdcb27 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Internal/Penetrates.cs @@ -0,0 +1,352 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +using System.Diagnostics; +using System.Text; + +namespace ThingsGateway.Logging; + +/// +/// 常量、公共方法配置类 +/// +internal static class Penetrates +{ + /// + /// 异常分隔符 + /// + private const string EXCEPTION_SEPARATOR = "++++++++++++++++++++++++++++++++++++++++++++++++++++++++"; + + /// + /// 从配置文件中加载配置并创建文件日志记录器提供程序 + /// + /// 获取配置文件对应的 Key + /// 文件日志记录器配置选项委托 + /// + internal static FileLoggerProvider CreateFromConfiguration(Func configuraionKey, Action configure = default) + { + // 检查 Key 是否存在 + var key = configuraionKey?.Invoke(); + if (string.IsNullOrWhiteSpace(key)) return new FileLoggerProvider("application.log", new FileLoggerOptions()); + + // 加载配置文件中指定节点 + var fileLoggerSettings = App.GetConfig(key) + ?? new FileLoggerSettings(); + + // 创建文件日志记录器配置选项 + var fileLoggerOptions = new FileLoggerOptions + { + Append = fileLoggerSettings.Append, + FileSizeLimitBytes = fileLoggerSettings.FileSizeLimitBytes, + MaxRollingFiles = fileLoggerSettings.MaxRollingFiles, + MinimumLevel = fileLoggerSettings.MinimumLevel, + UseUtcTimestamp = fileLoggerSettings.UseUtcTimestamp, + DateFormat = fileLoggerSettings.DateFormat, + IncludeScopes = fileLoggerSettings.IncludeScopes, + WithTraceId = fileLoggerSettings.WithTraceId, + WithStackFrame = fileLoggerSettings.WithStackFrame + }; + + // 处理自定义配置 + configure?.Invoke(fileLoggerOptions); + + // 创建文件日志记录器提供程序 + return new FileLoggerProvider(fileLoggerSettings.FileName ?? "application.log", fileLoggerOptions); + } + + /// + /// 从配置文件中加载配置并创建数据库日志记录器提供程序 + /// + /// 获取配置文件对应的 Key + /// 数据库日志记录器配置选项委托 + /// + internal static DatabaseLoggerProvider CreateFromConfiguration(Func configuraionKey, Action configure = default) + { + // 检查 Key 是否存在 + var key = configuraionKey?.Invoke(); + if (string.IsNullOrWhiteSpace(key)) return new DatabaseLoggerProvider(new DatabaseLoggerOptions()); + + // 加载配置文件中指定节点 + var databaseLoggerSettings = App.GetConfig(key) + ?? new DatabaseLoggerSettings(); + + // 创建数据库日志记录器配置选项 + var databaseLoggerOptions = new DatabaseLoggerOptions + { + MinimumLevel = databaseLoggerSettings.MinimumLevel, + UseUtcTimestamp = databaseLoggerSettings.UseUtcTimestamp, + DateFormat = databaseLoggerSettings.DateFormat, + IncludeScopes = databaseLoggerSettings.IncludeScopes, + IgnoreReferenceLoop = databaseLoggerSettings.IgnoreReferenceLoop, + WithTraceId = databaseLoggerSettings.WithTraceId, + WithStackFrame = databaseLoggerSettings.WithStackFrame + }; + + // 处理自定义配置 + configure?.Invoke(databaseLoggerOptions); + + // 创建数据库日志记录器提供程序 + return new DatabaseLoggerProvider(databaseLoggerOptions); + } + + /// + /// 输出标准日志消息 + /// + /// + /// + /// + /// + /// + /// + /// + internal static string OutputStandardMessage(LogMessage logMsg + , string dateFormat = "yyyy-MM-dd HH:mm:ss.fffffff zzz dddd" + , bool isConsole = false + , bool disableColors = true + , bool withTraceId = false + , bool withStackFrame = false) + { + // 空检查 + if (logMsg.Message is null) return null; + + // 创建默认日志格式化模板 + var formatString = new StringBuilder(); + + // 获取日志级别对应控制台的颜色 + var disableConsoleColor = !isConsole || disableColors; + var logLevelColors = GetLogLevelConsoleColors(logMsg.LogLevel, disableConsoleColor); + + _ = AppendWithColor(formatString, GetLogLevelString(logMsg.LogLevel), logLevelColors); + formatString.Append(": "); + formatString.Append(logMsg.LogDateTime.ToString(dateFormat)); + formatString.Append(' '); + formatString.Append(logMsg.UseUtcTimestamp ? "U" : "L"); + formatString.Append(' '); + _ = AppendWithColor(formatString, logMsg.LogName, disableConsoleColor + ? new ConsoleColors(null, null) + : new ConsoleColors(ConsoleColor.Cyan, ConsoleColor.DarkCyan)); + formatString.Append('['); + formatString.Append(logMsg.EventId.Id); + formatString.Append(']'); + formatString.Append(' '); + formatString.Append($"#{logMsg.ThreadId}"); + if (withTraceId && !string.IsNullOrWhiteSpace(logMsg.TraceId)) + { + formatString.Append(' '); + _ = AppendWithColor(formatString, $"'{logMsg.TraceId}'", disableConsoleColor + ? new ConsoleColors(null, null) + : new ConsoleColors(ConsoleColor.Gray, ConsoleColor.Black)); + } + formatString.AppendLine(); + + // 输出日志输出所在方法,类型,程序集 + if (withStackFrame) + { + var stackTraces = EnhancedStackTrace.Current(); + var pos = isConsole ? 6 : 5; + if (stackTraces.FrameCount > pos) + { + var targetMethod = stackTraces.Where((u, i) => i == pos).FirstOrDefault()?.MethodInfo; + var targetAssembly = targetMethod?.DeclaringType?.Assembly; + + if (targetAssembly != null) + { + formatString.Append(PadLeftAlign($"[{targetAssembly.GetName().Name}.dll] {targetMethod}")); + formatString.AppendLine(); + } + } + } + + // 对日志内容进行缩进对齐处理 + formatString.Append(PadLeftAlign(logMsg.Message)); + + // 如果包含异常信息,则创建新一行写入 + if (logMsg.Exception != null) + { + var EXCEPTION_SEPARATOR_WITHCOLOR = AppendWithColor(default, EXCEPTION_SEPARATOR, logLevelColors).ToString(); + var exceptionMessage = $"{Environment.NewLine}{EXCEPTION_SEPARATOR_WITHCOLOR}{Environment.NewLine}{logMsg.Exception}{Environment.NewLine}{EXCEPTION_SEPARATOR_WITHCOLOR}"; + + formatString.Append(PadLeftAlign(exceptionMessage)); + } + + // 返回日志消息模板 + return formatString.ToString(); + } + + /// + /// 将日志内容进行对齐 + /// + /// + /// + private static string PadLeftAlign(string message) + { + var newMessage = string.Join(Environment.NewLine, message.Split(new[] { Environment.NewLine, "\n" }, StringSplitOptions.None) + .Select(line => string.Empty.PadLeft(6, ' ') + line)); + + return newMessage; + } + + /// + /// 获取日志级别短名称 + /// + /// 日志级别 + /// + internal static string GetLogLevelString(LogLevel logLevel) + { + return logLevel switch + { + LogLevel.Trace => "trce", + LogLevel.Debug => "dbug", + LogLevel.Information => "info", + LogLevel.Warning => "warn", + LogLevel.Error => "fail", + LogLevel.Critical => "crit", + _ => throw new ArgumentOutOfRangeException(nameof(logLevel)), + }; + } + + /// + /// 获取日志上下文 + /// + /// + /// + /// + internal static LogContext SetLogContext(IExternalScopeProvider scopeProvider, bool includeScopes) + { + // 空检查 + if (!includeScopes || scopeProvider == null) return null; + + var contexts = new List(); + var scopes = new List(); + + // 解析日志上下文数据 + scopeProvider.ForEachScope((scope, ctx) => + { + if (scope != null) + { + if (scope is LogContext context) contexts.Add(context); + else scopes.Add(scope); + } + }, null); + + if (contexts.Count == 0 && scopes.Count == 0) return null; + + // 构建日志上下文 + var logConext = new LogContext + { + Properties = contexts.SelectMany(p => p.Properties) + .GroupBy(p => p.Key) + .ToDictionary(u => u.Key, u => u.FirstOrDefault().Value), + Scopes = scopes + }; + + return logConext; + } + + /// + /// 拓展 StringBuilder 增加带颜色写入 + /// + /// + /// + /// + /// + private static StringBuilder AppendWithColor(StringBuilder formatString, string message, ConsoleColors colors) + { + formatString ??= new(); + + // 输出控制台前景色和背景色 + if (colors.Background.HasValue) formatString.Append(GetBackgroundColorEscapeCode(colors.Background.Value)); + if (colors.Foreground.HasValue) formatString.Append(GetForegroundColorEscapeCode(colors.Foreground.Value)); + + formatString.Append(message); + + // 输出控制台前景色和背景色 + if (colors.Background.HasValue) formatString.Append("\u001b[39m\u001b[22m"); + if (colors.Foreground.HasValue) formatString.Append("\u001b[49m"); + + return formatString; + } + + /// + /// 输出控制台字体颜色 UniCode 码 + /// + /// + /// + private static string GetForegroundColorEscapeCode(ConsoleColor color) + { + return color switch + { + ConsoleColor.Black => "\u001b[30m", + ConsoleColor.DarkRed => "\u001b[31m", + ConsoleColor.DarkGreen => "\u001b[32m", + ConsoleColor.DarkYellow => "\u001b[33m", + ConsoleColor.DarkBlue => "\u001b[34m", + ConsoleColor.DarkMagenta => "\u001b[35m", + ConsoleColor.DarkCyan => "\u001b[36m", + ConsoleColor.Gray => "\u001b[37m", + ConsoleColor.Red => "\u001b[1m\u001b[31m", + ConsoleColor.Green => "\u001b[1m\u001b[32m", + ConsoleColor.Yellow => "\u001b[1m\u001b[33m", + ConsoleColor.Blue => "\u001b[1m\u001b[34m", + ConsoleColor.Magenta => "\u001b[1m\u001b[35m", + ConsoleColor.Cyan => "\u001b[1m\u001b[36m", + ConsoleColor.White => "\u001b[1m\u001b[37m", + _ => "\u001b[39m\u001b[22m", + }; + } + + /// + /// 输出控制台背景颜色 UniCode 码 + /// + /// + /// + private static string GetBackgroundColorEscapeCode(ConsoleColor color) + { + return color switch + { + ConsoleColor.Black => "\u001b[40m", + ConsoleColor.Red => "\u001b[41m", + ConsoleColor.Green => "\u001b[42m", + ConsoleColor.Yellow => "\u001b[43m", + ConsoleColor.Blue => "\u001b[44m", + ConsoleColor.Magenta => "\u001b[45m", + ConsoleColor.Cyan => "\u001b[46m", + ConsoleColor.White => "\u001b[47m", + _ => "\u001b[49m", + }; + } + + /// + /// 获取控制台日志级别对应的颜色 + /// + /// + /// + /// + private static ConsoleColors GetLogLevelConsoleColors(LogLevel logLevel, bool disableColors = false) + { + if (disableColors) + { + return new ConsoleColors(null, null); + } + + return logLevel switch + { + LogLevel.Critical => new ConsoleColors(ConsoleColor.White, ConsoleColor.Red), + LogLevel.Error => new ConsoleColors(ConsoleColor.Black, ConsoleColor.Red), + LogLevel.Warning => new ConsoleColors(ConsoleColor.Yellow, ConsoleColor.Black), + LogLevel.Information => new ConsoleColors(ConsoleColor.DarkGreen, ConsoleColor.Black), + LogLevel.Debug => new ConsoleColors(ConsoleColor.Gray, ConsoleColor.Black), + LogLevel.Trace => new ConsoleColors(ConsoleColor.Gray, ConsoleColor.Black), + _ => new ConsoleColors(null, background: null), + }; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Internal/StringLoggingPart.cs b/src/Admin/ThingsGateway.Furion/Logging/Internal/StringLoggingPart.cs new file mode 100644 index 000000000..dcd2c19ae --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Internal/StringLoggingPart.cs @@ -0,0 +1,71 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +using System.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 构建字符串日志部分类 +/// +[SuppressSniffer] +public sealed partial class StringLoggingPart +{ + /// + /// 静态缺省日志部件 + /// + public static StringLoggingPart Default() + { + return new(); + } + + /// + /// 日志内容 + /// + public string Message { get; private set; } + + /// + /// 日志级别 + /// + public LogLevel Level { get; private set; } = LogLevel.Information; + + /// + /// 消息格式化参数 + /// + public object[] Args { get; private set; } + + /// + /// 事件 Id + /// + public EventId? EventId { get; private set; } + + /// + /// 日志分类类型 + /// + public Type CategoryType { get; private set; } = typeof(StringLogging); + + /// + /// 异常对象 + /// + public Exception Exception { get; private set; } + + /// + /// 日志对象所在作用域 + /// + public IServiceProvider LoggerScoped { get; private set; } + + /// + /// 日志上下文 + /// + public LogContext LogContext { get; private set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Internal/StringLoggingPartMethods.cs b/src/Admin/ThingsGateway.Furion/Logging/Internal/StringLoggingPartMethods.cs new file mode 100644 index 000000000..ac7d7fc90 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Internal/StringLoggingPartMethods.cs @@ -0,0 +1,178 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 构建字符串日志部分类 +/// +public sealed partial class StringLoggingPart +{ + /// + /// Information + /// + public void LogInformation() + { + SetLevel(LogLevel.Information).Log(); + } + + /// + /// Warning + /// + public void LogWarning() + { + SetLevel(LogLevel.Warning).Log(); + } + + /// + /// Error + /// + public void LogError() + { + SetLevel(LogLevel.Error).Log(); + } + + /// + /// Debug + /// + public void LogDebug() + { + SetLevel(LogLevel.Debug).Log(); + } + + /// + /// Trace + /// + public void LogTrace() + { + SetLevel(LogLevel.Trace).Log(); + } + + /// + /// Critical + /// + public void LogCritical() + { + SetLevel(LogLevel.Critical).Log(); + } + + /// + /// 写入日志 + /// + /// + public void Log() + { + if (Message == null) return; + + // 获取日志实例 + var (logger, loggerFactory, hasException) = GetLogger(); + using var scope = logger.ScopeContext(LogContext); + + // 如果没有异常且事件 Id 为空 + if (Exception == null && EventId == null) + { + logger.Log(Level, Message, Args); + } + // 如果存在异常且事件 Id 为空 + else if (Exception != null && EventId == null) + { + logger.Log(Level, Exception, Message, Args); + } + // 如果异常为空且事件 Id 不为空 + else if (Exception == null && EventId != null) + { + logger.Log(Level, EventId.Value, Message, Args); + } + // 如果存在异常且事件 Id 不为空 + else if (Exception != null && EventId != null) + { + logger.Log(Level, EventId.Value, Exception, Message, Args); + } + else { } + + // 释放临时日志工厂 + if (hasException == true) + { + loggerFactory?.Dispose(); + } + } + + /// + /// 获取日志实例 + /// + /// + internal (ILogger, ILoggerFactory, bool) GetLogger() + { + // 解析日志分类名 + var categoryType = CategoryType ?? typeof(System.Logging.StringLogging); + + ILoggerFactory loggerFactory = null; + ILogger logger = null; + var hasException = false; + + // 解决启动时打印日志问题 + if (App.RootServices == null) + { + hasException = true; + loggerFactory = CreateDisposeLoggerFactory(); + } + else + { + try + { + logger = App.GetService(typeof(ILogger<>).MakeGenericType(categoryType)) as ILogger; + } + catch + { + hasException = true; + loggerFactory = CreateDisposeLoggerFactory(); + } + } + + // 创建日志实例 + logger ??= loggerFactory.CreateLogger(categoryType.FullName); + + return (logger, loggerFactory, hasException); + } + + /// + /// 创建待释放的日志工厂 + /// + /// + private static ILoggerFactory CreateDisposeLoggerFactory() + { + var consoleFormatterExtendOptions = App.GetOptions(); + + Action configure = consoleFormatterExtendOptions is not null + ? (opt => + { + opt.IncludeScopes = consoleFormatterExtendOptions.IncludeScopes; + opt.TimestampFormat = consoleFormatterExtendOptions.TimestampFormat; + opt.UseUtcTimestamp = consoleFormatterExtendOptions.UseUtcTimestamp; + opt.ColorBehavior = consoleFormatterExtendOptions.ColorBehavior; + opt.MessageFormat = consoleFormatterExtendOptions.MessageFormat; + opt.DateFormat = consoleFormatterExtendOptions.DateFormat; + opt.WriteFilter = consoleFormatterExtendOptions.WriteFilter; + opt.WriteHandler = consoleFormatterExtendOptions.WriteHandler; + opt.WithTraceId = consoleFormatterExtendOptions.WithTraceId; + opt.WithStackFrame = consoleFormatterExtendOptions.WithStackFrame; + opt.MessageProcess = consoleFormatterExtendOptions.MessageProcess; + }) + : null; + + return LoggerFactory.Create(builder => + { + builder.AddConsoleFormatter(configure); + }); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Internal/StringLoggingPartSetters.cs b/src/Admin/ThingsGateway.Furion/Logging/Internal/StringLoggingPartSetters.cs new file mode 100644 index 000000000..dca8dfa23 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Internal/StringLoggingPartSetters.cs @@ -0,0 +1,134 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +using ThingsGateway.Templates.Extensions; + +namespace ThingsGateway.Logging; + +/// +/// 构建字符串日志部分类 +/// +public sealed partial class StringLoggingPart +{ + /// + /// 设置消息 + /// + /// + public StringLoggingPart SetMessage(string message) + { + // 支持读取配置渲染 + if (message != null) Message = message.Render(); + return this; + } + + /// + /// 设置日志级别 + /// + /// + public StringLoggingPart SetLevel(LogLevel level) + { + Level = level; + return this; + } + + /// + /// 设置消息格式化参数 + /// + /// + public StringLoggingPart SetArgs(params object[] args) + { + if (args != null && args.Length > 0) Args = args; + return this; + } + + /// + /// 设置事件 Id + /// + /// + public StringLoggingPart SetEventId(EventId eventId) + { + EventId = eventId; + return this; + } + + /// + /// 设置日志分类 + /// + /// + public StringLoggingPart SetCategory() + { + CategoryType = typeof(TClass); + return this; + } + + /// + /// 设置异常对象 + /// + public StringLoggingPart SetException(Exception exception) + { + if (exception != null) Exception = exception; + return this; + } + + /// + /// 设置日志服务作用域 + /// + /// + /// + public StringLoggingPart SetLoggerScoped(IServiceProvider serviceProvider) + { + if (serviceProvider != null) LoggerScoped = serviceProvider; + return this; + } + + /// + /// 配置日志上下文 + /// + /// 建议使用 ConcurrentDictionary 类型 + /// + public StringLoggingPart ScopeContext(IDictionary properties) + { + if (properties == null) return this; + LogContext = new LogContext { Properties = properties }; + + return this; + } + + /// + /// 配置日志上下文 + /// + /// + /// + public StringLoggingPart ScopeContext(Action configure) + { + var logContext = new LogContext(); + configure?.Invoke(logContext); + + LogContext = logContext; + + return this; + } + + /// + /// 配置日志上下文 + /// + /// + /// + public StringLoggingPart ScopeContext(LogContext context) + { + if (context == null) return this; + LogContext = context; + + return this; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/Log.cs b/src/Admin/ThingsGateway.Furion/Logging/Log.cs new file mode 100644 index 000000000..e7dfb402e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/Log.cs @@ -0,0 +1,660 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Logging; + +/// +/// 全局日志静态类 +/// +[SuppressSniffer] +public static class Log +{ + /// + /// 手动构建方式 + /// + /// + public static StringLoggingPart Default() + { + return StringLoggingPart.Default(); + } + + /// + /// 创建日志记录器 + /// + /// + public static ILogger CreateLogger() + { + return App.GetService>(); + } + + /// + /// 创建日志工厂 + /// + /// 日志构建器 + /// 实现了 接口,注意使用 `using` 控制 + /// + public static ILoggerFactory CreateLoggerFactory(Action configure = default) + { + return LoggerFactory.Create(builder => + { + // 添加默认控制台输出 + builder.AddConsoleFormatter(); + + configure?.Invoke(builder); + }); + } + + /// + /// 配置日志上下文 + /// + /// 建议使用 ConcurrentDictionary 类型 + /// + public static (ILogger logger, IDisposable scope) ScopeContext(IDictionary properties) + { + return GetLogger(StringLoggingPart.Default().ScopeContext(properties)); + } + + /// + /// 配置日志上下文 + /// + /// + /// + public static (ILogger logger, IDisposable scope) ScopeContext(Action configure) + { + return GetLogger(StringLoggingPart.Default().ScopeContext(configure)); + } + + /// + /// 配置日志上下文 + /// + /// + /// + public static (ILogger logger, IDisposable scope) ScopeContext(LogContext context) + { + return GetLogger(StringLoggingPart.Default().ScopeContext(context)); + } + + /// + /// LogInformation + /// + /// + /// + public static void Information(string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + public static void Information(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + public static void Information(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + /// + public static void Information(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + public static void Information(string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + /// + public static void Information(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + /// + public static void Information(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogInformation(); + } + + /// + /// LogInformation + /// + /// + /// + /// + /// + /// + public static void Information(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogInformation(); + } + + /// + /// LogWarning + /// + /// + /// + public static void Warning(string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + public static void Warning(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + public static void Warning(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + /// + public static void Warning(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + public static void Warning(string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + /// + public static void Warning(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + /// + public static void Warning(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogWarning(); + } + + /// + /// LogWarning + /// + /// + /// + /// + /// + /// + public static void Warning(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogWarning(); + } + + /// + /// LogError + /// + /// + /// + public static void Error(string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + public static void Error(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + public static void Error(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + /// + public static void Error(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + public static void Error(string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + /// + public static void Error(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + /// + public static void Error(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogError(); + } + + /// + /// LogError + /// + /// + /// + /// + /// + /// + public static void Error(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogError(); + } + + /// + /// LogDebug + /// + /// + /// + public static void Debug(string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + public static void Debug(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + public static void Debug(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + /// + public static void Debug(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + public static void Debug(string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + /// + public static void Debug(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + /// + public static void Debug(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogDebug(); + } + + /// + /// LogDebug + /// + /// + /// + /// + /// + /// + public static void Debug(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogDebug(); + } + + /// + /// LogTrace + /// + /// + /// + public static void Trace(string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + public static void Trace(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + public static void Trace(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + /// + public static void Trace(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + public static void Trace(string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + /// + public static void Trace(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + /// + public static void Trace(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogTrace(); + } + + /// + /// LogTrace + /// + /// + /// + /// + /// + /// + public static void Trace(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogTrace(); + } + + /// + /// LogCritical + /// + /// + /// + public static void Critical(string message, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + public static void Critical(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + public static void Critical(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetException(exception).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + /// + public static void Critical(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + public static void Critical(string message, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + /// + public static void Critical(string message, EventId eventId, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + /// + public static void Critical(string message, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetException(exception).LogCritical(); + } + + /// + /// LogCritical + /// + /// + /// + /// + /// + /// + public static void Critical(string message, EventId eventId, Exception exception, params object[] args) + { + StringLoggingPart.Default().SetCategory().SetMessage(message).SetArgs(args).SetEventId(eventId).SetException(exception).LogCritical(); + } + + /// + /// 获取日志实例 + /// + /// + /// + /// + private static (ILogger, IDisposable) GetLogger(StringLoggingPart loggingPart) + { + // 获取日志实例 + var (logger, loggerFactory, hasException) = loggingPart.GetLogger(); + var scope = logger.ScopeContext(loggingPart.LogContext); + if (hasException) + { + scope?.Dispose(); + loggerFactory?.Dispose(); + + throw new InvalidOperationException("Unable to set log context data."); + } + + return (logger, scope); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Logging/LoggerFormatter.cs b/src/Admin/ThingsGateway.Furion/Logging/LoggerFormatter.cs new file mode 100644 index 000000000..20fb14ff3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Logging/LoggerFormatter.cs @@ -0,0 +1,80 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json; + +using ThingsGateway.Logging; + +namespace System; + +/// +/// 日志格式化静态类 +/// +[SuppressSniffer] +public static class LoggerFormatter +{ + /// + /// Json 输出格式化 + /// + public static readonly Func Json = (logMsg) => + { + return logMsg.Write(writer => WriteJson(logMsg, writer)); + }; + + /// + /// Json 输出格式化 + /// + public static readonly Func JsonIndented = (logMsg) => + { + return logMsg.Write(writer => WriteJson(logMsg, writer), true); + }; + + /// + /// 写入 JSON + /// + /// + /// + private static void WriteJson(LogMessage logMsg, Utf8JsonWriter writer) + { + writer.WriteStartObject(); + + // 输出日志级别 + writer.WriteString("logLevel", logMsg.LogLevel.ToString()); + + // 输出日志时间 + writer.WriteString("logDateTime", logMsg.LogDateTime.ToString("o")); + + // 输出日志类别 + writer.WriteString("logName", logMsg.LogName); + + // 输出日志事件 Id + writer.WriteNumber("eventId", logMsg.EventId.Id); + + // 输出日志消息 + writer.WriteString("message", logMsg.Message); + + // 输出日志所在线程 Id + writer.WriteNumber("threadId", logMsg.ThreadId); + + // 输出是否使用 UTC 时间戳 + writer.WriteBoolean("useUtcTimestamp", logMsg.UseUtcTimestamp); + + // 输出请求 TraceId + writer.WriteString("traceId", logMsg.TraceId); + + // 输出异常信息 + writer.WritePropertyName("exception"); + if (logMsg.Exception == null) writer.WriteNullValue(); + else writer.WriteStringValue(logMsg.Exception.ToString()); + + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/ObjectMapper/Extensions/ObjectMapperServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/ObjectMapper/Extensions/ObjectMapperServiceCollectionExtensions.cs new file mode 100644 index 000000000..5c5323dd8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/ObjectMapper/Extensions/ObjectMapperServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Mapster; + +using System.Reflection; + +using ThingsGateway; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 对象映射拓展类 +/// +[SuppressSniffer] +public static class ObjectMapperServiceCollectionExtensions +{ + + /// + /// 添加对象映射 + /// + /// 服务集合 + /// + public static IServiceCollection AddObjectMapper(this IServiceCollection services) + { + // 判断是否安装了 Mapster 程序集 + return services.AddObjectMapper(App.Assemblies.ToArray()); + } + + /// + /// 添加对象映射 + /// + /// 服务集合 + /// 扫描的程序集 + /// + public static IServiceCollection AddObjectMapper(this IServiceCollection services, params Assembly[] assemblies) + { + // 获取全局映射配置 + var config = TypeAdapterConfig.GlobalSettings; + + // 扫描所有继承 IRegister 接口的对象映射配置 + if (assemblies != null && assemblies.Length > 0) config.Scan(assemblies); + + // 配置支持依赖注入 + services.AddSingleton(config); + + return services; + } +} diff --git a/src/Admin/ThingsGateway.Furion/Options/Attributes/FailureMessageAttribute.cs b/src/Admin/ThingsGateway.Furion/Options/Attributes/FailureMessageAttribute.cs new file mode 100644 index 000000000..c9626c442 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Options/Attributes/FailureMessageAttribute.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Options; + +/// +/// 选项校验失败消息特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public sealed class FailureMessageAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// 文本内容 + public FailureMessageAttribute(string text) + { + Text = text; + } + + /// + /// 文本内容 + /// + public string Text { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Options/Attributes/OptionsBuilderAttribute.cs b/src/Admin/ThingsGateway.Furion/Options/Attributes/OptionsBuilderAttribute.cs new file mode 100644 index 000000000..62f29fb64 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Options/Attributes/OptionsBuilderAttribute.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Options; + +/// +/// 选项构建器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public sealed class OptionsBuilderAttribute : Attribute +{ + /// + /// 构造函数 + /// + public OptionsBuilderAttribute() + { + } + + /// + /// 构造函数 + /// + /// 配置节点 + public OptionsBuilderAttribute(string sectionKey) + { + SectionKey = sectionKey; + } + + /// + /// 配置节点 + /// + public string SectionKey { get; set; } + + /// + /// 未知配置节点抛异常 + /// + public bool ErrorOnUnknownConfiguration { get; set; } + + /// + /// 绑定非公开属性 + /// + public bool BindNonPublicProperties { get; set; } + + /// + /// 启用验证特性支持 + /// + public bool ValidateDataAnnotations { get; set; } + + /// + /// 验证选项类型 + /// + public Type[] ValidateOptionsTypes { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Options/Attributes/OptionsBuilderMethodMapAttribute.cs b/src/Admin/ThingsGateway.Furion/Options/Attributes/OptionsBuilderMethodMapAttribute.cs new file mode 100644 index 000000000..01157f8bb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Options/Attributes/OptionsBuilderMethodMapAttribute.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Options; + +/// +/// 选项构建器方法映射特性 +/// +[AttributeUsage(AttributeTargets.Interface, AllowMultiple = false)] +internal sealed class OptionsBuilderMethodMapAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// 映射方法名 + /// 无返回值 + internal OptionsBuilderMethodMapAttribute(string methodName, bool voidReturn) + { + MethodName = methodName; + VoidReturn = voidReturn; + } + + /// + /// 方法名称 + /// + internal string MethodName { get; set; } + + /// + /// 有无返回值 + /// + internal bool VoidReturn { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Options/Constants/Constants.cs b/src/Admin/ThingsGateway.Furion/Options/Constants/Constants.cs new file mode 100644 index 000000000..ab373337a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Options/Constants/Constants.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Options; + +/// +/// Options 模块常量 +/// +internal static class Constants +{ + /// + /// Options 类型后缀 + /// + /// 主要用于匹配配置节点,自动去掉该后缀 + internal const string OptionsTypeSuffix = "Options"; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Options/Dependencies/IConfigureOptionsBuilder.cs b/src/Admin/ThingsGateway.Furion/Options/Dependencies/IConfigureOptionsBuilder.cs new file mode 100644 index 000000000..f7916a589 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Options/Dependencies/IConfigureOptionsBuilder.cs @@ -0,0 +1,163 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Options; + +namespace ThingsGateway.Options; + +/// +/// 选项配置依赖接口 +/// +/// 选项类型 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Configure), true)] +public interface IConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class +{ + /// + /// 选项配置 + /// + /// 选项实例 + void Configure(TOptions options); +} + +/// +/// 选项配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Configure), true)] +public interface IConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep : class +{ + /// + /// 选项配置 + /// + /// 选项实例 + /// 依赖服务 + void Configure(TOptions options, TDep dep); +} + +/// +/// 选项配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Configure), true)] +public interface IConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class +{ + /// + /// 选项配置 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + void Configure(TOptions options + , TDep1 dep1 + , TDep2 dep2); +} + +/// +/// 选项配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Configure), true)] +public interface IConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class + where TDep3 : class +{ + /// + /// 选项配置 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + void Configure(TOptions options + , TDep1 dep1 + , TDep2 dep2 + , TDep3 dep3); +} + +/// +/// 选项配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Configure), true)] +public interface IConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class + where TDep3 : class + where TDep4 : class +{ + /// + /// 选项配置 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + void Configure(TOptions options + , TDep1 dep1 + , TDep2 dep2 + , TDep3 dep3 + , TDep4 dep4); +} + +/// +/// 选项配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Configure), true)] +public interface IConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class + where TDep3 : class + where TDep4 : class + where TDep5 : class +{ + /// + /// 选项配置 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + void Configure(TOptions options + , TDep1 dep1 + , TDep2 dep2 + , TDep3 dep3 + , TDep4 dep4 + , TDep5 dep5); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Options/Dependencies/IOptionsBuilderDependency.cs b/src/Admin/ThingsGateway.Furion/Options/Dependencies/IOptionsBuilderDependency.cs new file mode 100644 index 000000000..2746a7e2e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Options/Dependencies/IOptionsBuilderDependency.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Options; + +/// +/// 选项构建器依赖接口 +/// +/// 选项类型 +public interface IOptionsBuilderDependency + where TOptions : class +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Options/Dependencies/IPostConfigureOptionsBuilder.cs b/src/Admin/ThingsGateway.Furion/Options/Dependencies/IPostConfigureOptionsBuilder.cs new file mode 100644 index 000000000..b03ca1d4c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Options/Dependencies/IPostConfigureOptionsBuilder.cs @@ -0,0 +1,163 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Options; + +namespace ThingsGateway.Options; + +/// +/// 选项后期配置依赖接口 +/// +/// 选项类型 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.PostConfigure), true)] +public interface IPostConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class +{ + /// + /// 选项后期配置 + /// + /// 选项实例 + void PostConfigure(TOptions options); +} + +/// +/// 选项后期配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.PostConfigure), true)] +public interface IPostConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep : class +{ + /// + /// 选项后期配置 + /// + /// 选项实例 + /// 依赖服务 + void PostConfigure(TOptions options, TDep dep); +} + +/// +/// 选项后期配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.PostConfigure), true)] +public interface IPostConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class +{ + /// + /// 选项后期配置 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + void PostConfigure(TOptions options + , TDep1 dep1 + , TDep2 dep2); +} + +/// +/// 选项后期配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.PostConfigure), true)] +public interface IPostConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class + where TDep3 : class +{ + /// + /// 选项后期配置 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + void PostConfigure(TOptions options + , TDep1 dep1 + , TDep2 dep2 + , TDep3 dep3); +} + +/// +/// 选项后期配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.PostConfigure), true)] +public interface IPostConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class + where TDep3 : class + where TDep4 : class +{ + /// + /// 选项后期配置 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + void PostConfigure(TOptions options + , TDep1 dep1 + , TDep2 dep2 + , TDep3 dep3 + , TDep4 dep4); +} + +/// +/// 选项后期配置依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.PostConfigure), true)] +public interface IPostConfigureOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class + where TDep3 : class + where TDep4 : class + where TDep5 : class +{ + /// + /// 选项后期配置 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + void PostConfigure(TOptions options + , TDep1 dep1 + , TDep2 dep2 + , TDep3 dep3 + , TDep4 dep4 + , TDep5 dep5); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Options/Dependencies/IValidateOptionsBuilder.cs b/src/Admin/ThingsGateway.Furion/Options/Dependencies/IValidateOptionsBuilder.cs new file mode 100644 index 000000000..ab3adb2a2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Options/Dependencies/IValidateOptionsBuilder.cs @@ -0,0 +1,163 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Options; + +namespace ThingsGateway.Options; + +/// +/// 选项验证依赖接口 +/// +/// 选项类型 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Validate), false)] +public interface IValidateOptionsBuilder : IOptionsBuilderDependency + where TOptions : class +{ + /// + /// 复杂验证 + /// + /// 选项实例 + bool Validate(TOptions options); +} + +/// +/// 选项验证依赖接口 +/// +/// 选项类型 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Validate), false)] +public interface IValidateOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep : class +{ + /// + /// 复杂验证 + /// + /// 选项实例 + /// 依赖服务 + bool Validate(TOptions options, TDep dep); +} + +/// +/// 选项验证依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Validate), false)] +public interface IValidateOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class +{ + /// + /// 复杂验证 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + bool Validate(TOptions options + , TDep1 dep1 + , TDep2 dep2); +} + +/// +/// 选项验证依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Validate), false)] +public interface IValidateOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class + where TDep3 : class +{ + /// + /// 复杂验证 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + bool Validate(TOptions options + , TDep1 dep1 + , TDep2 dep2 + , TDep3 dep3); +} + +/// +/// 选项验证依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Validate), false)] +public interface IValidateOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class + where TDep3 : class + where TDep4 : class +{ + /// + /// 复杂验证 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + bool Validate(TOptions options + , TDep1 dep1 + , TDep2 dep2 + , TDep3 dep3 + , TDep4 dep4); +} + +/// +/// 选项验证依赖接口 +/// +/// 选项类型 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +/// 依赖服务 +[OptionsBuilderMethodMap(nameof(OptionsBuilder.Validate), false)] +public interface IValidateOptionsBuilder : IOptionsBuilderDependency + where TOptions : class + where TDep1 : class + where TDep2 : class + where TDep3 : class + where TDep4 : class + where TDep5 : class +{ + /// + /// 复杂验证 + /// + /// 选项实例 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + /// 依赖服务 + bool Validate(TOptions options + , TDep1 dep1 + , TDep2 dep2 + , TDep3 dep3 + , TDep4 dep4 + , TDep5 dep5); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Options/Extensions/OptionsBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/Options/Extensions/OptionsBuilderExtensions.cs new file mode 100644 index 000000000..b471b6d48 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Options/Extensions/OptionsBuilderExtensions.cs @@ -0,0 +1,302 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using System.Linq.Expressions; +using System.Reflection; + +using ThingsGateway.Extensions; +using ThingsGateway.Options; + +namespace Microsoft.Extensions.Options; + +/// +/// OptionsBuilder 拓展类 +/// +[SuppressSniffer] +public static class OptionsBuilderExtensions +{ + /// + /// 配置选项构建器 + /// + /// 选项类型 + /// 选项构建器实例 + /// 配置对象 + /// 选项构建器类型,默认为 typeof(TOptions) + /// 选项构建器实例 + public static OptionsBuilder ConfigureBuilder(this OptionsBuilder optionsBuilder + , IConfiguration configuration + , Type optionsBuilderType = default) + where TOptions : class + { + // 配置默认处理和选项构建器 + optionsBuilder.ConfigureDefaults(configuration) + .ConfigureBuilder(optionsBuilderType); + + return optionsBuilder; + } + + /// + /// 配置多个选项构建器 + /// + /// 选项类型 + /// 选项构建器实例 + /// 配置对象 + /// 配置多个选项构建器 + /// 选项构建器实例 + /// + public static OptionsBuilder ConfigureBuilders(this OptionsBuilder optionsBuilder + , IConfiguration configuration + , Type[] optionsBuilderTypes) + where TOptions : class + { + // 配置默认处理和多个选项构建器 + optionsBuilder.ConfigureDefaults(configuration) + .ConfigureBuilders(optionsBuilderTypes); + + return optionsBuilder; + } + + /// + /// 配置选项构建器 + /// + /// 选项类型 + /// 选项构建器实例 + /// 选项构建器类型,默认为 typeof(TOptions) + /// 选项构建器实例 + public static OptionsBuilder ConfigureBuilder(this OptionsBuilder optionsBuilder, Type optionsBuilderType = default) + where TOptions : class + { + optionsBuilderType ??= typeof(TOptions); + var optionsBuilderDependency = typeof(IOptionsBuilderDependency); + + // 获取所有构建器依赖接口 + var builderInterfaces = optionsBuilderType.GetInterfaces() + .Where(u => optionsBuilderDependency.IsAssignableFrom(u) && u != optionsBuilderDependency); + + if (!builderInterfaces.Any()) + { + return optionsBuilder; + } + + // 逐条调用 .NET 底层选项配置方法 + foreach (var builderInterface in builderInterfaces) + { + InvokeMapMethod(optionsBuilder, optionsBuilderType, builderInterface); + } + + return optionsBuilder; + } + + /// + /// 配置多个选项构建器 + /// + /// 选项类型 + /// 选项构建器实例 + /// 配置多个选项构建器 + /// 选项构建器实例 + /// + public static OptionsBuilder ConfigureBuilders(this OptionsBuilder optionsBuilder, Type[] optionsBuilderTypes) + where TOptions : class + { + // 处理空对象或空值 + if (optionsBuilderTypes.IsEmpty()) + { + throw new ArgumentNullException(nameof(optionsBuilderTypes)); + } + + // 逐条配置选项构建器 + Array.ForEach(optionsBuilderTypes, optionsBuilderType => + { + optionsBuilder.ConfigureBuilder(optionsBuilderType); + }); + + return optionsBuilder; + } + + /// + /// 配置选项常规默认处理 + /// + /// 选项类型 + /// 选项构建器实例 + /// 配置对象 + /// 选项构建器实例 + public static OptionsBuilder ConfigureDefaults(this OptionsBuilder optionsBuilder, IConfiguration configuration) + where TOptions : class + { + // 获取 [OptionsBuilder] 特性 + var optionsType = typeof(TOptions); + var optionsBuilderAttribute = typeof(TOptions).GetTypeAttribute(); + + // 解析配置类型(自动识别是否是节点配置对象) + var configurationSection = configuration is IConfigurationSection section + ? section + : configuration.GetSection( + string.IsNullOrWhiteSpace(optionsBuilderAttribute?.SectionKey) + ? optionsType.Name.ClearStringAffixes(1, Constants.OptionsTypeSuffix) + : optionsBuilderAttribute.SectionKey + ); + + // 绑定配置 + optionsBuilder.Bind(configurationSection, binderOptions => + { + binderOptions.ErrorOnUnknownConfiguration = optionsBuilderAttribute?.ErrorOnUnknownConfiguration ?? false; + binderOptions.BindNonPublicProperties = optionsBuilderAttribute?.BindNonPublicProperties ?? false; + }); + + // 注册验证特性支持 + if (optionsBuilderAttribute?.ValidateDataAnnotations == true) + { + optionsBuilder.ValidateDataAnnotations() + .ValidateOnStart(); + } + + // 注册复杂验证类型 + if (optionsBuilderAttribute?.ValidateOptionsTypes.IsEmpty() == false) + { + var validateOptionsType = typeof(IValidateOptions); + + // 注册复杂选项 + Array.ForEach(optionsBuilderAttribute.ValidateOptionsTypes!, type => + { + optionsBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(validateOptionsType, type)); + }); + } + + return optionsBuilder; + } + + /// + /// 调用 OptionsBuilder{TOptions} 对应方法 + /// + /// 选项构建器实例 + /// 选项构建器类型 + /// 构建器接口 + private static void InvokeMapMethod(object optionsBuilder + , Type optionsBuilderType + , Type builderInterface) + { + // 获取接口对应 OptionsBuilder 方法映射特性 + var optionsBuilderMethodMapAttribute = builderInterface.GetCustomAttribute()!; + var methodName = optionsBuilderMethodMapAttribute.MethodName; + + // 获取选项构建器接口实际泛型参数 + var genericArguments = builderInterface.GetGenericArguments(); + + // 获取匹配的配置方法 + var bindingAttr = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly; + var matchMethod = optionsBuilderType.GetMethods(bindingAttr) + .First(u => u.Name == methodName || u.Name.EndsWith("." + methodName) && u.GetParameters().Length == genericArguments.Length); + + // 构建表达式实际传入参数 + var parameterExpressions = BuildExpressionCallParameters(matchMethod + , !optionsBuilderMethodMapAttribute.VoidReturn + , genericArguments + , out var args); + + // 创建 OptionsBuilder 实例对应调用方法表达式 + var callExpression = Expression.Call(Expression.Constant(optionsBuilder) + , methodName + , genericArguments.IsEmpty() ? default : genericArguments!.Skip(1).ToArray() + , parameterExpressions); + + // 创建调用委托 + var @delegate = Expression.Lambda(callExpression, parameterExpressions).Compile(); + + // 动态调用 + @delegate.DynamicInvoke(args); + } + + /// + /// 构建 Call 调用方法表达式参数 + /// + /// 含实际传入参数 + /// 表达式匹配方法 + /// 是否校验方法 + /// 泛型参数 + /// 实际传入参数 + /// 调用参数表达式数组 + private static ParameterExpression[] BuildExpressionCallParameters(MethodInfo matchMethod + , bool isValidateMethod + , Type[] genericArguments + , out object[] args) + { + /* + * 该方法目的是构建符合 OptionsBuilder 对象的 Configure、PostConfigure、Validate 方法签名委托参数表达式,如: + * Configure/PostConfigure: [Method](Action); + * Validate: [Method](Func, string); + */ + + // 创建调用方法第一个委托参数表达式 + var delegateType = CreateDelegate(genericArguments, !isValidateMethod ? default : typeof(bool)); + + var arg0Expression = Expression.Parameter(delegateType, "arg0"); + var arg0 = matchMethod.CreateDelegate(delegateType, default); + + // 创建调用方法第二个字符串参数表达式(仅限 Validate 方法使用) + ParameterExpression arg1Expression = default; + string arg1 = default; + + if (isValidateMethod) + { + // 获取 [FailureMessage] 特性配置 + arg1 = matchMethod.IsDefined(typeof(FailureMessageAttribute)) + ? matchMethod.GetCustomAttribute()!.Text + : default; + + if (!string.IsNullOrWhiteSpace(arg1)) + { + arg1Expression = Expression.Parameter(typeof(string), "arg1"); + } + } + + // 设置调用方法实际传入参数 + args = arg1Expression == default + ? new object[] { arg0 } + : new object[] { arg0, arg1! }; + + // 返回调用方法参数定义表达式 + return arg1Expression == default + ? new[] { arg0Expression } + : new[] { arg0Expression, arg1Expression }; + } + + /// + /// 创建委托类型 + /// + /// 输入类型 + /// 输出类型 + /// Action或Func 委托类型 + internal static Type CreateDelegate(Type[] inputTypes, Type outputType = default) + { + var isFuncDelegate = outputType != default; + + // 获取基础委托类型,通过是否带返回值判断 + var baseDelegateType = !isFuncDelegate ? typeof(Action) : typeof(Func<>); + + // 处理无输入参数委托类型 + if (inputTypes.IsEmpty()) + { + return !isFuncDelegate + ? baseDelegateType + : baseDelegateType.MakeGenericType(outputType!); + } + + // 处理含输入参数委托类型 + return !isFuncDelegate + ? baseDelegateType.Assembly.GetType($"{baseDelegateType.FullName}`{inputTypes!.Length}")!.MakeGenericType(inputTypes) + : baseDelegateType.Assembly.GetType($"{(baseDelegateType.FullName![0..^2])}`{inputTypes!.Length + 1}") + !.MakeGenericType(inputTypes.Concat(new[] { outputType! }).ToArray()); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/README.md b/src/Admin/ThingsGateway.Furion/README.md new file mode 100644 index 000000000..32adb05e8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/README.md @@ -0,0 +1,14 @@ +# ASPNetCore 核心库 + +``Furion`` 魔改,删除不需要的功能,精简代码。 + +版权信息 + +版权归百小僧及百签科技(广东)有限公司所有。 +所有权利保留。 +官方网站:https://baiqian.com + +许可证信息 +项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 + diff --git a/src/Admin/ThingsGateway.Furion/README.zh-CN.md b/src/Admin/ThingsGateway.Furion/README.zh-CN.md new file mode 100644 index 000000000..32adb05e8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/README.zh-CN.md @@ -0,0 +1,14 @@ +# ASPNetCore 核心库 + +``Furion`` 魔改,删除不需要的功能,精简代码。 + +版权信息 + +版权归百小僧及百签科技(广东)有限公司所有。 +所有权利保留。 +官方网站:https://baiqian.com + +许可证信息 +项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 + diff --git a/src/Admin/ThingsGateway.Furion/Reflection/Extensions/MethodInfoExtensions.cs b/src/Admin/ThingsGateway.Furion/Reflection/Extensions/MethodInfoExtensions.cs new file mode 100644 index 000000000..9370f342e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Reflection/Extensions/MethodInfoExtensions.cs @@ -0,0 +1,163 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Reflection.Extensions; + +/// +/// Method Info 拓展 +/// +[SuppressSniffer] +public static class MethodInfoExtensions +{ + /// + /// 获取真实方法的特性集合 + /// + /// + /// + /// + public static IEnumerable GetActualCustomAttributes(this MethodInfo method, object target) + { + return GetActualMethodInfo(method, target)?.GetCustomAttributes(); + } + + /// + /// 获取真实方法的特性集合 + /// + /// + /// + /// + /// + public static object[] GetActualCustomAttributes(this MethodInfo method, object target, bool inherit) + { + return GetActualMethodInfo(method, target)?.GetCustomAttributes(inherit); + } + + /// + /// 获取真实方法的特性集合 + /// + /// + /// + /// + /// + public static IEnumerable GetActualCustomAttributes(this MethodInfo method, object target, Type attributeType) + { + return GetActualMethodInfo(method, target)?.GetCustomAttributes(attributeType); + } + + /// + /// 获取真实方法的特性集合 + /// + /// + /// + /// + /// + /// + public static object[] GetActualCustomAttributes(this MethodInfo method, object target, Type attributeType, bool inherit) + { + return GetActualMethodInfo(method, target)?.GetCustomAttributes(attributeType, inherit); + } + + /// + /// 获取真实方法的特性集合 + /// + /// + /// + /// + public static IEnumerable GetActualCustomAttributes(this MethodInfo method, object target) + where TAttribute : Attribute + { + return GetActualMethodInfo(method, target)?.GetCustomAttributes(); + } + + /// + /// 获取真实方法的特性集合 + /// + /// + /// + /// + /// + public static IEnumerable GetActualCustomAttributes(this MethodInfo method, object target, bool inherit) + where TAttribute : Attribute + { + return GetActualMethodInfo(method, target)?.GetCustomAttributes(inherit); + } + + /// + /// 获取真实方法的特性 + /// + /// + /// + /// + /// + public static Attribute GetActualCustomAttribute(this MethodInfo method, object target, Type attributeType) + { + return GetActualMethodInfo(method, target)?.GetCustomAttribute(attributeType); + } + + /// + /// 获取真实方法的特性 + /// + /// + /// + /// + /// + /// + public static Attribute GetActualCustomAttribute(this MethodInfo method, object target, Type attributeType, bool inherit) + { + return GetActualMethodInfo(method, target)?.GetCustomAttribute(attributeType, inherit); + } + + /// + /// 获取真实方法的特性 + /// + /// + /// + /// + public static TAttribute GetActualCustomAttribute(this MethodInfo method, object target) + where TAttribute : Attribute + { + return GetActualMethodInfo(method, target)?.GetCustomAttribute(); + } + + /// + /// 获取真实方法的特性 + /// + /// + /// + /// + /// + public static TAttribute GetActualCustomAttribute(this MethodInfo method, object target, bool inherit) + where TAttribute : Attribute + { + return GetActualMethodInfo(method, target)?.GetCustomAttribute(inherit); + } + + /// + /// 获取实际方法对象 + /// + /// + /// + /// + private static MethodInfo GetActualMethodInfo(MethodInfo method, object target) + { + if (target == null) return default; + + var targetType = target.GetType(); + var actualMethod = targetType.GetMethods() + .FirstOrDefault(u => u.ToString().Equals(method.ToString())); + + if (actualMethod == null) return default; + + return actualMethod; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Reflection/Internal/MethodParameterInfo.cs b/src/Admin/ThingsGateway.Furion/Reflection/Internal/MethodParameterInfo.cs new file mode 100644 index 000000000..61caffac9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Reflection/Internal/MethodParameterInfo.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Reflection; + +/// +/// 方法参数信息 +/// +internal sealed class MethodParameterInfo +{ + /// + /// 参数 + /// + internal ParameterInfo Parameter { get; set; } + + /// + /// 参数名 + /// + internal string Name { get; set; } + + /// + /// 参数值 + /// + internal object Value { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Reflection/Proxies/AspectDispatchProxy.cs b/src/Admin/ThingsGateway.Furion/Reflection/Proxies/AspectDispatchProxy.cs new file mode 100644 index 000000000..030f37d56 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Reflection/Proxies/AspectDispatchProxy.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Reflection; + +/// +/// 异步代理分发类 +/// +public abstract class AspectDispatchProxy +{ + /// + /// 创建代理 + /// + /// + /// + /// + public static T Create() where TProxy : AspectDispatchProxy + { + return (T)AspectDispatchProxyGenerator.CreateProxyInstance(typeof(TProxy), typeof(T)); + } + + /// + /// 执行同步代理 + /// + /// + /// + /// + public abstract object Invoke(MethodInfo method, object[] args); + + /// + /// 执行异步代理 + /// + /// + /// + /// + public abstract Task InvokeAsync(MethodInfo method, object[] args); + + /// + /// 执行异步返回 Task{T} 代理 + /// + /// + /// + /// + /// + public abstract Task InvokeAsyncT(MethodInfo method, object[] args); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Reflection/Proxies/AspectDispatchProxyGenerator.cs b/src/Admin/ThingsGateway.Furion/Reflection/Proxies/AspectDispatchProxyGenerator.cs new file mode 100644 index 000000000..ba6fe8449 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Reflection/Proxies/AspectDispatchProxyGenerator.cs @@ -0,0 +1,1119 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.ExceptionServices; + +namespace ThingsGateway.Reflection; + +/// +/// 异步分发代理生成器 +/// +internal static class AspectDispatchProxyGenerator +{ + private const int InvokeActionFieldAndCtorParameterIndex = 0; + + private static readonly Dictionary> s_baseTypeAndInterfaceToGeneratedProxyType = new(); + + private static readonly ProxyAssembly s_proxyAssembly = new(); + private static readonly MethodInfo s_dispatchProxyInvokeMethod = typeof(AspectDispatchProxy).GetTypeInfo().GetDeclaredMethod("Invoke"); + private static readonly MethodInfo s_dispatchProxyInvokeAsyncMethod = typeof(AspectDispatchProxy).GetTypeInfo().GetDeclaredMethod("InvokeAsync"); + private static readonly MethodInfo s_dispatchProxyInvokeAsyncTMethod = typeof(AspectDispatchProxy).GetTypeInfo().GetDeclaredMethod("InvokeAsyncT"); + + // Returns a new instance of a proxy the derives from 'baseType' and implements 'interfaceType' + internal static object CreateProxyInstance(Type baseType, Type interfaceType) + { + Debug.Assert(baseType != null); + Debug.Assert(interfaceType != null); + + var proxiedType = GetProxyType(baseType, interfaceType); + return Activator.CreateInstance(proxiedType, new DispatchProxyHandler()); + } + + private static Type GetProxyType(Type baseType, Type interfaceType) + { + lock (s_baseTypeAndInterfaceToGeneratedProxyType) + { + if (!s_baseTypeAndInterfaceToGeneratedProxyType.TryGetValue(baseType, out var interfaceToProxy)) + { + interfaceToProxy = new Dictionary(); + s_baseTypeAndInterfaceToGeneratedProxyType[baseType] = interfaceToProxy; + } + + if (!interfaceToProxy.TryGetValue(interfaceType, out var generatedProxy)) + { + generatedProxy = GenerateProxyType(baseType, interfaceType); + interfaceToProxy[interfaceType] = generatedProxy; + } + + return generatedProxy; + } + } + + // Unconditionally generates a new proxy type derived from 'baseType' and implements 'interfaceType' + private static Type GenerateProxyType(Type baseType, Type interfaceType) + { + // Parameter validation is deferred until the point we need to create the proxy. + // This prevents unnecessary overhead revalidating cached proxy types. + var baseTypeInfo = baseType.GetTypeInfo(); + + // The interface type must be an interface, not a class + if (!interfaceType.GetTypeInfo().IsInterface) + { + // "T" is the generic parameter seen via the public contract + throw new ArgumentException($"InterfaceType_Must_Be_Interface, {interfaceType.FullName}", nameof(interfaceType)); + } + + // The base type cannot be sealed because the proxy needs to subclass it. + if (baseTypeInfo.IsSealed) + { + // "TProxy" is the generic parameter seen via the public contract + throw new ArgumentException($"BaseType_Cannot_Be_Sealed, {baseTypeInfo.FullName}", nameof(baseType)); + } + + // The base type cannot be abstract + if (baseTypeInfo.IsAbstract) + { + throw new ArgumentException($"BaseType_Cannot_Be_Abstract {baseType.FullName}", nameof(baseType)); + } + + // The base type must have a public default ctor + if (!baseTypeInfo.DeclaredConstructors.Any(c => c.IsPublic && c.GetParameters().Length == 0)) + { + throw new ArgumentException($"BaseType_Must_Have_Default_Ctor {baseType.FullName}", nameof(baseType)); + } + + // Create a type that derives from 'baseType' provided by caller + var pb = s_proxyAssembly.CreateProxy("generatedProxy", baseType); + + foreach (var t in interfaceType.GetTypeInfo().ImplementedInterfaces) + pb.AddInterfaceImpl(t); + + pb.AddInterfaceImpl(interfaceType); + + var generatedProxyType = pb.CreateType(); + return generatedProxyType; + } + + private sealed class ProxyMethodResolverContext + { + public PackedArgs Packed { get; } + public MethodBase Method { get; } + + public ProxyMethodResolverContext(PackedArgs packed, MethodBase method) + { + Packed = packed; + Method = method; + } + } + + private static ProxyMethodResolverContext Resolve(object[] args) + { + var packed = new PackedArgs(args); + var method = s_proxyAssembly.ResolveMethodToken(packed.DeclaringType, packed.MethodToken); + if (method.IsGenericMethodDefinition) + method = ((MethodInfo)method).MakeGenericMethod(packed.GenericTypes); + + return new ProxyMethodResolverContext(packed, method); + } + + public static object Invoke(object[] args) + { + var context = Resolve(args); + + // Call (protected method) DispatchProxyAsync.Invoke() + object returnValue = null; + try + { + Debug.Assert(s_dispatchProxyInvokeMethod != null); + returnValue = s_dispatchProxyInvokeMethod.Invoke(context.Packed.DispatchProxy, + new object[] { context.Method, context.Packed.Args }); + context.Packed.ReturnValue = returnValue; + } + catch (TargetInvocationException tie) + { + // 这里处理内部异常 + //ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + ExceptionDispatchInfo.Capture(tie.InnerException?.InnerException ?? tie.InnerException).Throw(); + } + + return returnValue; + } + + public static async Task InvokeAsync(object[] args) + { + var context = Resolve(args); + + // Call (protected Task method) NetCoreStackDispatchProxy.InvokeAsync() + try + { + Debug.Assert(s_dispatchProxyInvokeAsyncMethod != null); + await ((Task)s_dispatchProxyInvokeAsyncMethod.Invoke(context.Packed.DispatchProxy, + new object[] { context.Method, context.Packed.Args })).ConfigureAwait(false); + } + catch (TargetInvocationException tie) + { + // 这里处理内部异常 + //ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + ExceptionDispatchInfo.Capture(tie.InnerException?.InnerException ?? tie.InnerException).Throw(); + } + } + + public static async Task InvokeAsync(object[] args) + { + var context = Resolve(args); + + // Call (protected Task method) NetCoreStackDispatchProxy.InvokeAsync() + T returnValue = default; + try + { + Debug.Assert(s_dispatchProxyInvokeAsyncTMethod != null); + var genericmethod = s_dispatchProxyInvokeAsyncTMethod.MakeGenericMethod(typeof(T)); + returnValue = await ((Task)genericmethod.Invoke(context.Packed.DispatchProxy, + new object[] { context.Method, context.Packed.Args })).ConfigureAwait(false); + context.Packed.ReturnValue = returnValue; + } + catch (TargetInvocationException tie) + { + // 这里处理内部异常 + //ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + ExceptionDispatchInfo.Capture(tie.InnerException?.InnerException ?? tie.InnerException).Throw(); + } + return returnValue; + } + + private sealed class PackedArgs + { + internal const int DispatchProxyPosition = 0; + internal const int DeclaringTypePosition = 1; + internal const int MethodTokenPosition = 2; + internal const int ArgsPosition = 3; + internal const int GenericTypesPosition = 4; + internal const int ReturnValuePosition = 5; + + internal static readonly Type[] PackedTypes = new Type[] { typeof(object), typeof(Type), typeof(int), typeof(object[]), typeof(Type[]), typeof(object) }; + + private readonly object[] _args; + + internal PackedArgs() : this(new object[PackedTypes.Length]) + { + } + + internal PackedArgs(object[] args) + { + _args = args; + } + + internal AspectDispatchProxy DispatchProxy => (AspectDispatchProxy)_args[DispatchProxyPosition]; + internal Type DeclaringType => (Type)_args[DeclaringTypePosition]; + internal int MethodToken => (int)_args[MethodTokenPosition]; + internal object[] Args => (object[])_args[ArgsPosition]; + internal Type[] GenericTypes => (Type[])_args[GenericTypesPosition]; + + internal object ReturnValue + { /*get { return args[ReturnValuePosition]; }*/ set { _args[ReturnValuePosition] = value; } } + } + + private sealed class ProxyAssembly + { + public AssemblyBuilder _ab; + private readonly ModuleBuilder _mb; + private int _typeId = 0; + + // Maintain a MethodBase-->int, int-->MethodBase mapping to permit generated code + // to pass methods by token + private readonly Dictionary _methodToToken = new(); + + private readonly List _methodsByToken = new(); + private readonly HashSet _ignoresAccessAssemblyNames = new(); + private ConstructorInfo _ignoresAccessChecksToAttributeConstructor; + + public ProxyAssembly() + { + var access = AssemblyBuilderAccess.Run; + var assemblyName = new AssemblyName("ProxyBuilder2") + { + Version = new Version(1, 0, 0) + }; + _ab = AssemblyBuilder.DefineDynamicAssembly(assemblyName, access); + _mb = _ab.DefineDynamicModule("testmod"); + } + + // Gets or creates the ConstructorInfo for the IgnoresAccessChecksAttribute. + // This attribute is both defined and referenced in the dynamic assembly to + // allow access to internal types in other assemblies. + internal ConstructorInfo IgnoresAccessChecksAttributeConstructor + { + get + { + if (_ignoresAccessChecksToAttributeConstructor == null) + { + var attributeTypeInfo = GenerateTypeInfoOfIgnoresAccessChecksToAttribute(); + _ignoresAccessChecksToAttributeConstructor = attributeTypeInfo.DeclaredConstructors.Single(); + } + + return _ignoresAccessChecksToAttributeConstructor; + } + } + + public ProxyBuilder CreateProxy(string name, Type proxyBaseType) + { + var nextId = Interlocked.Increment(ref _typeId); + var tb = _mb.DefineType(name + "_" + nextId, TypeAttributes.Public, proxyBaseType); + return new ProxyBuilder(this, tb, proxyBaseType); + } + + private TypeInfo GenerateTypeInfoOfIgnoresAccessChecksToAttribute() + { + var attributeTypeBuilder = + _mb.DefineType("System.Runtime.CompilerServices.IgnoresAccessChecksToAttribute", + TypeAttributes.Public | TypeAttributes.Class, + typeof(Attribute)); + + // Create backing field as: + // private string assemblyName; + var assemblyNameField = + attributeTypeBuilder.DefineField("assemblyName", typeof(string), FieldAttributes.Private); + + // Create ctor as: + // public IgnoresAccessChecksToAttribute(string) + var constructorBuilder = attributeTypeBuilder.DefineConstructor(MethodAttributes.Public, + CallingConventions.HasThis, + new Type[] { assemblyNameField.FieldType }); + + var il = constructorBuilder.GetILGenerator(); + + // Create ctor body as: + // this.assemblyName = {ctor parameter 0} + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldarg, 1); + il.Emit(OpCodes.Stfld, assemblyNameField); + + // return + il.Emit(OpCodes.Ret); + + // Define property as: + // public string AssemblyName {get { return this.assemblyName; } } + var getterPropertyBuilder = attributeTypeBuilder.DefineProperty( + "AssemblyName", + PropertyAttributes.None, + CallingConventions.HasThis, + returnType: typeof(string), + parameterTypes: null); + + var getterMethodBuilder = attributeTypeBuilder.DefineMethod( + "get_AssemblyName", + MethodAttributes.Public, + CallingConventions.HasThis, + returnType: typeof(string), + parameterTypes: null); + + // Generate body: + // return this.assemblyName; + il = getterMethodBuilder.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, assemblyNameField); + il.Emit(OpCodes.Ret); + + // Generate the AttributeUsage attribute for this attribute type: + // [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + var attributeUsageTypeInfo = typeof(AttributeUsageAttribute).GetTypeInfo(); + + // Find the ctor that takes only AttributeTargets + var attributeUsageConstructorInfo = + attributeUsageTypeInfo.DeclaredConstructors + .Single(c => c.GetParameters().Length == 1 && + c.GetParameters()[0].ParameterType == typeof(AttributeTargets)); + + // Find the property to set AllowMultiple + var allowMultipleProperty = + attributeUsageTypeInfo.DeclaredProperties + .Single(f => string.Equals(f.Name, "AllowMultiple")); + + // Create a builder to construct the instance via the ctor and property + CustomAttributeBuilder customAttributeBuilder = + new(attributeUsageConstructorInfo, + new object[] { AttributeTargets.Assembly }, + new PropertyInfo[] { allowMultipleProperty }, + new object[] { true }); + + // Attach this attribute instance to the newly defined attribute type + attributeTypeBuilder.SetCustomAttribute(customAttributeBuilder); + + // Make the TypeInfo real so the constructor can be used. + return attributeTypeBuilder.CreateTypeInfo(); + } + + // Generates an instance of the IgnoresAccessChecksToAttribute to + // identify the given assembly as one which contains internal types + // the dynamic assembly will need to reference. + internal void GenerateInstanceOfIgnoresAccessChecksToAttribute(string assemblyName) + { + // Add this assembly level attribute: + // [assembly: System.Runtime.CompilerServices.IgnoresAccessChecksToAttribute(assemblyName)] + var attributeConstructor = IgnoresAccessChecksAttributeConstructor; + CustomAttributeBuilder customAttributeBuilder = + new(attributeConstructor, new object[] { assemblyName }); + _ab.SetCustomAttribute(customAttributeBuilder); + } + + // Ensures the type we will reference from the dynamic assembly + // is visible. Non-public types need to emit an attribute that + // allows access from the dynamic assembly. + internal void EnsureTypeIsVisible(Type type) + { + var typeInfo = type.GetTypeInfo(); + if (!typeInfo.IsVisible) + { + var assemblyName = Reflect.GetAssemblyName(typeInfo); + if (!_ignoresAccessAssemblyNames.Contains(assemblyName)) + { + GenerateInstanceOfIgnoresAccessChecksToAttribute(assemblyName); + _ignoresAccessAssemblyNames.Add(assemblyName); + } + } + } + + internal void GetTokenForMethod(MethodBase method, out Type type, out int token) + { + type = method.DeclaringType; + if (!_methodToToken.TryGetValue(method, out token)) + { + _methodsByToken.Add(method); + token = _methodsByToken.Count - 1; + _methodToToken[method] = token; + } + } + + internal MethodBase ResolveMethodToken(Type type, int token) + { + _ = type; + Debug.Assert(token >= 0 && token < _methodsByToken.Count); + return _methodsByToken[token]; + } + } + + private sealed class ProxyBuilder + { + private static readonly MethodInfo s_delegateInvoke = typeof(DispatchProxyHandler).GetMethod("InvokeHandle"); + private static readonly MethodInfo s_delegateInvokeAsync = typeof(DispatchProxyHandler).GetMethod("InvokeAsyncHandle"); + private static readonly MethodInfo s_delegateinvokeAsyncT = typeof(DispatchProxyHandler).GetMethod("InvokeAsyncHandleT"); + + private readonly ProxyAssembly _assembly; + private readonly TypeBuilder _tb; + private readonly Type _proxyBaseType; + private readonly List _fields; + + internal ProxyBuilder(ProxyAssembly assembly, TypeBuilder tb, Type proxyBaseType) + { + _assembly = assembly; + _tb = tb; + _proxyBaseType = proxyBaseType; + + _fields = new List + { + tb.DefineField("_handler", typeof(DispatchProxyHandler), FieldAttributes.Private) + }; + } + + private static bool IsGenericTask(Type type) + { + var current = type; + while (current != null) + { + if (current.GetTypeInfo().IsGenericType && current.GetGenericTypeDefinition() == typeof(Task<>)) + return true; + current = current.GetTypeInfo().BaseType; + } + return false; + } + + private void Complete() + { + var args = new Type[_fields.Count]; + for (var i = 0; i < args.Length; i++) + { + args[i] = _fields[i].FieldType; + } + + var cb = _tb.DefineConstructor(MethodAttributes.Public, CallingConventions.HasThis, args); + var il = cb.GetILGenerator(); + + // chained ctor call + var baseCtor = _proxyBaseType.GetTypeInfo().DeclaredConstructors.SingleOrDefault(c => c.IsPublic && c.GetParameters().Length == 0); + Debug.Assert(baseCtor != null); + + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, baseCtor); + + // store all the fields + for (var i = 0; i < args.Length; i++) + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldarg, i + 1); + il.Emit(OpCodes.Stfld, _fields[i]); + } + + il.Emit(OpCodes.Ret); + } + + internal Type CreateType() + { + Complete(); + return _tb.CreateTypeInfo().AsType(); + } + + internal void AddInterfaceImpl(Type iface) + { + // If necessary, generate an attribute to permit visibility + // to internal types. + _assembly.EnsureTypeIsVisible(iface); + + _tb.AddInterfaceImplementation(iface); + + // AccessorMethods -> Metadata mappings. + var propertyMap = new Dictionary(MethodInfoEqualityComparer.Instance); + foreach (var pi in iface.GetRuntimeProperties()) + { + var ai = new PropertyAccessorInfo(pi.GetMethod, pi.SetMethod); + if (pi.GetMethod != null) + propertyMap[pi.GetMethod] = ai; + if (pi.SetMethod != null) + propertyMap[pi.SetMethod] = ai; + } + + var eventMap = new Dictionary(MethodInfoEqualityComparer.Instance); + foreach (var ei in iface.GetRuntimeEvents()) + { + var ai = new EventAccessorInfo(ei.AddMethod, ei.RemoveMethod, ei.RaiseMethod); + if (ei.AddMethod != null) + eventMap[ei.AddMethod] = ai; + if (ei.RemoveMethod != null) + eventMap[ei.RemoveMethod] = ai; + if (ei.RaiseMethod != null) + eventMap[ei.RaiseMethod] = ai; + } + + foreach (var mi in iface.GetRuntimeMethods()) + { + // 排除静态方法 + if (mi.IsStatic) continue; + + var mdb = AddMethodImpl(mi); + if (propertyMap.TryGetValue(mi, out var associatedProperty)) + { + if (MethodInfoEqualityComparer.Instance.Equals(associatedProperty.InterfaceGetMethod, mi)) + associatedProperty.GetMethodBuilder = mdb; + else + associatedProperty.SetMethodBuilder = mdb; + } + + if (eventMap.TryGetValue(mi, out var associatedEvent)) + { + if (MethodInfoEqualityComparer.Instance.Equals(associatedEvent.InterfaceAddMethod, mi)) + associatedEvent.AddMethodBuilder = mdb; + else if (MethodInfoEqualityComparer.Instance.Equals(associatedEvent.InterfaceRemoveMethod, mi)) + associatedEvent.RemoveMethodBuilder = mdb; + else + associatedEvent.RaiseMethodBuilder = mdb; + } + } + + foreach (var pi in iface.GetRuntimeProperties()) + { + var ai = propertyMap[pi.GetMethod ?? pi.SetMethod]; + var pb = _tb.DefineProperty(pi.Name, pi.Attributes, pi.PropertyType, pi.GetIndexParameters().Select(p => p.ParameterType).ToArray()); + if (ai.GetMethodBuilder != null) + pb.SetGetMethod(ai.GetMethodBuilder); + if (ai.SetMethodBuilder != null) + pb.SetSetMethod(ai.SetMethodBuilder); + } + + foreach (var ei in iface.GetRuntimeEvents()) + { + var ai = eventMap[ei.AddMethod ?? ei.RemoveMethod]; + var eb = _tb.DefineEvent(ei.Name, ei.Attributes, ei.EventHandlerType); + if (ai.AddMethodBuilder != null) + eb.SetAddOnMethod(ai.AddMethodBuilder); + if (ai.RemoveMethodBuilder != null) + eb.SetRemoveOnMethod(ai.RemoveMethodBuilder); + if (ai.RaiseMethodBuilder != null) + eb.SetRaiseMethod(ai.RaiseMethodBuilder); + } + } + + private MethodBuilder AddMethodImpl(MethodInfo mi) + { + var parameters = mi.GetParameters(); + var paramTypes = ParamTypes(parameters, false); + + var mdb = _tb.DefineMethod(mi.Name, MethodAttributes.Public | MethodAttributes.Virtual, mi.ReturnType, paramTypes); + if (mi.ContainsGenericParameters) + { + var ts = mi.GetGenericArguments(); + var ss = new string[ts.Length]; + for (var i = 0; i < ts.Length; i++) + { + ss[i] = ts[i].Name; + } + var genericParameters = mdb.DefineGenericParameters(ss); + for (var i = 0; i < genericParameters.Length; i++) + { + genericParameters[i].SetGenericParameterAttributes(ts[i].GetTypeInfo().GenericParameterAttributes); + } + } + var il = mdb.GetILGenerator(); + + ParametersArray args = new(il, paramTypes); + + // object[] args = new object[paramCount]; + il.Emit(OpCodes.Nop); + var argsArr = new GenericArray(il, ParamTypes(parameters, true).Length); + + for (var i = 0; i < parameters.Length; i++) + { + // args[i] = argi; + if (!parameters[i].IsOut) + { + argsArr.BeginSet(i); + args.Get(i); + argsArr.EndSet(parameters[i].ParameterType); + } + } + + // object[] packed = new object[PackedArgs.PackedTypes.Length]; + GenericArray packedArr = new(il, PackedArgs.PackedTypes.Length); + + // packed[PackedArgs.DispatchProxyPosition] = this; + packedArr.BeginSet(PackedArgs.DispatchProxyPosition); + il.Emit(OpCodes.Ldarg_0); + packedArr.EndSet(typeof(AspectDispatchProxy)); + + // packed[PackedArgs.DeclaringTypePosition] = typeof(iface); + var Type_GetTypeFromHandle = typeof(Type).GetRuntimeMethod("GetTypeFromHandle", new Type[] { typeof(RuntimeTypeHandle) }); + _assembly.GetTokenForMethod(mi, out var declaringType, out var methodToken); + packedArr.BeginSet(PackedArgs.DeclaringTypePosition); + il.Emit(OpCodes.Ldtoken, declaringType); + il.Emit(OpCodes.Call, Type_GetTypeFromHandle); + packedArr.EndSet(typeof(object)); + + // packed[PackedArgs.MethodTokenPosition] = iface method token; + packedArr.BeginSet(PackedArgs.MethodTokenPosition); + il.Emit(OpCodes.Ldc_I4, methodToken); + packedArr.EndSet(typeof(int)); + + // packed[PackedArgs.ArgsPosition] = args; + packedArr.BeginSet(PackedArgs.ArgsPosition); + argsArr.Load(); + packedArr.EndSet(typeof(object[])); + + // packed[PackedArgs.GenericTypesPosition] = mi.GetGenericArguments(); + if (mi.ContainsGenericParameters) + { + packedArr.BeginSet(PackedArgs.GenericTypesPosition); + var genericTypes = mi.GetGenericArguments(); + GenericArray typeArr = new(il, genericTypes.Length); + for (var i = 0; i < genericTypes.Length; ++i) + { + typeArr.BeginSet(i); + il.Emit(OpCodes.Ldtoken, genericTypes[i]); + il.Emit(OpCodes.Call, Type_GetTypeFromHandle); + typeArr.EndSet(typeof(Type)); + } + typeArr.Load(); + packedArr.EndSet(typeof(Type[])); + } + + for (var i = 0; i < parameters.Length; i++) + { + if (parameters[i].ParameterType.IsByRef) + { + args.BeginSet(i); + argsArr.Get(i); + args.EndSet(i, typeof(object)); + } + } + + var invokeMethod = s_delegateInvoke; + if (mi.ReturnType == typeof(Task)) + { + invokeMethod = s_delegateInvokeAsync; + } + if (IsGenericTask(mi.ReturnType)) + { + var returnTypes = mi.ReturnType.GetGenericArguments(); + invokeMethod = s_delegateinvokeAsyncT.MakeGenericMethod(returnTypes); + } + + // Call AsyncDispatchProxyGenerator.Invoke(object[]), InvokeAsync or InvokeAsyncT + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, _fields[InvokeActionFieldAndCtorParameterIndex]); + packedArr.Load(); + il.Emit(OpCodes.Callvirt, invokeMethod); + if (mi.ReturnType != typeof(void)) + { + Convert(il, typeof(object), mi.ReturnType, false); + } + else + { + il.Emit(OpCodes.Pop); + } + + il.Emit(OpCodes.Ret); + + _tb.DefineMethodOverride(mdb, mi); + return mdb; + } + + private static Type[] ParamTypes(ParameterInfo[] parms, bool noByRef) + { + var types = new Type[parms.Length]; + for (var i = 0; i < parms.Length; i++) + { + types[i] = parms[i].ParameterType; + if (noByRef && types[i].IsByRef) + types[i] = types[i].GetElementType(); + } + return types; + } + + // TypeCode does not exist in ProjectK or ProjectN. + // This lookup method was copied from PortableLibraryThunks\Internal\PortableLibraryThunks\System\TypeThunks.cs + // but returns the integer value equivalent to its TypeCode enum. + private static int GetTypeCode(Type type) + { + if (type == null) + return 0; // TypeCode.Empty; + + if (type == typeof(bool)) + return 3; // TypeCode.Boolean; + + if (type == typeof(char)) + return 4; // TypeCode.Char; + + if (type == typeof(sbyte)) + return 5; // TypeCode.SByte; + + if (type == typeof(byte)) + return 6; // TypeCode.Byte; + + if (type == typeof(short)) + return 7; // TypeCode.Int16; + + if (type == typeof(ushort)) + return 8; // TypeCode.UInt16; + + if (type == typeof(int)) + return 9; // TypeCode.Int32; + + if (type == typeof(uint)) + return 10; // TypeCode.UInt32; + + if (type == typeof(long)) + return 11; // TypeCode.Int64; + + if (type == typeof(ulong)) + return 12; // TypeCode.UInt64; + + if (type == typeof(float)) + return 13; // TypeCode.Single; + + if (type == typeof(double)) + return 14; // TypeCode.Double; + + if (type == typeof(decimal)) + return 15; // TypeCode.Decimal; + + if (type == typeof(DateTime)) + return 16; // TypeCode.DateTime; + + if (type == typeof(string)) + return 18; // TypeCode.String; + + if (type.GetTypeInfo().IsEnum) + return GetTypeCode(Enum.GetUnderlyingType(type)); + + return 1; // TypeCode.Object; + } + + private static readonly OpCode[] s_convOpCodes = new OpCode[] { + OpCodes.Nop,//Empty = 0, + OpCodes.Nop,//Object = 1, + OpCodes.Nop,//DBNull = 2, + OpCodes.Conv_I1,//Boolean = 3, + OpCodes.Conv_I2,//Char = 4, + OpCodes.Conv_I1,//SByte = 5, + OpCodes.Conv_U1,//Byte = 6, + OpCodes.Conv_I2,//Int16 = 7, + OpCodes.Conv_U2,//UInt16 = 8, + OpCodes.Conv_I4,//Int32 = 9, + OpCodes.Conv_U4,//UInt32 = 10, + OpCodes.Conv_I8,//Int64 = 11, + OpCodes.Conv_U8,//UInt64 = 12, + OpCodes.Conv_R4,//Single = 13, + OpCodes.Conv_R8,//Double = 14, + OpCodes.Nop,//Decimal = 15, + OpCodes.Nop,//DateTime = 16, + OpCodes.Nop,//17 + OpCodes.Nop,//String = 18, + }; + + private static readonly OpCode[] s_ldindOpCodes = new OpCode[] { + OpCodes.Nop,//Empty = 0, + OpCodes.Nop,//Object = 1, + OpCodes.Nop,//DBNull = 2, + OpCodes.Ldind_I1,//Boolean = 3, + OpCodes.Ldind_I2,//Char = 4, + OpCodes.Ldind_I1,//SByte = 5, + OpCodes.Ldind_U1,//Byte = 6, + OpCodes.Ldind_I2,//Int16 = 7, + OpCodes.Ldind_U2,//UInt16 = 8, + OpCodes.Ldind_I4,//Int32 = 9, + OpCodes.Ldind_U4,//UInt32 = 10, + OpCodes.Ldind_I8,//Int64 = 11, + OpCodes.Ldind_I8,//UInt64 = 12, + OpCodes.Ldind_R4,//Single = 13, + OpCodes.Ldind_R8,//Double = 14, + OpCodes.Nop,//Decimal = 15, + OpCodes.Nop,//DateTime = 16, + OpCodes.Nop,//17 + OpCodes.Ldind_Ref,//String = 18, + }; + + private static readonly OpCode[] s_stindOpCodes = new OpCode[] { + OpCodes.Nop,//Empty = 0, + OpCodes.Nop,//Object = 1, + OpCodes.Nop,//DBNull = 2, + OpCodes.Stind_I1,//Boolean = 3, + OpCodes.Stind_I2,//Char = 4, + OpCodes.Stind_I1,//SByte = 5, + OpCodes.Stind_I1,//Byte = 6, + OpCodes.Stind_I2,//Int16 = 7, + OpCodes.Stind_I2,//UInt16 = 8, + OpCodes.Stind_I4,//Int32 = 9, + OpCodes.Stind_I4,//UInt32 = 10, + OpCodes.Stind_I8,//Int64 = 11, + OpCodes.Stind_I8,//UInt64 = 12, + OpCodes.Stind_R4,//Single = 13, + OpCodes.Stind_R8,//Double = 14, + OpCodes.Nop,//Decimal = 15, + OpCodes.Nop,//DateTime = 16, + OpCodes.Nop,//17 + OpCodes.Stind_Ref,//String = 18, + }; + + private static void Convert(ILGenerator il, Type source, Type target, bool isAddress) + { + Debug.Assert(!target.IsByRef); + if (target == source) + return; + + var sourceTypeInfo = source.GetTypeInfo(); + var targetTypeInfo = target.GetTypeInfo(); + + if (source.IsByRef) + { + Debug.Assert(!isAddress); + var argType = source.GetElementType(); + Ldind(il, argType); + Convert(il, argType, target, isAddress); + return; + } + if (targetTypeInfo.IsValueType) + { + if (sourceTypeInfo.IsValueType) + { + var opCode = s_convOpCodes[GetTypeCode(target)]; + Debug.Assert(!opCode.Equals(OpCodes.Nop)); + il.Emit(opCode); + } + else + { + Debug.Assert(sourceTypeInfo.IsAssignableFrom(targetTypeInfo)); + il.Emit(OpCodes.Unbox, target); + if (!isAddress) + Ldind(il, target); + } + } + else if (targetTypeInfo.IsAssignableFrom(sourceTypeInfo)) + { + if (sourceTypeInfo.IsValueType || source.IsGenericParameter) + { + if (isAddress) + Ldind(il, source); + il.Emit(OpCodes.Box, source); + } + } + else + { + Debug.Assert(sourceTypeInfo.IsAssignableFrom(targetTypeInfo) || targetTypeInfo.IsInterface || sourceTypeInfo.IsInterface); + if (target.IsGenericParameter) + { + il.Emit(OpCodes.Unbox_Any, target); + } + else + { + il.Emit(OpCodes.Castclass, target); + } + } + } + + private static void Ldind(ILGenerator il, Type type) + { + var opCode = s_ldindOpCodes[GetTypeCode(type)]; + if (!opCode.Equals(OpCodes.Nop)) + { + il.Emit(opCode); + } + else + { + il.Emit(OpCodes.Ldobj, type); + } + } + + private static void Stind(ILGenerator il, Type type) + { + var opCode = s_stindOpCodes[GetTypeCode(type)]; + if (!opCode.Equals(OpCodes.Nop)) + { + il.Emit(opCode); + } + else + { + il.Emit(OpCodes.Stobj, type); + } + } + + private sealed class ParametersArray + { + private readonly ILGenerator _il; + private readonly Type[] _paramTypes; + + internal ParametersArray(ILGenerator il, Type[] paramTypes) + { + _il = il; + _paramTypes = paramTypes; + } + + internal void Get(int i) + { + _il.Emit(OpCodes.Ldarg, i + 1); + } + + internal void BeginSet(int i) + { + _il.Emit(OpCodes.Ldarg, i + 1); + } + + internal void EndSet(int i, Type stackType) + { + Debug.Assert(_paramTypes[i].IsByRef); + var argType = _paramTypes[i].GetElementType(); + Convert(_il, stackType, argType, false); + Stind(_il, argType); + } + } + + private sealed class GenericArray + { + private readonly ILGenerator _il; + private readonly LocalBuilder _lb; + + internal GenericArray(ILGenerator il, int len) + { + _il = il; + _lb = il.DeclareLocal(typeof(T[])); + + il.Emit(OpCodes.Ldc_I4, len); + il.Emit(OpCodes.Newarr, typeof(T)); + il.Emit(OpCodes.Stloc, _lb); + } + + internal void Load() + { + _il.Emit(OpCodes.Ldloc, _lb); + } + + internal void Get(int i) + { + _il.Emit(OpCodes.Ldloc, _lb); + _il.Emit(OpCodes.Ldc_I4, i); + _il.Emit(OpCodes.Ldelem_Ref); + } + + internal void BeginSet(int i) + { + _il.Emit(OpCodes.Ldloc, _lb); + _il.Emit(OpCodes.Ldc_I4, i); + } + + internal void EndSet(Type stackType) + { + Convert(_il, stackType, typeof(T), false); + _il.Emit(OpCodes.Stelem_Ref); + } + } + + private sealed class PropertyAccessorInfo + { + public MethodInfo InterfaceGetMethod { get; } + public MethodInfo InterfaceSetMethod { get; } + public MethodBuilder GetMethodBuilder { get; set; } + public MethodBuilder SetMethodBuilder { get; set; } + + public PropertyAccessorInfo(MethodInfo interfaceGetMethod, MethodInfo interfaceSetMethod) + { + InterfaceGetMethod = interfaceGetMethod; + InterfaceSetMethod = interfaceSetMethod; + } + } + + private sealed class EventAccessorInfo + { + public MethodInfo InterfaceAddMethod { get; } + public MethodInfo InterfaceRemoveMethod { get; } + public MethodInfo InterfaceRaiseMethod { get; } + public MethodBuilder AddMethodBuilder { get; set; } + public MethodBuilder RemoveMethodBuilder { get; set; } + public MethodBuilder RaiseMethodBuilder { get; set; } + + public EventAccessorInfo(MethodInfo interfaceAddMethod, MethodInfo interfaceRemoveMethod, MethodInfo interfaceRaiseMethod) + { + InterfaceAddMethod = interfaceAddMethod; + InterfaceRemoveMethod = interfaceRemoveMethod; + InterfaceRaiseMethod = interfaceRaiseMethod; + } + } + + private sealed class MethodInfoEqualityComparer : EqualityComparer + { + public static readonly MethodInfoEqualityComparer Instance = new(); + + private MethodInfoEqualityComparer() + { + } + + public override sealed bool Equals(MethodInfo left, MethodInfo right) + { + if (ReferenceEquals(left, right)) + return true; + + if (left == null) + return right == null; + else if (right == null) + return false; + + // This assembly should work in netstandard1.3, + // so we cannot use MemberInfo.MetadataToken here. + // Therefore, it compares honestly referring ECMA-335 I.8.6.1.6 Signature Matching. + if (!Equals(left.DeclaringType, right.DeclaringType)) + return false; + + if (!Equals(left.ReturnType, right.ReturnType)) + return false; + + if (left.CallingConvention != right.CallingConvention) + return false; + + if (left.IsStatic != right.IsStatic) + return false; + + if (left.Name != right.Name) + return false; + + var leftGenericParameters = left.GetGenericArguments(); + var rightGenericParameters = right.GetGenericArguments(); + if (leftGenericParameters.Length != rightGenericParameters.Length) + return false; + + for (var i = 0; i < leftGenericParameters.Length; i++) + { + if (!Equals(leftGenericParameters[i], rightGenericParameters[i])) + return false; + } + + var leftParameters = left.GetParameters(); + var rightParameters = right.GetParameters(); + if (leftParameters.Length != rightParameters.Length) + return false; + + for (var i = 0; i < leftParameters.Length; i++) + { + if (!Equals(leftParameters[i].ParameterType, rightParameters[i].ParameterType)) + return false; + } + + return true; + } + + public override sealed int GetHashCode(MethodInfo obj) + { + if (obj == null) + return 0; + + var hashCode = obj.DeclaringType.GetHashCode(); + hashCode ^= obj.Name.GetHashCode(); + foreach (var parameter in obj.GetParameters()) + { + hashCode ^= parameter.ParameterType.GetHashCode(); + } + + return hashCode; + } + } + } +} + +/// +/// 代理分发处理 +/// +public class DispatchProxyHandler +{ + /// + /// 构造函数 + /// + public DispatchProxyHandler() + { + } + + /// + /// 同步处理 + /// + /// + /// + public object InvokeHandle(object[] args) + { + return AspectDispatchProxyGenerator.Invoke(args); + } + + /// + /// 异步处理 + /// + /// + /// + public Task InvokeAsyncHandle(object[] args) + { + return AspectDispatchProxyGenerator.InvokeAsync(args); + } + + /// + /// 异步带返回值处理 + /// + /// + /// + /// + public Task InvokeAsyncHandleT(object[] args) + { + return AspectDispatchProxyGenerator.InvokeAsync(args); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Reflection/Proxies/IDispatchProxy.cs b/src/Admin/ThingsGateway.Furion/Reflection/Proxies/IDispatchProxy.cs new file mode 100644 index 000000000..8c557e41a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Reflection/Proxies/IDispatchProxy.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Reflection; + +/// +/// 代理拦截依赖接口 +/// +public interface IDispatchProxy +{ + /// + /// 实例 + /// + object Target { get; set; } + + /// + /// 服务提供器 + /// + IServiceProvider Services { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Reflection/Proxies/IGlobalDispatchProxy.cs b/src/Admin/ThingsGateway.Furion/Reflection/Proxies/IGlobalDispatchProxy.cs new file mode 100644 index 000000000..1cdc48372 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Reflection/Proxies/IGlobalDispatchProxy.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Reflection; + +/// +/// 全局代理拦截接口 +/// +public interface IGlobalDispatchProxy : IDispatchProxy +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Reflection/Reflect.cs b/src/Admin/ThingsGateway.Furion/Reflection/Reflect.cs new file mode 100644 index 000000000..961050942 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Reflection/Reflect.cs @@ -0,0 +1,153 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; +using System.Runtime.Loader; + +using ThingsGateway.NewLife.Log; + +namespace ThingsGateway.Reflection; + +/// +/// 内部反射静态类 +/// +internal static class Reflect +{ + /// + /// 获取入口程序集 + /// + /// + internal static Assembly GetEntryAssembly() + { + return Assembly.GetEntryAssembly(); + } + + /// + /// 根据程序集名称获取运行时程序集 + /// + /// + /// + internal static Assembly GetAssembly(string assemblyName) + { + // 加载程序集 + try + { + return AssemblyLoadContext.Default.LoadFromAssemblyName(new AssemblyName(assemblyName)); + } + catch (Exception ex) + { + XTrace.WriteException(ex); + return null; + } + } + + /// + /// 根据路径加载程序集 + /// + /// + /// + internal static Assembly LoadAssembly(string path) + { + if (!File.Exists(path)) return default; + return Assembly.LoadFrom(path); + } + + /// + /// 通过流加载程序集 + /// + /// + /// + internal static Assembly LoadAssembly(MemoryStream assembly) + { + return Assembly.Load(assembly.ToArray()); + } + + /// + /// 根据程序集名称、类型完整限定名获取运行时类型 + /// + /// + /// + /// + internal static Type GetType(string assemblyName, string typeFullName) + { + return GetAssembly(assemblyName).GetType(typeFullName); + } + + /// + /// 根据程序集和类型完全限定名获取运行时类型 + /// + /// + /// + /// + internal static Type GetType(Assembly assembly, string typeFullName) + { + return assembly.GetType(typeFullName); + } + + /// + /// 根据程序集和类型完全限定名获取运行时类型 + /// + /// + /// + /// + internal static Type GetType(MemoryStream assembly, string typeFullName) + { + return LoadAssembly(assembly).GetType(typeFullName); + } + + /// + /// 获取程序集名称 + /// + /// + /// + internal static string GetAssemblyName(Assembly assembly) + { + return assembly.GetName().Name; + } + + /// + /// 获取程序集名称 + /// + /// + /// + internal static string GetAssemblyName(Type type) + { + return GetAssemblyName(type.GetTypeInfo()); + } + + /// + /// 获取程序集名称 + /// + /// + /// + internal static string GetAssemblyName(TypeInfo typeInfo) + { + return GetAssemblyName(typeInfo.Assembly); + } + + /// + /// 加载程序集类型,支持格式:程序集;完全限定的类型名称 + /// + /// + /// + internal static Type GetStringType(string str) + { + var typeDefinitions = str.Split(';'); + + // 类型格式必须以分号作为分隔程序集名称和完全限定的类型名称 + if (typeDefinitions.Length != 2) + { + throw new InvalidOperationException("The type format must use a semicolon as the separator between the assembly name and the fully qualified type name."); + } + + return GetType(typeDefinitions[0], typeDefinitions[1]); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/CronAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/CronAttribute.cs new file mode 100644 index 000000000..e7ecdbe73 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/CronAttribute.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.TimeCrontab; + +namespace ThingsGateway.Schedule; + +/// +/// Cron 表达式作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class CronAttribute : TriggerAttribute +{ + /// + /// 构造函数 + /// + /// Cron 表达式 + /// Cron 表达式格式化类型 + public CronAttribute(string schedule, CronStringFormat format = CronStringFormat.Default) + : base(typeof(CronTrigger) + , schedule, format) + { + } + + /// + /// 构造函数 + /// + /// Cron 表达式 + /// 动态参数类型,支持 和 object[] + internal CronAttribute(string schedule, object args) + : base(typeof(CronTrigger) + , schedule, args) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/JobDetailAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/JobDetailAttribute.cs new file mode 100644 index 000000000..baa7d25bc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/JobDetailAttribute.cs @@ -0,0 +1,87 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json.Serialization; + +namespace ThingsGateway.Schedule; + +/// +/// 配置作业信息特性 +/// +/// 仅限 实现类使用 +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public sealed class JobDetailAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// 作业 Id + public JobDetailAttribute(string jobId) + { + JobId = jobId; + } + + /// + /// 构造函数 + /// + /// 作业 Id + /// 作业描述 + public JobDetailAttribute(string jobId, string description) + : this(jobId) + { + Description = description; + } + + /// + /// 构造函数 + /// + /// 作业 Id + /// 并行/串行 + public JobDetailAttribute(string jobId, bool concurrent) + : this(jobId) + { + Concurrent = concurrent; + } + + /// + /// 构造函数 + /// + /// 作业 Id + /// 并行/串行 + /// 作业描述 + public JobDetailAttribute(string jobId, bool concurrent, string description) + : this(jobId, concurrent) + { + Description = description; + } + + /// + /// 作业 Id + /// + [JsonInclude] + public string JobId { get; set; } + + /// + /// 作业组名称 + /// + public string GroupName { get; set; } + + /// + /// 描述信息 + /// + public string Description { get; set; } + + /// + /// 是否采用并行执行 + /// + /// 如果设置为 false,那么使用串行执行 + public bool Concurrent { get; set; } = true; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/DailyAtAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/DailyAtAttribute.cs new file mode 100644 index 000000000..d9c744738 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/DailyAtAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每天特定小时开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class DailyAtAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + /// 字段值 + public DailyAtAttribute(params object[] fields) + : base("@daily", fields) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/HourlyAtAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/HourlyAtAttribute.cs new file mode 100644 index 000000000..9b744a9dd --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/HourlyAtAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每小时特定分钟开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class HourlyAtAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + /// 字段值 + public HourlyAtAttribute(params object[] fields) + : base("@hourly", fields) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/MinutelyAtAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/MinutelyAtAttribute.cs new file mode 100644 index 000000000..972a935bc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/MinutelyAtAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每分钟特定秒开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class MinutelyAtAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + /// 字段值 + public MinutelyAtAttribute(params object[] fields) + : base("@minutely", fields) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/MonthlyAtAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/MonthlyAtAttribute.cs new file mode 100644 index 000000000..0aab817cc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/MonthlyAtAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每月特定天(午夜)开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class MonthlyAtAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + /// 字段值 + public MonthlyAtAttribute(params object[] fields) + : base("@monthly", fields) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/SecondlyAtAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/SecondlyAtAttribute.cs new file mode 100644 index 000000000..427cbe6ec --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/SecondlyAtAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 特定秒开始作业触发器特性 +/// +[SecondlyAtAttribute, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class SecondlyAtAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + /// 字段值 + public SecondlyAtAttribute(params object[] fields) + : base("@secondly", fields) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/WeeklyAtAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/WeeklyAtAttribute.cs new file mode 100644 index 000000000..338a5e5a2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/WeeklyAtAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每周特定星期几(午夜)开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class WeeklyAtAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + /// 字段值 + public WeeklyAtAttribute(params object[] fields) + : base("@weekly", fields) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/YearlyAtAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/YearlyAtAttribute.cs new file mode 100644 index 000000000..b3a736931 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/MacroAts/YearlyAtAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每年特定月1号(午夜)开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class YearlyAtAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + /// 字段值 + public YearlyAtAttribute(params object[] fields) + : base("@yearly", fields) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/DailyAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/DailyAttribute.cs new file mode 100644 index 000000000..d19c6a89f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/DailyAttribute.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每天(午夜)开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class DailyAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + public DailyAttribute() + : base("@daily") + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/HourlyAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/HourlyAttribute.cs new file mode 100644 index 000000000..37f48c80f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/HourlyAttribute.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每小时开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class HourlyAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + public HourlyAttribute() + : base("@hourly") + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/MinutelyAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/MinutelyAttribute.cs new file mode 100644 index 000000000..76ed6ab88 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/MinutelyAttribute.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每分钟开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class MinutelyAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + public MinutelyAttribute() + : base("@minutely") + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/MonthlyAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/MonthlyAttribute.cs new file mode 100644 index 000000000..5ef54a417 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/MonthlyAttribute.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每月1号(午夜)开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class MonthlyAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + public MonthlyAttribute() + : base("@monthly") + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/SecondlyAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/SecondlyAttribute.cs new file mode 100644 index 000000000..8ac00a502 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/SecondlyAttribute.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每秒开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class SecondlyAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + public SecondlyAttribute() + : base("@secondly") + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/WeeklyAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/WeeklyAttribute.cs new file mode 100644 index 000000000..9c97e9de9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/WeeklyAttribute.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每周日(午夜)开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class WeeklyAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + public WeeklyAttribute() + : base("@weekly") + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/WorkdayAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/WorkdayAttribute.cs new file mode 100644 index 000000000..fd040eb26 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/WorkdayAttribute.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每周一至周五(午夜)开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class WorkdayAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + public WorkdayAttribute() + : base("@workday") + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/YearlyAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/YearlyAttribute.cs new file mode 100644 index 000000000..cecc42e03 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Macros/YearlyAttribute.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 每年1月1号(午夜)开始作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class YearlyAttribute : CronAttribute +{ + /// + /// 构造函数 + /// + public YearlyAttribute() + : base("@yearly") + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/PeriodAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/PeriodAttribute.cs new file mode 100644 index 000000000..20d966b7d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/PeriodAttribute.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 毫秒周期(间隔)作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public class PeriodAttribute : TriggerAttribute +{ + /// + /// 构造函数 + /// + /// 间隔(毫秒) + public PeriodAttribute(long interval) + : base(typeof(PeriodTrigger) + , interval) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Periods/PeriodHoursAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Periods/PeriodHoursAttribute.cs new file mode 100644 index 000000000..80760a5a9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Periods/PeriodHoursAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 小时周期(间隔)作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class PeriodHoursAttribute : PeriodAttribute +{ + /// + /// 构造函数 + /// + /// 间隔(小时) + public PeriodHoursAttribute(long interval) + : base(interval * 1000 * 60 * 60) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Periods/PeriodMinutesAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Periods/PeriodMinutesAttribute.cs new file mode 100644 index 000000000..a4281130d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Periods/PeriodMinutesAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 分钟周期(间隔)作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class PeriodMinutesAttribute : PeriodAttribute +{ + /// + /// 构造函数 + /// + /// 间隔(分钟) + public PeriodMinutesAttribute(long interval) + : base(interval * 1000 * 60) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Periods/PeriodSecondsAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Periods/PeriodSecondsAttribute.cs new file mode 100644 index 000000000..c257ad4e5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/Periods/PeriodSecondsAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 秒周期(间隔)作业触发器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public sealed class PeriodSecondsAttribute : PeriodAttribute +{ + /// + /// 构造函数 + /// + /// 间隔(秒) + public PeriodSecondsAttribute(long interval) + : base(interval * 1000) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Attributes/TriggerAttribute.cs b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/TriggerAttribute.cs new file mode 100644 index 000000000..0a62d2f07 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Attributes/TriggerAttribute.cs @@ -0,0 +1,147 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业触发器特性基类 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] +public abstract class TriggerAttribute : Attribute +{ + /// + /// 私有开始时间 + /// + private string _startTime; + + /// + /// 私有结束时间 + /// + private string _endTime; + + /// + /// 构造函数 + /// + /// 作业触发器类型 + /// 作业触发器参数 + public TriggerAttribute(Type triggerType, params object[] args) + { + RuntimeTriggerType = triggerType; + RuntimeTriggerArgs = args; + } + + /// + /// 作业触发器 Id + /// + public string TriggerId { get; set; } + + /// + /// 描述信息 + /// + public string Description { get; set; } + + /// + /// 起始时间 + /// + public string StartTime + { + get { return _startTime; } + set + { + _startTime = value; + + // 解析运行时开始时间 + if (string.IsNullOrWhiteSpace(value)) RuntimeStartTime = null; + else RuntimeStartTime = Convert.ToDateTime(value); + } + } + + /// + /// 结束时间 + /// + public string EndTime + { + get { return _endTime; } + set + { + _endTime = value; + + // 解析运行时结束时间 + if (string.IsNullOrWhiteSpace(value)) RuntimeEndTime = null; + else RuntimeEndTime = Convert.ToDateTime(value); + } + } + + /// + /// 最大触发次数 + /// + /// + /// 0:不限制 + /// n:N 次 + /// + public long MaxNumberOfRuns { get; set; } + + /// + /// 最大出错次数 + /// + /// + /// 0:不限制 + /// n:N 次 + /// + public long MaxNumberOfErrors { get; set; } + + /// + /// 重试次数 + /// + public int NumRetries { get; set; } = 0; + + /// + /// 重试间隔时间 + /// + /// 默认1000毫秒 + public int RetryTimeout { get; set; } = 1000; + + /// + /// 是否立即启动 + /// + public bool StartNow { get; set; } = true; + + /// + /// 是否启动时执行一次 + /// + public bool RunOnStart { get; set; } = false; + + /// + /// 是否在启动时重置最大触发次数等于一次的作业 + /// + /// 解决因持久化数据已完成一次触发但启动时不再执行的问题 + public bool ResetOnlyOnce { get; set; } = true; + + /// + /// 作业触发器运行时起始时间 + /// + internal DateTime? RuntimeStartTime { get; set; } + + /// + /// 作业触发器运行时结束时间 + /// + internal DateTime? RuntimeEndTime { get; set; } + + /// + /// 作业触发器运行时类型 + /// + internal Type RuntimeTriggerType { get; set; } + + /// + /// 作业触发器运行时参数 + /// + internal object[] RuntimeTriggerArgs { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Builders/JobBuilder.cs b/src/Admin/ThingsGateway.Furion/Schedule/Builders/JobBuilder.cs new file mode 100644 index 000000000..307ccdc04 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Builders/JobBuilder.cs @@ -0,0 +1,389 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业信息构建器 +/// +[SuppressSniffer] +public sealed class JobBuilder : JobDetail +{ + /// + /// 构造函数 + /// + private JobBuilder() + { + } + + /// + /// 创建作业信息构建器 + /// + /// 作业 Id + /// + public static JobBuilder Create(string jobId) + { + return new JobBuilder() + .SetJobId(jobId); + } + + /// + /// 创建作业信息构建器 + /// + /// 实现类型 + /// + public static JobBuilder Create() + where TJob : class, IJob + { + return Create(typeof(TJob)); + } + + /// + /// 创建作业信息构建器 + /// + /// 作业类型所在程序集 Name + /// 作业类型 FullName + /// + public static JobBuilder Create(string assemblyName, string jobTypeFullName) + { + return new JobBuilder() + .SetJobType(assemblyName, jobTypeFullName); + } + + /// + /// 创建作业信息构建器 + /// + /// 作业类型 + /// + public static JobBuilder Create(Type jobType) + { + return new JobBuilder() + .SetJobType(jobType); + } + + /// + /// 创建作业信息构建器 + /// + /// 运行时动态作业执行逻辑 + /// + public static JobBuilder Create(Func dynamicExecuteAsync) + { + return Create() + .SetDynamicExecuteAsync(dynamicExecuteAsync); + } + + /// + /// 将 转换成 + /// + /// 作业信息 + /// + public static JobBuilder From(JobDetail jobDetail) + { + var jobBuilder = jobDetail.MapTo(); + + // 初始化运行时作业类型和额外数据 + jobBuilder.SetJobType(jobBuilder.AssemblyName, jobBuilder.JobType) + .SetProperties(jobBuilder.Properties); + + return jobBuilder; + } + + /// + /// 将 JSON 字符串转换成 + /// + /// JSON 字符串 + /// + public static JobBuilder From(string json) + { + return From(Penetrates.Deserialize(json)); + } + + /// + /// 克隆作业信息构建器 + /// + /// 被克隆的作业信息构建器 + /// + public static JobBuilder Clone(JobBuilder fromJobBuilder) + { + return Create(fromJobBuilder.AssemblyName, fromJobBuilder.JobType) + .SetGroupName(fromJobBuilder.GroupName) + .SetDescription(fromJobBuilder.Description) + .SetConcurrent(fromJobBuilder.Concurrent) + .SetIncludeAnnotations(fromJobBuilder.IncludeAnnotations) + .SetProperties(fromJobBuilder.Properties) + .SetDynamicExecuteAsync(fromJobBuilder.DynamicExecuteAsync); + } + + /// + /// 从目标值填充到作业信息构建器 + /// + /// 目标值 + /// 忽略空值 + /// 忽略属性名 + /// + public JobBuilder LoadFrom(object value, bool ignoreNullValue = false, string[] ignorePropertyNames = default) + { + if (value == null) return this; + + // 排除枚举类型,接口类型,数组类型,值类型 + var valueType = value.GetType(); + if (valueType.IsInterface + || valueType.IsValueType + || valueType.IsEnum + || valueType.IsArray) throw new InvalidOperationException(nameof(value)); + + var jobBuilder = value.MapTo(this, ignoreNullValue, ignorePropertyNames); + + // 初始化运行时作业类型和额外数据 + jobBuilder.SetJobType(jobBuilder.AssemblyName, jobBuilder.JobType) + .SetProperties(jobBuilder.Properties); + + return jobBuilder; + } + + /// + /// 设置作业 Id + /// + /// 作业 Id + /// + /// + public JobBuilder SetJobId(string jobId) + { + JobId = jobId; + + return this; + } + + /// + /// 设置作业组名称 + /// + /// 作业组名称 + /// + /// + public JobBuilder SetGroupName(string groupName) + { + GroupName = groupName; + + return this; + } + + /// + /// 设置作业类型 + /// + /// 作业类型所在程序集 Name + /// 作业类型 FullName + /// + public JobBuilder SetJobType(string assemblyName, string jobTypeFullName) + { + AssemblyName = assemblyName; + JobType = jobTypeFullName; + + // 只有 assemblyName 和 jobTypeFullName 同时存在才创建类型 + if (!string.IsNullOrWhiteSpace(assemblyName) + && !string.IsNullOrWhiteSpace(jobTypeFullName)) + { + // 加载 GAC 全局应用程序缓存中的程序集及类型 + var jobType = Penetrates.LoadAssembly(assemblyName) + .GetType(jobTypeFullName); + + + return SetJobType(jobType); + } + + return this; + } + + /// + /// 设置作业类型 + /// + /// 实现类类型 + /// + public JobBuilder SetJobType() + where TJob : IJob + { + return SetJobType(typeof(TJob)); + } + + /// + /// 设置作业类型 + /// + /// 作业类型 + /// + public JobBuilder SetJobType(Type jobType) + { + // 不做 null 检查 + if (jobType == null) return this; + + // 检查 jobType 类型是否实现 IJob 接口 + if (!jobType.IsJobType()) throw new InvalidOperationException("The does not implement IJob interface."); + + AssemblyName = jobType.Assembly.GetName().Name; + JobType = jobType.FullName; + RuntimeJobType = jobType; + + + + return this; + } + + /// + /// 设置描述信息 + /// + /// 描述信息 + /// + public JobBuilder SetDescription(string description) + { + Description = description; + + return this; + } + + /// + /// 设置是否采用并发执行 + /// + /// 是否并发执行 + /// + public JobBuilder SetConcurrent(bool concurrent) + { + Concurrent = concurrent; + + return this; + } + + /// + /// 设置是否扫描 IJob 实现类 [Trigger] 特性触发器 + /// + /// 是否扫描 IJob 实现类 [Trigger] 特性触发器 + /// + public JobBuilder SetIncludeAnnotations(bool includeAnnotations) + { + IncludeAnnotations = includeAnnotations; + + return this; + } + + /// + /// 设置运行时动态作业执行逻辑 + /// + /// 运行时动态作业执行逻辑 + /// + public JobBuilder SetDynamicExecuteAsync(Func dynamicExecuteAsync) + { + DynamicExecuteAsync = dynamicExecuteAsync; + + return this; + } + + /// + /// 设置作业信息额外数据 + /// + /// 作业信息额外数据 + /// 必须是 Dictionary{string, object} 类型序列化的结果 + /// + public JobBuilder SetProperties(string properties) + { + if (string.IsNullOrWhiteSpace(properties)) properties = "{}"; + + Properties = properties; + var jsonDictionary = Penetrates.Deserialize>(properties); + + // 解决反序列化 object 类型被转换成了 JsonElement 类型 + var newDictionary = new Dictionary(jsonDictionary.Count); + foreach (var key in jsonDictionary.Keys) + { + newDictionary[key] = Penetrates.GetJsonElementValue(jsonDictionary[key]); + } + + RuntimeProperties = newDictionary; + + return this; + } + + /// + /// 设置作业信息额外数据 + /// + /// 作业信息额外数据 + /// 必须是 Dictionary{string, object} 类型序列化的结果 + /// + public JobBuilder SetProperties(Dictionary properties) + { + properties ??= new(); + + Properties = Penetrates.Serialize(properties); + RuntimeProperties = properties; + + return this; + } + + /// + /// 添加作业信息额外数据 + /// + /// 键 + /// 值 + /// + public new JobBuilder AddProperty(string key, object value) + { + return base.AddProperty(key, value) as JobBuilder; + } + + /// + /// 添加或更新作业信息额外数据 + /// + /// 值类型 + /// 键 + /// 新值 + /// 更新委托,如果传递了该参数,那么键存在使则使用该参数的返回值 + /// + public new JobBuilder AddOrUpdateProperty(string key, T newValue, Func updateAction = default) + { + return base.AddOrUpdateProperty(key, newValue, updateAction) as JobBuilder; + } + + /// + /// 删除作业信息额外数据 + /// + /// 键 + /// + public new JobBuilder RemoveProperty(string key) + { + return base.RemoveProperty(key) as JobBuilder; + } + + /// + /// 清空作业信息额外数据 + /// + /// + public new JobBuilder ClearProperties() + { + return base.ClearProperties() as JobBuilder; + } + + /// + /// 构建 对象 + /// + /// + internal JobDetail Build() + { + // 空检查 + if (string.IsNullOrWhiteSpace(JobId)) throw new ArgumentNullException(nameof(JobId)); + + // 二次检查委托方式作业类型 + if (DynamicExecuteAsync != null && RuntimeJobType != typeof(DynamicJob)) + { + SetJobType(); + } + + // 避免类型还未初始化,强制检查一次 + SetJobType(AssemblyName, JobType); + SetProperties(Properties); + + return this.MapTo(); + } +} diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Builders/ScheduleOptionsBuilder.cs b/src/Admin/ThingsGateway.Furion/Schedule/Builders/ScheduleOptionsBuilder.cs new file mode 100644 index 000000000..5de9f1348 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Builders/ScheduleOptionsBuilder.cs @@ -0,0 +1,582 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +using System.Reflection; + +namespace ThingsGateway.Schedule; + +/// +/// 作业调度器配置选项构建器 +/// +[SuppressSniffer] +public sealed class ScheduleOptionsBuilder +{ + /// + /// 作业计划构建器集合 + /// + private readonly List _schedulerBuilders = new(); + + /// + /// 作业处理程序监视器 + /// + private Type _jobMonitor; + + /// + /// 作业处理程序执行器 + /// + private Type _jobExecutor; + + /// + /// 作业调度持久化器 + /// + private Type _jobPersistence; + + /// + /// 作业集群服务 + /// + private Type _jobClusterServer; + + /// + /// 作业处理程序工厂 + /// + private Type _jobFactory; + + /// + /// 当前作业组名称 + /// + internal string _groupSet; + + /// + /// 未察觉任务异常事件处理程序 + /// + public EventHandler UnobservedTaskExceptionHandler { get; set; } + + /// + /// 是否使用 UTC 时间,默认 false + /// + public bool UseUtcTimestamp + { + get => UseUtcTimestampProperty; + set + { + UseUtcTimestampProperty = value; + } + } + + /// + /// 是否启用日志记录 + /// + public bool LogEnabled { get; set; } = true; + + /// + /// 作业集群 Id + /// + public string ClusterId { get; set; } = string.Empty; + + /// + /// 作业信息配置选项 + /// + public JobDetailOptions JobDetail { get; } = new(); + + /// + /// 作业触发器配置选项 + /// + public TriggerOptions Trigger { get; } = new(); + + /// + /// 公开配置 + /// + public static bool UseUtcTimestampProperty { get; private set; } = false; + + /// + /// 生成 SQL 的类型 + /// + public SqlTypes BuildSqlType + { + get + { + return InternalBuildSqlType; + } + set + { + InternalBuildSqlType = value; + } + } + + /// + /// 内部生成 SQL 的类型 + /// + internal static SqlTypes InternalBuildSqlType { get; private set; } = SqlTypes.Standard; + + /// + /// 配置 RunOnStart 提供程序 + /// + public Func RunOnStartProvider + { + get + { + return InternalRunOnStartProvider; + } + set + { + InternalRunOnStartProvider = value; + } + } + + /// + /// 内部配置 RunOnStart 提供程序 + /// + internal static Func InternalRunOnStartProvider { get; private set; } + + /// + /// 添加作业组作业 + /// + /// 作业组名称 + /// + /// + public ScheduleOptionsBuilder GroupSet(string groupSet, Action setAction) + { + // 空检查 + if (string.IsNullOrWhiteSpace(groupSet)) throw new ArgumentNullException(nameof(groupSet)); + if (setAction is null) throw new ArgumentNullException(nameof(setAction)); + + // 设置当前作业组名称(理应不存在并发问题,若有添加 lock) + _groupSet = groupSet; + + // 调用设置 + setAction(); + + // 清空当前作业组名称 + _groupSet = null; + + return this; + } + + /// + /// 添加作业 + /// + /// 作业调度程序构建器集合 + /// + public ScheduleOptionsBuilder AddJob(params SchedulerBuilder[] schedulerBuilders) + { + // 空检查 + if (schedulerBuilders == null || schedulerBuilders.Length == 0) throw new ArgumentNullException(nameof(schedulerBuilders)); + + // 逐条将作业计划构建器添加到集合中 + foreach (var schedulerBuilder in schedulerBuilders) + { + // 设置作业组名称 + var jobBuilder = schedulerBuilder.JobBuilder; + if (!string.IsNullOrWhiteSpace(_groupSet)) jobBuilder.SetGroupName(_groupSet); + + _schedulerBuilders.Add(schedulerBuilder); + } + + return this; + } + + /// + /// 添加作业 + /// + /// 作业信息构建器 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(JobBuilder jobBuilder, params TriggerBuilder[] triggerBuilders) + { + return AddJob(SchedulerBuilder.Create(jobBuilder, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 实现类型 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return AddJob(SchedulerBuilder.Create(triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 实现类型 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(Type jobType, params TriggerBuilder[] triggerBuilders) + { + return AddJob(SchedulerBuilder.Create(jobType, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 实现类型 + /// 作业 Id + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(string jobId, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return AddJob(SchedulerBuilder.Create(jobId, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 实现类型 + /// 作业 Id + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(Type jobType, string jobId, params TriggerBuilder[] triggerBuilders) + { + return AddJob(SchedulerBuilder.Create(jobType, jobId, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 实现类型 + /// 作业 Id + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(string jobId, bool concurrent, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return AddJob(SchedulerBuilder.Create(jobId, concurrent, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 实现类型 + /// 作业 Id + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(Type jobType, string jobId, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return AddJob(SchedulerBuilder.Create(jobType, jobId, concurrent, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 实现类型 + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(bool concurrent, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return AddJob(SchedulerBuilder.Create(concurrent, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 实现类型 + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(Type jobType, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return AddJob(SchedulerBuilder.Create(jobType, concurrent, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 运行时动态作业执行逻辑 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(Func dynamicExecuteAsync, params TriggerBuilder[] triggerBuilders) + { + return AddJob(SchedulerBuilder.Create(dynamicExecuteAsync, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 运行时动态作业执行逻辑 + /// 作业 Id + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(Func dynamicExecuteAsync, string jobId, params TriggerBuilder[] triggerBuilders) + { + return AddJob(SchedulerBuilder.Create(dynamicExecuteAsync, jobId, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 运行时动态作业执行逻辑 + /// 作业 Id + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(Func dynamicExecuteAsync, string jobId, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return AddJob(SchedulerBuilder.Create(dynamicExecuteAsync, jobId, concurrent, triggerBuilders)); + } + + /// + /// 添加作业 + /// + /// 运行时动态作业执行逻辑 + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddJob(Func dynamicExecuteAsync, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return AddJob(SchedulerBuilder.Create(dynamicExecuteAsync, concurrent, triggerBuilders)); + } + + /// + /// 添加 HTTP 作业 + /// + /// 构建 HTTP 作业消息委托 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddHttpJob(Action buildMessage, params TriggerBuilder[] triggerBuilders) + { + return AddHttpJob(buildMessage, triggerBuilders); + } + + /// + /// 添加 HTTP 作业 + /// + /// 实现类型 + /// 构建 HTTP 作业消息委托 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddHttpJob(Action buildMessage, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return AddHttpJob(buildMessage, SchedulerBuilder.Create(triggerBuilders)); + } + + /// + /// 添加 HTTP 作业 + /// + /// 构建 HTTP 作业消息委托 + /// 作业 Id + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddHttpJob(Action buildMessage, string jobId, params TriggerBuilder[] triggerBuilders) + { + return AddHttpJob(buildMessage, jobId, triggerBuilders); + } + + /// + /// 添加 HTTP 作业 + /// + /// 实现类型 + /// 构建 HTTP 作业消息委托 + /// 构建 HTTP 作业消息委托 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddHttpJob(Action buildMessage, string jobId, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return AddHttpJob(buildMessage, SchedulerBuilder.Create(jobId, triggerBuilders)); + } + + /// + /// 添加 HTTP 作业 + /// + /// 构建 HTTP 作业消息委托 + /// 作业 Id + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddHttpJob(Action buildMessage, string jobId, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return AddHttpJob(buildMessage, jobId, concurrent, triggerBuilders); + } + + /// + /// 添加 HTTP 作业 + /// + /// 实现类型 + /// 构建 HTTP 作业消息委托 + /// 构建 HTTP 作业消息委托 + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddHttpJob(Action buildMessage, string jobId, bool concurrent, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return AddHttpJob(buildMessage, SchedulerBuilder.Create(jobId, concurrent, triggerBuilders)); + } + + /// + /// 添加 HTTP 作业 + /// + /// 构建 HTTP 作业消息委托 + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddHttpJob(Action buildMessage, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return AddHttpJob(buildMessage, concurrent, triggerBuilders); + } + + /// + /// 添加 HTTP 作业 + /// + /// 实现类型 + /// 构建 HTTP 作业消息委托 + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public ScheduleOptionsBuilder AddHttpJob(Action buildMessage, bool concurrent, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return AddHttpJob(buildMessage, SchedulerBuilder.Create(concurrent, triggerBuilders)); + } + + /// + /// 添加 HTTP 作业 + /// + /// 构建 HTTP 作业消息委托 + /// 作业调度程序构建器 + /// + private ScheduleOptionsBuilder AddHttpJob(Action buildMessage, SchedulerBuilder schedulerBuilder) + { + // 空检查 + if (buildMessage == null) throw new ArgumentNullException(nameof(buildMessage)); + + // 空检查 + if (schedulerBuilder == null) throw new ArgumentNullException(nameof(schedulerBuilder)); + + // 创建 HTTP 作业消息 + var httpJobMessage = new HttpJobMessage(); + buildMessage?.Invoke(httpJobMessage); + + // 设置作业组名称和描述 + schedulerBuilder.JobBuilder.SetGroupName(httpJobMessage.GroupName).SetDescription(httpJobMessage.Description); + + // 将 HTTP 作业消息序列化并存储起来 + schedulerBuilder.JobBuilder.AddOrUpdateProperty(nameof(HttpJob), Penetrates.Serialize(httpJobMessage)); + + return AddJob(schedulerBuilder); + } + + /// + /// 注册作业处理程序监视器 + /// + /// 实现自 + /// 实例 + public ScheduleOptionsBuilder AddMonitor() + where TJobMonitor : class, IJobMonitor + { + _jobMonitor = typeof(TJobMonitor); + return this; + } + + /// + /// 注册作业处理程序执行器 + /// + /// 实现自 + /// 实例 + public ScheduleOptionsBuilder AddExecutor() + where TJobExecutor : class, IJobExecutor + { + _jobExecutor = typeof(TJobExecutor); + return this; + } + + /// + /// 注册作业调度持久化器 + /// + /// 实现自 + /// 实例 + public ScheduleOptionsBuilder AddPersistence() + where TJobPersistence : class, IJobPersistence + { + _jobPersistence = typeof(TJobPersistence); + return this; + } + + /// + /// 注册作业集群服务 + /// + /// 实现自 + /// 实例 + public ScheduleOptionsBuilder AddClusterServer() + where TJobClusterServer : class, IJobClusterServer + { + _jobClusterServer = typeof(TJobClusterServer); + return this; + } + + /// + /// 注册作业处理程序工厂 + /// + /// 实现自 + /// 实例 + public ScheduleOptionsBuilder AddJobFactory() + where TJobFactory : class, IJobFactory + { + _jobFactory = typeof(TJobFactory); + return this; + } + + /// + /// 构建作业调度器配置选项 + /// + /// + /// + internal IList Build(IServiceCollection services) + { + // 注册作业监视器 + if (_jobMonitor != default) + { + services.AddSingleton(typeof(IJobMonitor), _jobMonitor); + } + + // 注册作业执行器 + if (_jobExecutor != default) + { + services.AddSingleton(typeof(IJobExecutor), _jobExecutor); + } + + // 注册作业调度持久化器 + if (_jobPersistence != default) + { + services.AddSingleton(typeof(IJobPersistence), _jobPersistence); + } + + // 注册作业集群服务 + if (_jobClusterServer != default) + { + // 初始化集群 Id + ClusterId = !string.IsNullOrWhiteSpace(ClusterId) + ? ClusterId + : Assembly.GetEntryAssembly()?.GetName()?.Name ?? "cluster1"; + + services.AddSingleton(typeof(IJobClusterServer), _jobClusterServer); + } + + // 注册作业处理程序工厂 + if (_jobFactory != default) + { + services.AddSingleton(typeof(IJobFactory), _jobFactory); + } + + return _schedulerBuilders; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Builders/SchedulerBuilder.cs b/src/Admin/ThingsGateway.Furion/Schedule/Builders/SchedulerBuilder.cs new file mode 100644 index 000000000..0ef606eb5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Builders/SchedulerBuilder.cs @@ -0,0 +1,571 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Schedule; + +/// +/// 作业计划构建器 +/// +[SuppressSniffer] +public sealed class SchedulerBuilder +{ + /// + /// 构造函数 + /// + /// 作业信息构建器 + private SchedulerBuilder(JobBuilder jobBuilder) + { + JobBuilder = jobBuilder; + } + + /// + /// 构造函数 + /// + /// 作业信息构建器 + /// 作业触发器构建器集合 + private SchedulerBuilder(JobBuilder jobBuilder, List triggerBuilders) + { + JobBuilder = jobBuilder; + TriggerBuilders = triggerBuilders; + } + + /// + /// 标记作业持久化行为 + /// + internal PersistenceBehavior Behavior { get; private set; } = PersistenceBehavior.Appended; + + /// + /// 作业信息构建器 + /// + internal JobBuilder JobBuilder { get; private set; } + + /// + /// 作业触发器构建器集合 + /// + internal List TriggerBuilders { get; private set; } = new(); + + /// + /// 作业触发器数量 + /// + public int TriggerCount => TriggerBuilders.Count; + + /// + /// 创建作业计划构建器 + /// + /// 作业 Id + /// + public static SchedulerBuilder Create(string jobId) + { + return Create(JobBuilder.Create(jobId)); + } + + /// + /// 创建作业计划构建器 + /// + /// 实现类型 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return Create(JobBuilder.Create(), triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 实现类型 + /// 作业 Id + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(string jobId, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return Create(JobBuilder.Create().SetJobId(jobId), triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 实现类型 + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(bool concurrent, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return Create(JobBuilder.Create().SetConcurrent(concurrent), triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 实现类型 + /// 作业 Id + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(string jobId, bool concurrent, params TriggerBuilder[] triggerBuilders) + where TJob : class, IJob + { + return Create(JobBuilder.Create().SetJobId(jobId).SetConcurrent(concurrent), triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 实现类型 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(Type jobType, params TriggerBuilder[] triggerBuilders) + { + return Create(JobBuilder.Create(jobType), triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 实现类型 + /// 作业 Id + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(Type jobType, string jobId, params TriggerBuilder[] triggerBuilders) + { + return Create(JobBuilder.Create(jobType).SetJobId(jobId), triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 实现类型 + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(Type jobType, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return Create(JobBuilder.Create(jobType).SetConcurrent(concurrent), triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 实现类型 + /// 作业 Id + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(Type jobType, string jobId, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return Create(JobBuilder.Create(jobType).SetJobId(jobId).SetConcurrent(concurrent), triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 运行时动态作业执行逻辑 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(Func dynamicExecuteAsync, params TriggerBuilder[] triggerBuilders) + { + return Create(JobBuilder.Create(dynamicExecuteAsync), triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 运行时动态作业执行逻辑 + /// 作业 Id + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(Func dynamicExecuteAsync, string jobId, params TriggerBuilder[] triggerBuilders) + { + return Create(JobBuilder.Create(dynamicExecuteAsync).SetJobId(jobId) + , triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 运行时动态作业执行逻辑 + /// 作业 Id + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(Func dynamicExecuteAsync, string jobId, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return Create(JobBuilder.Create(dynamicExecuteAsync).SetJobId(jobId).SetConcurrent(concurrent) + , triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 运行时动态作业执行逻辑 + /// 是否采用并发执行 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(Func dynamicExecuteAsync, bool concurrent, params TriggerBuilder[] triggerBuilders) + { + return Create(JobBuilder.Create(dynamicExecuteAsync).SetConcurrent(concurrent) + , triggerBuilders); + } + + /// + /// 创建作业计划构建器 + /// + /// 作业信息构建器 + /// 作业触发器构建器集合 + /// + public static SchedulerBuilder Create(JobBuilder jobBuilder, params TriggerBuilder[] triggerBuilders) + { + // 空检查 + if (jobBuilder == null) throw new ArgumentNullException(nameof(jobBuilder)); + + // 创建作业计划构建器 + var schedulerBuilder = new SchedulerBuilder(jobBuilder); + + // 批量添加触发器 + if (triggerBuilders != null && triggerBuilders.Length > 0) + { + schedulerBuilder.TriggerBuilders.AddRange(triggerBuilders); + } + + // 判断是否扫描 IJob 实现类 [Trigger] 特性触发器 + if (jobBuilder.IncludeAnnotations && jobBuilder.RuntimeJobType != null) + { + // 检查类型是否贴有 [JobDetail] 特性 + if (jobBuilder.RuntimeJobType.IsDefined(typeof(JobDetailAttribute), true)) + { + // 这里加载之后忽略空值 + jobBuilder.LoadFrom(jobBuilder.RuntimeJobType.GetCustomAttribute(true), true); + } + + schedulerBuilder.TriggerBuilders.AddRange(jobBuilder.RuntimeJobType.ScanTriggers()); + } + + return schedulerBuilder.Appended(); + } + + /// + /// 将 转换成 + /// + /// 作业计划 + /// + internal static SchedulerBuilder From(Scheduler scheduler) + { + return new SchedulerBuilder(JobBuilder.From(scheduler.JobDetail) + , scheduler.Triggers.Select(t => TriggerBuilder.From(t.Value)).ToList()) + .Updated(); + } + + /// + /// 将 转换成 + /// + /// 作业计划 + /// + public static SchedulerBuilder From(IScheduler scheduler) + { + return scheduler.GetBuilder(); + } + + /// + /// 将 JSON 字符串转换成 + /// + /// JSON 字符串 + /// + public static SchedulerBuilder From(string json) + { + // 反序列化成 SchedulerModel 类型 + var schedulerModel = Penetrates.Deserialize(json); + + return new SchedulerBuilder(JobBuilder.From(schedulerModel.JobDetail) + , schedulerModel.Triggers.Select(t => TriggerBuilder.From(t).Appended()).ToList()) + .Appended(); + } + + /// + /// 克隆作业计划构建器 + /// + /// 被克隆的作业计划构建器 + /// + public static SchedulerBuilder Clone(SchedulerBuilder fromSchedulerBuilder) + { + // 空检查 + if (fromSchedulerBuilder == null) throw new ArgumentNullException(nameof(fromSchedulerBuilder)); + + return new SchedulerBuilder(JobBuilder.Clone(fromSchedulerBuilder.JobBuilder) + , fromSchedulerBuilder.TriggerBuilders.Select(t => TriggerBuilder.Clone(t)).ToList()) + .Appended(); + } + + /// + /// 获取作业信息构建器 + /// + /// + public JobBuilder GetJobBuilder() + { + return JobBuilder; + } + + /// + /// 获取作业触发器构建器集合 + /// + /// + public IReadOnlyList GetTriggerBuilders() + { + return TriggerBuilders; + } + + /// + /// 获取作业触发器构建器 + /// + /// + public TriggerBuilder GetTriggerBuilder(string triggerId) + { + // 空检查 + if (string.IsNullOrWhiteSpace(triggerId)) throw new ArgumentNullException(nameof(triggerId)); + + return TriggerBuilders.SingleOrDefault(t => t.TriggerId == triggerId); + } + + /// + /// 更新作业触发器构建器 + /// + /// 作业触发器构建器 + /// 是否完全替换为新的 + /// + public SchedulerBuilder UpdateJobBuilder(JobBuilder jobBuilder, bool replace = true) + { + // 空检查 + if (jobBuilder == null) throw new ArgumentNullException(nameof(jobBuilder)); + + jobBuilder.MapTo(JobBuilder, !replace); + + // 初始化运行时作业类型和额外数据 + JobBuilder.SetJobType(JobBuilder.AssemblyName, JobBuilder.JobType) + .SetProperties(JobBuilder.Properties); + + return this; + } + + /// + /// 添加作业触发器构建器 + /// + /// 作业触发器构建器 + /// + public SchedulerBuilder AddTriggerBuilder(params TriggerBuilder[] triggerBuilders) + { + // 空检查 + if (triggerBuilders == null) throw new ArgumentNullException(nameof(triggerBuilders)); + + foreach (var triggerBuilder in triggerBuilders) + { + TriggerBuilders.Add(triggerBuilder.Appended()); + } + + return this; + } + + /// + /// 更新作业触发器构建器 + /// + /// 作业触发器构建器 + /// 是否完全替换为新的 + /// + public SchedulerBuilder UpdateTriggerBuilder(TriggerBuilder triggerBuilder, bool replace = true) + { + // 空检查 + if (triggerBuilder == null) throw new ArgumentNullException(nameof(triggerBuilder)); + + // 获取原来的作业触发器构建器 + var originTriggerBuilder = GetTriggerBuilder(triggerBuilder?.TriggerId); + if (originTriggerBuilder != null) + { + triggerBuilder.MapTo(originTriggerBuilder, !replace); + + // 初始化运行时作业类型和额外数据 + originTriggerBuilder.SetTriggerType(originTriggerBuilder.AssemblyName, originTriggerBuilder.TriggerType) + .SetArgs(originTriggerBuilder.Args); + } + + return this; + } + + /// + /// 更新作业触发器构建器 + /// + /// 作业触发器构建器 + /// + public SchedulerBuilder UpdateTriggerBuilder(params TriggerBuilder[] triggerBuilders) + { + // 空检查 + if (triggerBuilders == null) throw new ArgumentNullException(nameof(triggerBuilders)); + + foreach (var triggerBuilder in triggerBuilders) + { + UpdateTriggerBuilder(triggerBuilder); + } + + return this; + } + + /// + /// 删除作业触发器构建器 + /// + /// 作业触发器 Id + /// + public SchedulerBuilder RemoveTriggerBuilder(params string[] triggerIds) + { + // 空检查 + if (triggerIds == null || triggerIds.Length == 0) return this; + + foreach (var triggerId in triggerIds) + { + GetTriggerBuilder(triggerId)?.Removed(); + } + + return this; + } + + /// + /// 清空作业触发器构建器 + /// + /// + public SchedulerBuilder ClearTriggerBuilders() + { + TriggerBuilders.ForEach(t => t.Removed()); + + return this; + } + + /// + /// 转换成 JSON 字符串 + /// + /// 命名法 + /// + public string ConvertToJSON(NamingConventions naming = NamingConventions.CamelCase) + { + return Penetrates.Write(writer => + { + writer.WriteStartObject(); + + // 输出 JobDetail + writer.WritePropertyName(Penetrates.GetNaming(nameof(JobDetail), naming)); + writer.WriteRawValue(JobBuilder.ConvertToJSON(naming)); + + // 输出 Triggers + writer.WritePropertyName(Penetrates.GetNaming(nameof(Triggers), naming)); + + writer.WriteStartArray(); + foreach (var triggerBuilder in TriggerBuilders) + { + writer.WriteRawValue(triggerBuilder.ConvertToJSON(naming)); + } + writer.WriteEndArray(); + + writer.WriteEndObject(); + }); + } + + /// + /// 将作业计划构建器转换成可枚举集合 + /// + /// + public Dictionary GetEnumerable() + { + var enumerable = new Dictionary(new RepeatKeyEqualityComparer()); + + TriggerBuilders.ForEach(triggerBuilder => enumerable.Add(JobBuilder, triggerBuilder)); + + return enumerable; + } + + /// + /// 标记作业计划为新增行为 + /// + /// + public SchedulerBuilder Appended() + { + Behavior = PersistenceBehavior.Appended; + return this; + } + + /// + /// 标记作业计划为更新行为 + /// + /// + public SchedulerBuilder Updated() + { + Behavior = PersistenceBehavior.Updated; + return this; + } + + /// + /// 标记作业计划为删除行为 + /// + /// + public SchedulerBuilder Removed() + { + Behavior = PersistenceBehavior.Removed; + + // 标记所有作业触发器持久化为删除状态 + TriggerBuilders.ForEach(triggerBuilder => triggerBuilder.Removed()); + + return this; + } + + /// + /// 构建 对象 + /// + /// 作业调度器中当前作业计划总量 + /// + internal Scheduler Build(int count) + { + // 配置默认 JobId + if (string.IsNullOrWhiteSpace(JobBuilder.JobId)) + { + JobBuilder.SetJobId($"job{count + 1}"); + } + + // 构建作业信息和作业触发器 + var jobDetail = JobBuilder.Build(); + + // 构建作业触发器 + var triggers = new Dictionary(); + + // 遍历作业触发器构建器集合 + foreach (var triggerBuilder in TriggerBuilders) + { + // 配置默认 TriggerId + if (string.IsNullOrWhiteSpace(triggerBuilder.TriggerId)) + { + triggerBuilder.SetTriggerId($"{jobDetail.JobId}_trigger{triggers.Count + 1}"); + } + + // 处理作业被标记删除的情况 + if (Behavior == PersistenceBehavior.Removed) + { + triggerBuilder.Removed(); + } + + var trigger = triggerBuilder.Build(jobDetail.JobId); + var succeed = triggers.TryAdd(trigger.TriggerId, trigger); + + // 作业触发器 Id 唯一检查 + if (!succeed) throw new InvalidOperationException($"The <{trigger.TriggerId}> trigger for scheduler of <{jobDetail.JobId}> already exists."); + } + + // 创建作业计划 + return new Scheduler(jobDetail, triggers); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Builders/TriggerBuilder.Setters.cs b/src/Admin/ThingsGateway.Furion/Schedule/Builders/TriggerBuilder.Setters.cs new file mode 100644 index 000000000..29b32750b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Builders/TriggerBuilder.Setters.cs @@ -0,0 +1,291 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.TimeCrontab; + +namespace ThingsGateway.Schedule; + +/// +/// 作业触发器 +/// +public sealed partial class TriggerBuilder +{ + /// + /// 设置作业触发器 + /// + /// 派生类 + /// + public TriggerBuilder AlterTo() + where TTrigger : Trigger + { + return AlterTo(); + } + + /// + /// 设置作业触发器 + /// + /// 派生类 + /// 作业触发器参数 + /// + public TriggerBuilder AlterTo(params object[] args) + where TTrigger : Trigger + { + return SetTriggerType().SetArgs(args); + } + + /// + /// 设置作业触发器 + /// + /// 作业触发器类型所在程序集 Name + /// 作业触发器类型 FullName + /// + public TriggerBuilder AlterTo(string assemblyName, string triggerTypeFullName) + { + return AlterTo(assemblyName, triggerTypeFullName); + } + + /// + /// 设置作业触发器 + /// + /// 作业触发器类型所在程序集 Name + /// 作业触发器类型 FullName + /// 作业触发器参数 + /// + public TriggerBuilder AlterTo(string assemblyName, string triggerTypeFullName, params object[] args) + { + return SetTriggerType(assemblyName, triggerTypeFullName).SetArgs(args); + } + + /// + /// 设置作业触发器 + /// + /// 派生类 + /// + public TriggerBuilder AlterTo(Type triggerType) + { + return AlterTo(triggerType); + } + + /// + /// 设置作业触发器 + /// + /// 派生类 + /// 作业触发器参数 + /// + public TriggerBuilder AlterTo(Type triggerType, params object[] args) + { + return SetTriggerType(triggerType).SetArgs(args); + } + + /// + /// 设置毫秒周期(间隔)作业触发器 + /// + /// 间隔(毫秒) + /// + public TriggerBuilder AlterToPeriod(long interval) + { + return SetTriggerType().SetArgs(interval); + } + + /// + /// 设置秒周期(间隔)作业触发器 + /// + /// 间隔(秒) + /// + public TriggerBuilder AlterToPeriodSeconds(long interval) + { + return AlterToPeriod(interval * 1000); + } + + /// + /// 设置分钟周期(间隔)作业触发器 + /// + /// 间隔(分钟) + /// + public TriggerBuilder AlterToPeriodMinutes(long interval) + { + return AlterToPeriod(interval * 1000 * 60); + } + + /// + /// 设置小时周期(间隔)作业触发器 + /// + /// 间隔(小时) + /// + public TriggerBuilder AlterToPeriodHours(long interval) + { + return AlterToPeriod(interval * 1000 * 60 * 60); + } + + /// + /// 设置 Cron 表达式作业触发器 + /// + /// Cron 表达式 + /// Cron 表达式格式化类型,默认 + /// + public TriggerBuilder AlterToCron(string schedule, CronStringFormat format = CronStringFormat.Default) + { + return SetTriggerType().SetArgs(schedule, format); + } + + /// + /// 设置 Cron 表达式作业触发器 + /// + /// Cron 表达式 + /// 动态参数类型,支持 和 object[] + /// + public TriggerBuilder AlterToCron(string schedule, object args) + { + return SetTriggerType().SetArgs(schedule, args); + } + + /// + /// 设置每秒开始作业触发器 + /// + /// + public TriggerBuilder AlterToSecondly() + { + return AlterToCron("@secondly"); + } + + /// + /// 设置指定特定秒开始作业触发器 + /// + /// 字段值 + /// + public TriggerBuilder AlterToSecondlyAt(params object[] fields) + { + return AlterToCron("@secondly", fields); + } + + /// + /// 设置每分钟开始作业触发器 + /// + /// + public TriggerBuilder AlterToMinutely() + { + return AlterToCron("@minutely"); + } + + /// + /// 设置每分钟特定秒开始作业触发器 + /// + /// 字段值 + /// + public TriggerBuilder AlterToMinutelyAt(params object[] fields) + { + return AlterToCron("@minutely", fields); + } + + /// + /// 设置每小时开始作业触发器 + /// + /// + public TriggerBuilder AlterToHourly() + { + return AlterToCron("@hourly"); + } + + /// + /// 设置每小时特定分钟开始作业触发器 + /// + /// 字段值 + /// + public TriggerBuilder AlterToHourlyAt(params object[] fields) + { + return AlterToCron("@hourly", fields); + } + + /// + /// 设置每天(午夜)开始作业触发器 + /// + /// + public TriggerBuilder AlterToDaily() + { + return AlterToCron("@daily"); + } + + /// + /// 设置每天特定小时开始作业触发器 + /// + /// 字段值 + /// + public TriggerBuilder AlterToDailyAt(params object[] fields) + { + return AlterToCron("@daily", fields); + } + + /// + /// 设置每月1号(午夜)开始作业触发器 + /// + /// + public TriggerBuilder AlterToMonthly() + { + return AlterToCron("@monthly"); + } + + /// + /// 设置每月特定天(午夜)开始作业触发器 + /// + /// 字段值 + /// + public TriggerBuilder AlterToMonthlyAt(params object[] fields) + { + return AlterToCron("@monthly", fields); + } + + /// + /// 设置每周日(午夜)开始作业触发器 + /// + /// + public TriggerBuilder AlterToWeekly() + { + return AlterToCron("@weekly"); + } + + /// + /// 设置每周特定星期几(午夜)开始作业触发器 + /// + /// 字段值 + /// + public TriggerBuilder AlterToWeeklyAt(params object[] fields) + { + return AlterToCron("@weekly", fields); + } + + /// + /// 设置每年1月1号(午夜)开始作业触发器 + /// + /// + public TriggerBuilder AlterToYearly() + { + return AlterToCron("@yearly"); + } + + /// + /// 设置每年特定月1号(午夜)开始作业触发器 + /// + /// 字段值 + /// + public TriggerBuilder AlterToYearlyAt(params object[] fields) + { + return AlterToCron("@yearly", fields); + } + + /// + /// 设置每周一至周五(午夜)开始作业触发器 + /// + /// + public TriggerBuilder AlterToWorkday() + { + return AlterToCron("@workday"); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Builders/TriggerBuilder.cs b/src/Admin/ThingsGateway.Furion/Schedule/Builders/TriggerBuilder.cs new file mode 100644 index 000000000..ad623e135 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Builders/TriggerBuilder.cs @@ -0,0 +1,637 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.TimeCrontab; + +namespace ThingsGateway.Schedule; + +/// +/// 作业触发器构建器 +/// +[SuppressSniffer] +public sealed partial class TriggerBuilder : Trigger +{ + /// + /// 构造函数 + /// + private TriggerBuilder() + { + } + + /// + /// 创建毫秒周期(间隔)作业触发器构建器 + /// + /// 间隔(毫秒) + /// + public static TriggerBuilder Period(long interval) + { + return Create(interval); + } + + /// + /// 创建 Cron 表达式作业触发器构建器 + /// + /// Cron 表达式 + /// Cron 表达式格式化类型,默认 + /// + public static TriggerBuilder Cron(string schedule, CronStringFormat format = CronStringFormat.Default) + { + return Create(schedule, format); + } + + /// + /// 创建 Cron 表达式作业触发器构建器 + /// + /// Cron 表达式 + /// 动态参数类型,支持 和 object[] + /// + internal static TriggerBuilder Cron(string schedule, object args) + { + return Create(schedule, args); + } + + /// + /// 创建作业触发器构建器 + /// + /// 作业触发器 Id + /// + public static TriggerBuilder Create(string triggerId) + { + return new TriggerBuilder() + .SetTriggerId(triggerId); + } + + /// + /// 创建作业触发器构建器 + /// + /// 派生类 + /// + public static TriggerBuilder Create() + where TTrigger : Trigger + { + return Create(typeof(TTrigger)); + } + + /// + /// 创建作业触发器构建器 + /// + /// 派生类 + /// 作业触发器参数 + /// + public static TriggerBuilder Create(params object[] args) + where TTrigger : Trigger + { + return Create().SetArgs(args); + } + + /// + /// 创建新的作业触发器构建器 + /// + /// 作业触发器类型所在程序集 Name + /// 作业触发器类型 FullName + /// + public static TriggerBuilder Create(string assemblyName, string triggerTypeFullName) + { + return new TriggerBuilder() + .SetTriggerType(assemblyName, triggerTypeFullName) + .Appended(); + } + + /// + /// 创建新的作业触发器构建器 + /// + /// 作业触发器类型所在程序集 Name + /// 作业触发器类型 FullName + /// 作业触发器参数 + /// + public static TriggerBuilder Create(string assemblyName, string triggerTypeFullName, params object[] args) + { + return Create(assemblyName, triggerTypeFullName).SetArgs(args); + } + + /// + /// 创建新的作业触发器构建器 + /// + /// 派生类 + /// + public static TriggerBuilder Create(Type triggerType) + { + return new TriggerBuilder() + .SetTriggerType(triggerType) + .Appended(); + } + + /// + /// 创建新的作业触发器构建器 + /// + /// 派生类 + /// 作业触发器参数 + /// + public static TriggerBuilder Create(Type triggerType, params object[] args) + { + return Create(triggerType).SetArgs(args); + } + + /// + /// 将 转换成 + /// + /// 作业触发器 + /// + public static TriggerBuilder From(Trigger trigger) + { + var triggerBuilder = trigger.MapTo(); + + // 初始化运行时作业触发器类型和参数 + triggerBuilder.SetTriggerType(triggerBuilder.AssemblyName, triggerBuilder.TriggerType) + .SetArgs(triggerBuilder.Args); + + return triggerBuilder.Updated(); + } + + /// + /// 将 JSON 字符串转换成 + /// + /// JSON 字符串 + /// + public static TriggerBuilder From(string json) + { + return From(Penetrates.Deserialize(json)).Appended(); + } + + /// + /// 克隆作业触发器构建器 + /// + /// 被克隆的作业触发器构建器 + /// + public static TriggerBuilder Clone(TriggerBuilder fromTriggerBuilder) + { + return Create(fromTriggerBuilder.AssemblyName, fromTriggerBuilder.TriggerType) + .SetArgs(fromTriggerBuilder.Args) + .SetDescription(fromTriggerBuilder.Description) + .SetStartTime(fromTriggerBuilder.StartTime) + .SetEndTime(fromTriggerBuilder.EndTime) + .SetMaxNumberOfRuns(fromTriggerBuilder.MaxNumberOfRuns) + .SetMaxNumberOfErrors(fromTriggerBuilder.MaxNumberOfErrors) + .SetNumRetries(fromTriggerBuilder.NumRetries) + .SetRetryTimeout(fromTriggerBuilder.RetryTimeout) + .SetStartNow(fromTriggerBuilder.StartNow) + .SetRunOnStart(fromTriggerBuilder.RunOnStart) + .SetResetOnlyOnce(fromTriggerBuilder.ResetOnlyOnce); + } + + /// + /// 从目标值填充数据到作业触发器构建器 + /// + /// 目标值 + /// 忽略空值 + /// 忽略属性名 + /// + public TriggerBuilder LoadFrom(object value, bool ignoreNullValue = false, string[] ignorePropertyNames = default) + { + if (value == null) return this; + + // 排除枚举类型,接口类型,数组类型,值类型 + var valueType = value.GetType(); + if (valueType.IsInterface + || valueType.IsValueType + || valueType.IsEnum + || valueType.IsArray) throw new InvalidOperationException(nameof(value)); + + var triggerBuilder = value.MapTo(this, ignoreNullValue, ignorePropertyNames); + + // 初始化运行时作业触发器类型和参数 + triggerBuilder.SetTriggerType(triggerBuilder.AssemblyName, triggerBuilder.TriggerType) + .SetArgs(triggerBuilder.Args); + + return triggerBuilder; + } + + /// + /// 设置作业触发器 Id + /// + /// 作业触发器 Id + /// + public TriggerBuilder SetTriggerId(string triggerId) + { + TriggerId = triggerId; + + return this; + } + + /// + /// 设置作业触发器类型 + /// + /// 作业触发器所在程序集 Name + /// 作业触发器 FullName + /// + public TriggerBuilder SetTriggerType(string assemblyName, string triggerTypeFullName) + { + AssemblyName = assemblyName; + TriggerType = triggerTypeFullName; + + // 只有 assemblyName 和 triggerTypeFullName 同时存在才创建类型 + if (!string.IsNullOrWhiteSpace(assemblyName) + && !string.IsNullOrWhiteSpace(triggerTypeFullName)) + { + // 加载 GAC 全局应用程序缓存中的程序集及类型 + var triggerType = Penetrates.LoadAssembly(assemblyName) + .GetType(triggerTypeFullName); + + return SetTriggerType(triggerType); + } + + return this; + } + + /// + /// 设置作业触发器类型 + /// + /// 派生类类型 + /// + public TriggerBuilder SetTriggerType() + where TTrigger : Trigger + { + return SetTriggerType(typeof(TTrigger)); + } + + /// + /// 设置作业触发器类型 + /// + /// 作业触发器类型 + /// + public TriggerBuilder SetTriggerType(Type triggerType) + { + // 不做 null 检查 + if (triggerType == null) return this; + + // 检查 triggerType 类型是否派生自 Trigger + if (!typeof(Trigger).IsAssignableFrom(triggerType) + || triggerType == typeof(Trigger) + || triggerType.IsInterface + || triggerType.IsAbstract) throw new InvalidOperationException("The is not a valid Trigger type."); + + // 最多只能包含一个构造函数 + if (triggerType.GetConstructors().Length > 1) throw new InvalidOperationException("The can contain at most one constructor."); + + AssemblyName = triggerType.Assembly.GetName().Name; + TriggerType = triggerType.FullName; + RuntimeTriggerType = triggerType; + + return this; + } + + /// + /// 设置作业触发器参数 + /// + /// 作业触发器参数 + /// + public TriggerBuilder SetArgs(string args) + { + // 空检查 + if (string.IsNullOrWhiteSpace(args) || args == "[]") args = null; + + Args = args; + if (args == null) return this; + + var jsonObjectArray = Penetrates.Deserialize(args); + var runtimeArgs = new object[jsonObjectArray.Length]; + + // 解决反序列化 object 类型被转换成了 JsonElement 类型 + for (var i = 0; i < jsonObjectArray.Length; i++) + { + runtimeArgs[i] = Penetrates.GetJsonElementValue(jsonObjectArray[i]); + } + + RuntimeTriggerArgs = runtimeArgs; + // 解决修改了触发器参数没有更新下一次运行时间问题 + SetNextRunTime(Penetrates.GetNowTime().AddSeconds(-1)); + + return this; + } + + /// + /// 设置作业触发器参数 + /// + /// 作业触发器参数 + /// + public TriggerBuilder SetArgs(params object[] args) + { + Args = args == null || args.Length == 0 + ? null + : Penetrates.Serialize(args); + RuntimeTriggerArgs = args; + // 解决修改了触发器参数没有更新下一次运行时间问题 + SetNextRunTime(Penetrates.GetNowTime().AddSeconds(-1)); + + return this; + } + + /// + /// 设置描述信息 + /// + /// 描述信息 + /// + public TriggerBuilder SetDescription(string description) + { + Description = description; + + return this; + } + + /// + /// 设置作业触发器状态 + /// + /// 作业触发器状态 + /// + public new TriggerBuilder SetStatus(TriggerStatus status) + { + Status = status; + + return this; + } + + /// + /// 设置起始时间 + /// + /// 起始时间 + /// 如果启用 UTC 时间,那么这里也要使用 UTC 时间 + /// + public TriggerBuilder SetStartTime(DateTime? startTime) + { + StartTime = startTime; + + return this; + } + + /// + /// 设置结束时间 + /// + /// 结束时间 + /// 如果启用 UTC 时间,那么这里也要使用 UTC 时间 + /// + public TriggerBuilder SetEndTime(DateTime? endTime) + { + EndTime = endTime; + + return this; + } + + /// + /// 设置最近运行时间 + /// + /// 最近运行时间 + /// 如果启用 UTC 时间,那么这里也要使用 UTC 时间 + /// + public TriggerBuilder SetLastRunTime(DateTime? lastRunTime) + { + LastRunTime = lastRunTime; + + return this; + } + + /// + /// 设置下一次运行时间 + /// + /// 下一次运行时间 + /// 如果启用 UTC 时间,那么这里也要使用 UTC 时间 + /// + public TriggerBuilder SetNextRunTime(DateTime? nextRunTime) + { + NextRunTime = nextRunTime; + + return this; + } + + /// + /// 设置触发次数 + /// + /// 触发次数 + /// + public TriggerBuilder SetNumberOfRuns(long numberOfRuns) + { + NumberOfRuns = numberOfRuns; + + return this; + } + + /// + /// 设置最大触发次数 + /// + /// 最大触发次数 + /// + /// 0:不限制 + /// >n:N 次 + /// + /// + public TriggerBuilder SetMaxNumberOfRuns(long maxNumberOfRuns) + { + MaxNumberOfRuns = maxNumberOfRuns; + + return this; + } + + /// + /// 设置出错次数 + /// + /// 出错次数 + /// + public TriggerBuilder SetNumberOfErrors(long numberOfErrors) + { + NumberOfErrors = numberOfErrors; + + return this; + } + + /// + /// 设置最大出错次数 + /// + /// 最大出错次数 + /// + /// 0:不限制 + /// n:N 次 + /// + /// + public TriggerBuilder SetMaxNumberOfErrors(long maxNumberOfErrors) + { + MaxNumberOfErrors = maxNumberOfErrors; + + return this; + } + + /// + /// 设置重试次数 + /// + /// 重试次数 + /// + public TriggerBuilder SetNumRetries(int numRetries) + { + NumRetries = numRetries; + + return this; + } + + /// + /// 设置重试间隔时间 + /// + /// 重试间隔时间 + /// + public TriggerBuilder SetRetryTimeout(int retryTimeout) + { + RetryTimeout = retryTimeout; + + return this; + } + + /// + /// 设置是否立即启动 + /// + /// 是否立即启动 + /// + public TriggerBuilder SetStartNow(bool startNow) + { + StartNow = startNow; + + if (startNow == false && Status != TriggerStatus.NotStart) + { + SetNextRunTime(null); + SetStatus(TriggerStatus.NotStart); + } + + return this; + } + + /// + /// 设置是否启动时执行一次 + /// + /// 是否启动时执行一次 + /// + public TriggerBuilder SetRunOnStart(bool runOnStart) + { + RunOnStart = runOnStart; + + return this; + } + + /// + /// 设置是否在启动时重置最大触发次数等于一次的作业 + /// + /// 是否在启动时重置最大触发次数等于一次的作业 + /// + public TriggerBuilder SetResetOnlyOnce(bool resetOnlyOnce) + { + ResetOnlyOnce = resetOnlyOnce; + + return this; + } + + /// + /// 设置本次执行结果 + /// + /// 设置本次执行结果 + /// + public TriggerBuilder SetResult(string result) + { + Result = result; + + return this; + } + + /// + /// 设置本次执行耗时 + /// + /// 本次执行耗时 + /// + public TriggerBuilder SetElapsedTime(long elapsedTime) + { + ElapsedTime = elapsedTime; + + return this; + } + + /// + /// 标记作业触发器计划为新增行为 + /// + /// + public TriggerBuilder Appended() + { + Behavior = PersistenceBehavior.Appended; + return this; + } + + /// + /// 标记作业触发器计划为更新行为 + /// + /// + public TriggerBuilder Updated() + { + Behavior = PersistenceBehavior.Updated; + return this; + } + + /// + /// 标记作业触发器为删除行为 + /// + /// + public TriggerBuilder Removed() + { + Behavior = PersistenceBehavior.Removed; + return this; + } + + /// + /// 隐藏作业触发器公开方法 + /// + /// 起始时间 + /// + /// + public new DateTime GetNextOccurrence(DateTime startAt) => throw new NotImplementedException(); + + /// + /// 隐藏作业触发器公开方法 + /// + /// 作业信息 + /// 起始时间 + /// + public new bool ShouldRun(JobDetail jobDetail, DateTime startAt) => throw new NotImplementedException(); + + /// + /// 构建 对象 + /// + /// 作业 Id + /// + internal Trigger Build(string jobId) + { + // 空检查 + if (string.IsNullOrWhiteSpace(jobId)) throw new ArgumentNullException(nameof(jobId)); + + // 避免类型还未初始化,强制检查一次 + SetTriggerType(AssemblyName, TriggerType); + SetArgs(Args); + + // 检查 StartTime 和 EndTime 的关系,StartTime 不能大于 EndTime + if (StartTime != null && EndTime != null + && StartTime.Value > EndTime.Value) throw new InvalidOperationException("The start time cannot be greater than the end time."); + + JobId = jobId; + + // 判断是否带参数 + var hasArgs = !(RuntimeTriggerArgs == null || RuntimeTriggerArgs.Length == 0); + + // 反射创建作业触发器对象 + var triggerInstance = RuntimeTriggerType != null + ? ((!hasArgs + ? Activator.CreateInstance(RuntimeTriggerType) + : Activator.CreateInstance(RuntimeTriggerType, RuntimeTriggerArgs))) + : null; + + return this.MapTo(triggerInstance); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Cancellations/IJobCancellationToken.cs b/src/Admin/ThingsGateway.Furion/Schedule/Cancellations/IJobCancellationToken.cs new file mode 100644 index 000000000..12d37cee9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Cancellations/IJobCancellationToken.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 取消作业执行 Token 器 +/// +public interface IJobCancellationToken +{ + /// + /// 获取或创建取消作业执行 Token + /// + /// 作业 Id + /// 作业触发器触发的唯一标识 + /// 后台主机服务停止时取消任务 Token + /// + CancellationTokenSource GetOrCreate(string jobId, string runId, CancellationToken stoppingToken); + + /// + /// 取消(完成)正在执行的执行 + /// + /// 作业 Id + /// 作业触发器 Id + /// 是否显示日志 + void Cancel(string jobId, string triggerId = null, bool outputLog = true); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Cancellations/JobCancellationToken.cs b/src/Admin/ThingsGateway.Furion/Schedule/Cancellations/JobCancellationToken.cs new file mode 100644 index 000000000..0b3e0613c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Cancellations/JobCancellationToken.cs @@ -0,0 +1,112 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace ThingsGateway.Schedule; + +/// +/// 取消作业执行 Token 器 +/// +internal sealed class JobCancellationToken : IJobCancellationToken +{ + /// + /// 取消作业执行 Token 集合 + /// + private readonly ConcurrentDictionary _cancellationTokenSources; + + /// + /// 作业调度器日志服务 + /// + private readonly IScheduleLogger _logger; + + /// + /// 构造函数 + /// + /// 作业调度器日志服务 + public JobCancellationToken(IScheduleLogger logger) + { + _logger = logger; + _cancellationTokenSources = new(); + } + + /// + /// 获取或创建取消作业执行 Token + /// + /// 作业 Id + /// 作业触发器触发的唯一标识 + /// 后台主机服务停止时取消任务 Token + /// + public CancellationTokenSource GetOrCreate(string jobId, string runId, CancellationToken stoppingToken) + { + return _cancellationTokenSources.GetOrAdd(GetTokenKey(jobId, runId) + , _ => CancellationTokenSource.CreateLinkedTokenSource(stoppingToken)); + } + + /// + /// 取消(完成)正在执行的执行 + /// + /// 作业 Id + /// 作业触发器 Id + /// 是否显示日志 + public void Cancel(string jobId, string triggerId = null, bool outputLog = true) + { + var containsTriggerId = !string.IsNullOrWhiteSpace(triggerId); + + // 获取所有以作业 Id 或作业 Id + 作业触发器 Id 开头的作业 TokenKey + var allJobKeys = _cancellationTokenSources.Keys + .Where(u => u.StartsWith(!containsTriggerId + ? $"{jobId}__" + : $"{jobId}__{triggerId}___")); + + foreach (var tokenKey in allJobKeys) + { + try + { + if (_cancellationTokenSources.TryRemove(tokenKey, out var cancellationTokenSource)) + { + if (!cancellationTokenSource.IsCancellationRequested) cancellationTokenSource.Cancel(); + cancellationTokenSource.Dispose(); + + // 输出日志 + if (outputLog) + { + if (!containsTriggerId) _logger.LogWarning("The scheduler of <{JobId}> cancellation request has been sent to stop its execution.", jobId); + else _logger.LogWarning("The <{triggerId}> trigger for scheduler of <{jobId}> cancellation request has been sent to stop its execution.", triggerId, jobId); + } + } + else + { + // 输出日志 + if (outputLog) + { + if (!containsTriggerId) _logger.LogWarning(message: "The scheduler of <{jobId}> is not found.", jobId); + else _logger.LogWarning(message: "The <{triggerId}> trigger for scheduler of <{jobId}> is not found.", triggerId, jobId); + } + } + } + catch (TaskCanceledException) { } + catch (AggregateException ex) when (ex.InnerExceptions.Count == 1 && ex.InnerExceptions[0] is TaskCanceledException) { } + catch { } + } + } + + /// + /// 获取取消作业执行 Token 键 + /// + /// 作业 Id + /// 作业触发器触发的唯一标识 + /// + private static string GetTokenKey(string jobId, string runId) + { + return $"{jobId}__{runId}"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Constants/ClusterStatus.cs b/src/Admin/ThingsGateway.Furion/Schedule/Constants/ClusterStatus.cs new file mode 100644 index 000000000..3c10c551b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Constants/ClusterStatus.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业集群状态 +/// +[SuppressSniffer] +public enum ClusterStatus : uint +{ + /// + /// 宕机 + /// + Crashed = 0, + + /// + /// 工作中 + /// + Working = 1, + + /// + /// 等待被唤醒 + /// + Waiting = 2 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Constants/NamingConventions.cs b/src/Admin/ThingsGateway.Furion/Schedule/Constants/NamingConventions.cs new file mode 100644 index 000000000..0ec0f4d79 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Constants/NamingConventions.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 命名转换器 +/// +/// 用于生成持久化 SQL 语句 +[SuppressSniffer] +public enum NamingConventions +{ + /// + /// 驼峰命名法 + /// + /// 第一个单词首字母小写 + CamelCase = 0, + + /// + /// 帕斯卡命名法 + /// + /// 每一个单词首字母大写 + Pascal = 1, + + /// + /// 下划线命名法 + /// + /// 每次单词使用下划线连接且首字母都是小写 + UnderScoreCase = 2 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Constants/PersistenceBehavior.cs b/src/Admin/ThingsGateway.Furion/Schedule/Constants/PersistenceBehavior.cs new file mode 100644 index 000000000..5e72f9700 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Constants/PersistenceBehavior.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业持久化行为 +/// +[SuppressSniffer] +public enum PersistenceBehavior : uint +{ + /// + /// 添加 + /// + Appended = 0, + + /// + /// 更新 + /// + Updated = 1, + + /// + /// 删除 + /// + Removed = 2, +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Constants/ScheduleResult.cs b/src/Admin/ThingsGateway.Furion/Schedule/Constants/ScheduleResult.cs new file mode 100644 index 000000000..29b52f5c9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Constants/ScheduleResult.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业调度器操作结果 +/// +[SuppressSniffer] +public enum ScheduleResult +{ + /// + /// 不存在 + /// + NotFound = 0, + + /// + /// 未指定作业 Id + /// + NotIdentify = 1, + + /// + /// 已存在 + /// + Exists = 2, + + /// + /// 成功 + /// + Succeed = 3, + + /// + /// 失败 + /// + Failed = 4 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Constants/SqlTypes.cs b/src/Admin/ThingsGateway.Furion/Schedule/Constants/SqlTypes.cs new file mode 100644 index 000000000..c159b4bee --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Constants/SqlTypes.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// SQL 类型 +/// +/// 用于控制生成 SQL 格式 +[SuppressSniffer] +public enum SqlTypes +{ + /// + /// 标准 SQL + /// + Standard = 0, + + /// + /// SqlServer + /// + SqlServer = 1, + + /// + /// Sqlite + /// + Sqlite = 2, + + /// + /// MySql + /// + MySql = 3, + + /// + /// PostgresSQL + /// + PostgresSQL = 4, + + /// + /// Oracle + /// + Oracle = 5, + + /// + /// Firebird + /// + Firebird = 6 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Constants/TriggerStatus.cs b/src/Admin/ThingsGateway.Furion/Schedule/Constants/TriggerStatus.cs new file mode 100644 index 000000000..951726f72 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Constants/TriggerStatus.cs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业触发器状态 +/// +[SuppressSniffer] +public enum TriggerStatus : uint +{ + /// + /// 积压 + /// + /// 起始时间大于当前时间 + Backlog = 0, + + /// + /// 就绪 + /// + Ready = 1, + + /// + /// 正在运行 + /// + Running = 2, + + /// + /// 暂停 + /// + Pause = 3, + + /// + /// 阻塞 + /// + /// 本该执行但是没有执行 + Blocked = 4, + + /// + /// 由失败进入就绪 + /// + /// 运行错误当并未超出最大错误数,进入下一轮就绪 + ErrorToReady = 5, + + /// + /// 归档 + /// + /// 结束时间小于当前时间 + Archived = 6, + + /// + /// 崩溃 + /// + /// 错误次数超出了最大错误数 + Panic = 7, + + /// + /// 超限 + /// + /// 运行次数超出了最大限制 + Overrun = 8, + + /// + /// 无触发时间 + /// + /// 下一次执行时间为 null + Unoccupied = 9, + + /// + /// 未启动 + /// + NotStart = 10, + + /// + /// 未知作业触发器 + /// + /// 作业触发器运行时类型为 null + Unknown = 11, + + /// + /// 未知作业处理程序 + /// + /// 作业处理程序类型运行时类型为 null + Unhandled = 12 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobClusterContext.cs b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobClusterContext.cs new file mode 100644 index 000000000..fe869e7b3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobClusterContext.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业集群服务上下文 +/// +[SuppressSniffer] +public sealed class JobClusterContext +{ + /// + /// 构造函数 + /// + /// 作业集群 Id + internal JobClusterContext(string clusterId) + { + ClusterId = clusterId; + } + + /// + /// 作业集群 Id + /// + public string ClusterId { get; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobExecutedContext.cs b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobExecutedContext.cs new file mode 100644 index 000000000..522d65d03 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobExecutedContext.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业执行后上下文 +/// +[SuppressSniffer] +public sealed class JobExecutedContext : JobExecutionContext +{ + /// + /// 构造函数 + /// + /// 作业信息 + /// 作业触发器 + /// 作业计划触发时间 + /// 作业触发器触发的唯一标识 + /// 服务提供器 + internal JobExecutedContext(JobDetail jobDetail + , Trigger trigger + , DateTime occurrenceTime + , string runId + , IServiceProvider serviceProvider) + : base(jobDetail, trigger, occurrenceTime, runId, serviceProvider) + { + } + + /// + /// 执行后时间 + /// + public DateTime ExecutedTime { get; internal set; } + + /// + /// 异常信息 + /// + public Exception Exception { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobExecutingContext.cs b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobExecutingContext.cs new file mode 100644 index 000000000..ee1d8b718 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobExecutingContext.cs @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业执行前上下文 +/// +[SuppressSniffer] +public sealed class JobExecutingContext : JobExecutionContext +{ + /// + /// 构造函数 + /// + /// 作业信息 + /// 作业触发器 + /// 作业计划触发时间 + /// 作业触发器触发的唯一标识 + /// 服务提供器 + internal JobExecutingContext(JobDetail jobDetail + , Trigger trigger + , DateTime occurrenceTime + , string runId + , IServiceProvider serviceProvider) + : base(jobDetail, trigger, occurrenceTime, runId, serviceProvider) + { + } + + /// + /// 执行前时间 + /// + public DateTime ExecutingTime { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobExecutionContext.cs b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobExecutionContext.cs new file mode 100644 index 000000000..edaa53e5e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobExecutionContext.cs @@ -0,0 +1,119 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业执行上下文基类 +/// +public abstract class JobExecutionContext +{ + /// + /// 构造函数 + /// + /// 作业信息 + /// 作业触发器 + /// 作业计划触发时间 + /// 作业触发器触发的唯一标识 + /// 服务提供器 + internal JobExecutionContext(JobDetail jobDetail + , Trigger trigger + , DateTime occurrenceTime + , string runId + , IServiceProvider serviceProvider) + { + JobId = jobDetail.JobId; + TriggerId = trigger.TriggerId; + JobDetail = jobDetail; + Trigger = trigger; + OccurrenceTime = occurrenceTime; + RunId = runId; + ServiceProvider = serviceProvider; + } + + /// + /// 服务提供器 + /// + public IServiceProvider ServiceProvider { get; } + + /// + /// 作业 Id + /// + public string JobId { get; } + + /// + /// 作业触发器 Id + /// + public string TriggerId { get; } + + /// + /// 作业信息 + /// + public JobDetail JobDetail { get; } + + /// + /// 作业触发器 + /// + public Trigger Trigger { get; } + + /// + /// 作业计划触发时间 + /// + public DateTime OccurrenceTime { get; } + + /// + /// 作业触发器触发的唯一标识 + /// + public string RunId { get; } + + /// + /// 本次执行结果 + /// + public string Result { get; set; } + + /// + /// 触发模式 + /// + /// 默认为定时触发 + public int Mode { get; internal set; } + + /// + /// 转换成 JSON 字符串 + /// + /// 命名法 + /// + public string ConvertToJSON(NamingConventions naming = NamingConventions.CamelCase) + { + return Penetrates.Write(writer => + { + writer.WriteStartObject(); + + // 输出 JobDetail + writer.WritePropertyName(Penetrates.GetNaming(nameof(JobDetail), naming)); + writer.WriteRawValue(JobDetail.ConvertToJSON(naming)); + + // 输出 Trigger + writer.WritePropertyName(Penetrates.GetNaming(nameof(Trigger), naming)); + writer.WriteRawValue(Trigger.ConvertToJSON(naming)); + + writer.WriteEndObject(); + }); + } + + /// + /// 作业执行上下文转字符串输出输出 + /// + /// + public override string ToString() + { + return $"{JobDetail} {Trigger}{(Mode == 1 ? " Manual" : string.Empty)} {OccurrenceTime.ToFormatString()}{(Trigger.NextRunTime == null ? $" [{Trigger.Status}]" : $" -> {Trigger.NextRunTime.ToFormatString()}")}"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobFactoryContext.cs b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobFactoryContext.cs new file mode 100644 index 000000000..9901a7753 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/JobFactoryContext.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业处理程序工厂上下文 +/// +[SuppressSniffer] +public sealed class JobFactoryContext +{ + /// + /// 构造函数 + /// + /// 作业 Id + /// 作业类型 + public JobFactoryContext(string jobId, Type jobType) + { + JobId = jobId; + JobType = jobType; + } + + /// + /// 作业类型 + /// + public Type JobType { get; } + + /// + /// 作业 Id + /// + public string JobId { get; } + + /// + /// 触发模式 + /// + /// 默认为定时触发 + public int Mode { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Contexts/PersistenceContext.cs b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/PersistenceContext.cs new file mode 100644 index 000000000..709e42743 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/PersistenceContext.cs @@ -0,0 +1,101 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业信息持久化上下文 +/// +[SuppressSniffer] +public class PersistenceContext +{ + /// + /// 构造函数 + /// + /// 作业信息 + /// 作业持久化行为 + internal PersistenceContext(JobDetail jobDetail + , PersistenceBehavior behavior) + { + JobId = jobDetail.JobId; + JobDetail = jobDetail; + Behavior = behavior; + } + + /// + /// 作业 Id + /// + public string JobId { get; } + + /// + /// 作业信息 + /// + public JobDetail JobDetail { get; } + + /// + /// 作业持久化行为 + /// + public PersistenceBehavior Behavior { get; } + + /// + /// 转换成 Sql 语句 + /// + /// 数据库表名 + /// 命名法 + /// + public string ConvertToSQL(string tableName, NamingConventions naming = NamingConventions.CamelCase) + { + return JobDetail.ConvertToSQL(tableName, Behavior, naming); + } + + /// + /// 转换成 JSON 语句 + /// + /// 命名法 + /// + public string ConvertToJSON(NamingConventions naming = NamingConventions.CamelCase) + { + return JobDetail.ConvertToJSON(naming); + } + + /// + /// 转换成 Monitor 字符串 + /// + /// 命名法 + /// + public string ConvertToMonitor(NamingConventions naming = NamingConventions.CamelCase) + { + return JobDetail.ConvertToMonitor(naming); + } + + /// + /// 根据不同的命名法返回属性名 + /// + /// 属性名 + /// 命名法 + /// + public string GetNaming(string propertyName, NamingConventions naming = NamingConventions.CamelCase) + { + // 空检查 + if (!string.IsNullOrWhiteSpace(propertyName)) return propertyName; + + return Penetrates.GetNaming(propertyName, naming); + } + + /// + /// 作业信息持久化上下文转字符串输出 + /// + /// + public override string ToString() + { + return $"{JobDetail} [{Behavior}]"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Contexts/PersistenceExecutionRecordContext.cs b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/PersistenceExecutionRecordContext.cs new file mode 100644 index 000000000..ada7169cf --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/PersistenceExecutionRecordContext.cs @@ -0,0 +1,80 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业执行记录持久上下文 +/// +[SuppressSniffer] +public sealed class PersistenceExecutionRecordContext +{ + /// + /// 构造函数 + /// + /// 作业信息 + /// 作业触发器 + /// 触发模式 + /// 作业触发器运行记录 + internal PersistenceExecutionRecordContext(JobDetail jobDetail + , Trigger trigger + , int mode + , TriggerTimeline timeline) + { + JobId = jobDetail.JobId; + JobDetail = jobDetail; + TriggerId = trigger.TriggerId; + Trigger = trigger; + Mode = mode; + + Timeline = timeline; + } + + /// + /// 作业 Id + /// + public string JobId { get; } + + /// + /// 作业信息 + /// + public JobDetail JobDetail { get; } + + /// + /// 作业触发器 Id + /// + public string TriggerId { get; } + + /// + /// 作业触发器 + /// + public Trigger Trigger { get; } + + /// + /// 触发模式 + /// + /// 默认为定时触发 + public int Mode { get; } + + /// + /// 作业触发器运行记录 + /// + public TriggerTimeline Timeline { get; } + + /// + /// 作业执行记录持久上下文转字符串输出 + /// + /// + public override string ToString() + { + return $"{JobDetail} {Trigger}{(Mode == 1 ? " Manual" : string.Empty)} {Timeline.LastRunTime.ToFormatString()}{(Timeline.NextRunTime == null ? $" [{Timeline.Status}]" : $" -> {Timeline.NextRunTime.ToFormatString()}")} {Timeline.ElapsedTime}ms"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Contexts/PersistenceTriggerContext.cs b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/PersistenceTriggerContext.cs new file mode 100644 index 000000000..6d281f49a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Contexts/PersistenceTriggerContext.cs @@ -0,0 +1,113 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// 作业触发器持久化上下文 +/// +[SuppressSniffer] +public sealed class PersistenceTriggerContext : PersistenceContext +{ + /// + /// 构造函数 + /// + /// 作业信息 + /// 作业触发器 + /// 作业持久化行为 + internal PersistenceTriggerContext(JobDetail jobDetail + , Trigger trigger + , PersistenceBehavior behavior) + : base(jobDetail, behavior) + { + TriggerId = trigger.TriggerId; + Trigger = trigger; + } + + /// + /// 作业触发器 Id + /// + public string TriggerId { get; } + + /// + /// 作业触发器 + /// + public Trigger Trigger { get; } + + /// + /// 触发模式 + /// + /// 默认为定时触发 + public int Mode { get; internal set; } + + /// + /// 转换成 Sql 语句 + /// + /// 数据库表名 + /// 命名法 + /// + public new string ConvertToSQL(string tableName, NamingConventions naming = NamingConventions.CamelCase) + { + return Trigger.ConvertToSQL(tableName, Behavior, naming); + } + + /// + /// 转换成 JSON 语句 + /// + /// 命名法 + /// + public new string ConvertToJSON(NamingConventions naming = NamingConventions.CamelCase) + { + return Trigger.ConvertToJSON(naming); + } + + /// + /// 转换作业计划成 JSON 语句 + /// + /// 命名法 + /// + public string ConvertAllToJSON(NamingConventions naming = NamingConventions.CamelCase) + { + return Penetrates.Write(writer => + { + writer.WriteStartObject(); + + // 输出 JobDetail + writer.WritePropertyName(Penetrates.GetNaming(nameof(JobDetail), naming)); + writer.WriteRawValue(JobDetail.ConvertToJSON(naming)); + + // 输出 Trigger + writer.WritePropertyName(Penetrates.GetNaming(nameof(Trigger), naming)); + writer.WriteRawValue(Trigger.ConvertToJSON(naming)); + + writer.WriteEndObject(); + }); + } + + /// + /// 转换成 Monitor 字符串 + /// + /// 命名法 + /// + public new string ConvertToMonitor(NamingConventions naming = NamingConventions.CamelCase) + { + return Trigger.ConvertToMonitor(naming); + } + + /// + /// 作业触发器持久化上下文转字符串输出 + /// + /// + public override string ToString() + { + return $"{JobDetail} {Trigger} [{Behavior}]{(Trigger.NextRunTime == null ? $" [{Trigger.Status}]" : $" -> {Trigger.NextRunTime.ToFormatString()}")}"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Converters/DateTimeJsonConverter.cs b/src/Admin/ThingsGateway.Furion/Schedule/Converters/DateTimeJsonConverter.cs new file mode 100644 index 000000000..0cdc0afa4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Converters/DateTimeJsonConverter.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingsGateway.Schedule; + +/// +/// DateTime 类型序列化/反序列化处理 +/// +internal sealed class DateTimeJsonConverter : JsonConverter +{ + /// + /// 反序列化 + /// + /// + /// 需要转换的类型 + /// 序列化配置选项 + /// + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return Convert.ToDateTime(reader.GetString()); + } + + /// + /// 序列化 + /// + /// + /// + /// 序列化配置选项 + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIExtensions.cs b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIExtensions.cs new file mode 100644 index 000000000..c67b20f9d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIExtensions.cs @@ -0,0 +1,81 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; + +using ThingsGateway.Schedule; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Schedule 模块 UI 中间件拓展 +/// +[SuppressSniffer] +public static class ScheduleUIExtensions +{ + /// + /// 添加 Schedule 模块 UI 中间件 + /// + /// + /// Schedule 模块 UI 配置选项委托 + /// + public static IApplicationBuilder UseScheduleUI(this IApplicationBuilder app, Action configureAction = default) + { + var scheduleUIOptions = new ScheduleUIOptions(); + configureAction?.Invoke(scheduleUIOptions); + + return app.UseScheduleUI(scheduleUIOptions); + } + + /// + /// 添加 Schedule 模块 UI 中间件 + /// + /// + /// Schedule 模块 UI 配置选项 + /// + public static IApplicationBuilder UseScheduleUI(this IApplicationBuilder app, ScheduleUIOptions options) + { + // 判断是否配置了定时任务服务 + if (app.ApplicationServices.GetService() == null) return app; + + // 初始化默认值 + options ??= new ScheduleUIOptions(); + + // 生产环境关闭 + if (options.DisableOnProduction + && app.ApplicationServices.GetRequiredService().IsProduction()) return app; + + // 如果入口地址为空则不启动看板 + if (string.IsNullOrWhiteSpace(options.RequestPath)) return app; + + // 修复无效的入口地址 + options.RequestPath = $"/{options.RequestPath.TrimStart('/').TrimEnd('/')}"; + + // 注册 Schedule 中间件 + app.UseMiddleware(options); + + // 获取当前类型所在程序集 + var currentAssembly = typeof(ScheduleUIExtensions).Assembly; + + // 注册嵌入式文件服务器 + app.UseFileServer(new FileServerOptions + { + FileProvider = new EmbeddedFileProvider(currentAssembly, $"{currentAssembly.GetName().Name}.Schedule.Dashboard.frontend"), + RequestPath = options.RequestPath, + EnableDirectoryBrowsing = options.EnableDirectoryBrowsing + }); + + return app; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIMiddleware.cs b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIMiddleware.cs new file mode 100644 index 000000000..9b1e297c6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIMiddleware.cs @@ -0,0 +1,330 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; + +namespace ThingsGateway.Schedule; + +/// +/// Schedule 模块 UI 中间件 +/// +[SuppressSniffer] +public sealed class ScheduleUIMiddleware +{ + private const string STATIC_FILES_PATH = "/__schedule__"; + + /// + /// 请求委托 + /// + private readonly RequestDelegate _next; + + /// + /// 作业计划工厂 + /// + private readonly ISchedulerFactory _schedulerFactory; + + /// + /// 构造函数 + /// + /// 请求委托 + /// 作业计划工厂 + /// UI 配置选项 + public ScheduleUIMiddleware(RequestDelegate next + , ISchedulerFactory schedulerFactory + , ScheduleUIOptions options) + { + _next = next; + _schedulerFactory = schedulerFactory; + Options = options; + ApiRequestPath = $"{options.RequestPath}/api"; + } + + /// + /// UI 配置选项 + /// + public ScheduleUIOptions Options { get; } + + /// + /// API 入口地址 + /// + public string ApiRequestPath { get; } + + /// + /// 中间件执行方法 + /// + /// + /// + public async Task InvokeAsync(HttpContext context) + { + // 非看板请求跳过 + if (!context.Request.Path.StartsWithSegments(Options.RequestPath, StringComparison.OrdinalIgnoreCase)) + { + await _next(context).ConfigureAwait(false); + return; + } + + // ================================ 处理静态文件请求 ================================ + var staticFilePath = Options.RequestPath + "/"; + if (context.Request.Path.Equals(staticFilePath, StringComparison.OrdinalIgnoreCase) || context.Request.Path.Equals(staticFilePath + "apiconfig.js", StringComparison.OrdinalIgnoreCase)) + { + var targetPath = context.Request.Path.Value?[staticFilePath.Length..]; + var isIndex = string.IsNullOrEmpty(targetPath); + + // 获取当前类型所在程序集和对应嵌入式文件路径 + var currentAssembly = typeof(ScheduleUIExtensions).Assembly; + + // 读取配置文件内容 + byte[] buffer; + using (var readStream = currentAssembly.GetManifestResourceStream($"{currentAssembly.GetName().Name}.Schedule.Dashboard.frontend.{(isIndex ? "index.html" : targetPath)}")) + { + buffer = new byte[readStream.Length]; + _ = await readStream.ReadAsync(buffer).ConfigureAwait(false); + } + + // 替换配置占位符 + string content; + using (var stream = new MemoryStream(buffer)) + { + using var streamReader = new StreamReader(stream, new UTF8Encoding(false)); + content = await streamReader.ReadToEndAsync().ConfigureAwait(false); + content = isIndex + ? content.Replace(STATIC_FILES_PATH, $"{Options.VirtualPath}{Options.RequestPath}") + : content.Replace("%(RequestPath)", $"{Options.VirtualPath}{Options.RequestPath}") + .Replace("%(DisplayEmptyTriggerJobs)", Options.DisplayEmptyTriggerJobs ? "true" : "false") + .Replace("%(DisplayHead)", Options.DisplayHead ? "true" : "false") + .Replace("%(DefaultExpandAllJobs)", Options.DefaultExpandAllJobs ? "true" : "false") + .Replace("%(UseUtcTimestamp)", ScheduleOptionsBuilder.UseUtcTimestampProperty ? "true" : "false"); + } + + // 输出到客户端 + context.Response.ContentType = $"text/{(isIndex ? "html" : "javascript")}; charset=utf-8"; + await context.Response.WriteAsync(content).ConfigureAwait(false); + return; + } + + // ================================ 处理 API 请求 ================================ + + // 如果不是以 API_REQUEST_PATH 开头,则跳过 + if (!context.Request.Path.StartsWithSegments(ApiRequestPath, StringComparison.OrdinalIgnoreCase)) + { + await _next(context).ConfigureAwait(false); + return; + } + + // 只处理 GET/POST 请求 + if (!context.Request.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) && !context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase)) + { + await _next(context).ConfigureAwait(false); + return; + } + + // 获取匹配的路由标识 + var action = context.Request.Path.Value?[ApiRequestPath.Length..]?.ToLower(); + + // 允许跨域,设置返回 json + context.Response.ContentType = "application/json; charset=utf-8"; + context.Response.Headers["Access-Control-Allow-Origin"] = "*"; + context.Response.Headers["Access-Control-Allow-Headers"] = "*"; + + // 路由匹配 + switch (action) + { + // 获取所有作业 + case "/get-jobs": + var jobs = _schedulerFactory.GetJobsOfModels().OrderBy(u => u.JobDetail.GroupName); + + // 输出 JSON + await context.Response.WriteAsync(SerializeToJson(jobs)).ConfigureAwait(false); + break; + // 操作作业 + case "/operate-job": + // 获取作业 Id + var jobId = context.Request.Query["jobid"]; + // 获取操作方法 + var operate = context.Request.Query["action"]; + + // 获取作业计划 + var scheduleResult = _schedulerFactory.TryGetJob(jobId, out var scheduler); + + // 处理找不到作业情况 + if (scheduleResult != ScheduleResult.Succeed) + { + // 标识状态码为 500 + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + + // 输出 JSON + await context.Response.WriteAsync(SerializeToJson(new + { + msg = scheduleResult.ToString(), + ok = false + })).ConfigureAwait(false); + + return; + } + + switch (operate) + { + // 启动作业 + case "start": + scheduler?.Start(); + break; + // 暂停作业 + case "pause": + scheduler?.Pause(); + break; + // 移除作业 + case "remove": + _schedulerFactory.RemoveJob(jobId); + break; + // 立即执行 + case "run": + _schedulerFactory.RunJob(jobId); + break; + } + + // 输出 JSON + await context.Response.WriteAsync(SerializeToJson(new + { + msg = ScheduleResult.Succeed.ToString(), + ok = true + })).ConfigureAwait(false); + + break; + // 操作触发器 + case "/operate-trigger": + // 获取作业 Id + var jobId1 = context.Request.Query["jobid"]; + var triggerId = context.Request.Query["triggerid"]; + // 获取操作方法 + var operate1 = context.Request.Query["action"]; + + // 获取作业计划 + var scheduleResult1 = _schedulerFactory.TryGetJob(jobId1, out var scheduler1); + + // 处理找不到作业情况 + if (scheduleResult1 != ScheduleResult.Succeed) + { + // 标识状态码为 500 + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + + // 输出 JSON + await context.Response.WriteAsync(SerializeToJson(new + { + msg = scheduleResult1.ToString(), + ok = false + })).ConfigureAwait(false); + + return; + } + + switch (operate1) + { + // 启动作业触发器 + case "start": + scheduler1?.StartTrigger(triggerId); + break; + // 暂停作业触发器 + case "pause": + scheduler1?.PauseTrigger(triggerId); + break; + // 移除作业触发器 + case "remove": + scheduler1?.RemoveTrigger(triggerId); + break; + // 立即执行 + case "run": + scheduler1?.Run(triggerId); + break; + // 获取作业触发器最近运行时间 + case "timelines": + var trigger = scheduler1?.GetTrigger(triggerId); + var timelines = trigger?.GetTimelines() ?? Array.Empty(); + + // 输出 JSON + await context.Response.WriteAsync(SerializeToJson(timelines)).ConfigureAwait(false); + return; + } + + // 输出 JSON + await context.Response.WriteAsync(SerializeToJson(new + { + msg = ScheduleResult.Succeed.ToString(), + ok = true + })).ConfigureAwait(false); + + break; + + // 推送更新 + case "/check-change": + // 检查请求类型,是否为 text/event-stream 格式 + if (!context.WebSockets.IsWebSocketRequest && context.Request.Headers["Accept"].ToString().Contains("text/event-stream")) + { + // 设置响应头的 content-type 为 text/event-stream + context.Response.ContentType = "text/event-stream"; + + // 设置响应头,启用响应发送保持活动性 + context.Response.Headers.CacheControl = "no-cache"; + context.Response.Headers.Connection = "keep-alive"; + + // 防止 Nginx 缓存 Server-Sent Events + context.Response.Headers["X-Accel-Buffering"] = "no"; + + var queue = new BlockingCollection(); + + // 监听作业计划变化 + void Subscribe(object sender, SchedulerEventArgs args) + { + if (!queue.IsAddingCompleted) + { + queue.Add(args.JobDetail); + } + } + _schedulerFactory.OnChanged += Subscribe; + + // 持续发送 SSE 协议数据 + foreach (var jobDetail in queue.GetConsumingEnumerable()) + { + // 如果请求已终止则停止推送 + if (!context.RequestAborted.IsCancellationRequested) + { + var message = "data: " + SerializeToJson(jobDetail) + "\n\n"; + await context.Response.WriteAsync(message, context.RequestAborted).ConfigureAwait(false); + //await context.Response.Body.FlushAsync(); + } + else break; + } + + queue.CompleteAdding(); + _schedulerFactory.OnChanged -= Subscribe; + } + break; + } + } + + /// + /// 将对象输出为 JSON 字符串 + /// + /// 对象 + /// + private static string SerializeToJson(object obj) + { + // 初始化默认序列化选项 + var jsonSerializerOptions = Penetrates.GetDefaultJsonSerializerOptions(); + jsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + + return JsonSerializer.Serialize(obj, jsonSerializerOptions); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIOptions.cs b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIOptions.cs new file mode 100644 index 000000000..da466c423 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/backend/ScheduleUIOptions.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Schedule; + +/// +/// Schedule UI 配置选项 +/// +[SuppressSniffer] +public sealed class ScheduleUIOptions +{ + /// + /// UI 入口地址 + /// + /// 需以 / 开头,结尾不包含 / + public string RequestPath { get; set; } = "/schedule"; + + /// + /// 启用目录浏览 + /// + public bool EnableDirectoryBrowsing { get; set; } = false; + + /// + /// 生产环境关闭 + /// + /// 默认 false + public bool DisableOnProduction { get; set; } = false; + + /// + /// 二级虚拟目录 + /// + /// 需以 / 开头,结尾不包含 / + public string VirtualPath { get; set; } + + /// + /// 是否显示空触发器的作业信息 + /// + public bool DisplayEmptyTriggerJobs { get; set; } = true; + + /// + /// 是否显示页头 + /// + public bool DisplayHead { get; set; } = true; + + /// + /// 是否默认展开所有作业 + /// + public bool DefaultExpandAllJobs { get; set; } = false; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/apiconfig.js b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/apiconfig.js new file mode 100644 index 000000000..9c2df23c7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/apiconfig.js @@ -0,0 +1 @@ +window.apiconfig = { requestPath: "%(RequestPath)", hostAddress: "%(RequestPath)/api", options: { headers: { Accept: "application/json" }, cachePolicy: "no-cache" }, displayEmptyTriggerJobs: "%(DisplayEmptyTriggerJobs)", displayHead: "%(DisplayHead)", defaultExpandAllJobs: "%(DefaultExpandAllJobs)", useUtcTimestamp: "%(UseUtcTimestamp)" }; \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/asset-manifest.json b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/asset-manifest.json new file mode 100644 index 000000000..4ae214a8e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/asset-manifest.json @@ -0,0 +1,13 @@ +{ + "files": { + "main.css": "/__schedule__/static/css/main.8eb42378.css", + "main.js": "/__schedule__/static/js/main.78b3d71a.js", + "index.html": "/__schedule__/index.html", + "main.8eb42378.css.map": "/__schedule__/static/css/main.8eb42378.css.map", + "main.78b3d71a.js.map": "/__schedule__/static/js/main.78b3d71a.js.map" + }, + "entrypoints": [ + "static/css/main.8eb42378.css", + "static/js/main.78b3d71a.js" + ] +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/favicon.ico b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/favicon.ico new file mode 100644 index 000000000..407a025af Binary files /dev/null and b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/favicon.ico differ diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/index.html b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/index.html new file mode 100644 index 000000000..2ea3f8415 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/index.html @@ -0,0 +1 @@ +Schedule Dashboard
\ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/static/css/main.8eb42378.css b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/static/css/main.8eb42378.css new file mode 100644 index 000000000..1f90e8078 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/static/css/main.8eb42378.css @@ -0,0 +1,2 @@ +:host,body{--semi-transition_duration-slowest:0ms;--semi-transition_duration-slower:0ms;--semi-transition_duration-slow:0ms;--semi-transition_duration-normal:0ms;--semi-transition_duration-fast:0ms;--semi-transition_duration-faster:0ms;--semi-transition_duration-fastest:0ms;--semi-transition_duration-none:0ms;--semi-transition_function-linear:linear;--semi-transition_function-ease:ease;--semi-transition_function-easeIn:ease-in;--semi-transition_function-easeOut:ease-out;--semi-transition_function-easeInIOut:ease-in-out;--semi-transition_delay-none:0ms;--semi-transition_delay-slowest:0ms;--semi-transition_delay-slower:0ms;--semi-transition_delay-slow:0ms;--semi-transition_delay-normal:0ms;--semi-transition_delay-fast:0ms;--semi-transition_delay-faster:0ms;--semi-transition_delay-fastest:0ms;--semi-transform_scale-none:scale(1,1);--semi-transform_scale-small:scale(1,1);--semi-transform_scale-medium:scale(1,1);--semi-transform_scale-large:scale(1,1);--semi-transform-rotate-none:rotate(0deg);--semi-transform_rotate-clockwise90deg:rotate(90deg);--semi-transform_rotate-clockwise180deg:rotate(180deg);--semi-transform_rotate-clockwise270deg:rotate(270deg);--semi-transform_rotate-clockwise360deg:rotate(360deg);--semi-transform_rotate-anticlockwise90deg:rotate(-90deg);--semi-transform_rotate-anticlockwise180deg:rotate(-180deg);--semi-transform_rotate-anticlockwise270deg:rotate(-270deg);--semi-transform_rotate-anticlockwise360deg:rotate(-360deg)}:host,:host .semi-always-light,body,body .semi-always-light{--semi-amber-0:254,251,235;--semi-amber-1:252,245,206;--semi-amber-2:249,232,158;--semi-amber-3:246,216,111;--semi-amber-4:243,198,65;--semi-amber-5:240,177,20;--semi-amber-6:200,138,15;--semi-amber-7:160,102,10;--semi-amber-8:120,70,6;--semi-amber-9:80,43,3;--semi-black:0,0,0;--semi-blue-0:234,245,255;--semi-blue-1:203,231,254;--semi-blue-2:152,205,253;--semi-blue-3:101,178,252;--semi-blue-4:50,149,251;--semi-blue-5:0,100,250;--semi-blue-6:0,98,214;--semi-blue-7:0,79,179;--semi-blue-8:0,61,143;--semi-blue-9:0,44,107;--semi-cyan-0:229,247,248;--semi-cyan-1:194,239,240;--semi-cyan-2:138,221,226;--semi-cyan-3:88,203,211;--semi-cyan-4:44,184,197;--semi-cyan-5:5,164,182;--semi-cyan-6:3,134,152;--semi-cyan-7:1,105,121;--semi-cyan-8:0,77,91;--semi-cyan-9:0,50,61;--semi-green-0:236,247,236;--semi-green-1:208,240,209;--semi-green-2:164,224,167;--semi-green-3:125,209,130;--semi-green-4:90,194,98;--semi-green-5:59,179,70;--semi-green-6:48,149,59;--semi-green-7:37,119,47;--semi-green-8:27,89,36;--semi-green-9:17,60,24;--semi-grey-0:249,249,249;--semi-grey-1:230,232,234;--semi-grey-2:198,202,205;--semi-grey-3:167,171,176;--semi-grey-4:136,141,146;--semi-grey-5:107,112,117;--semi-grey-6:85,91,97;--semi-grey-7:65,70,76;--semi-grey-8:46,50,56;--semi-grey-9:28,31,35;--semi-indigo-0:236,239,248;--semi-indigo-1:209,216,240;--semi-indigo-2:167,179,225;--semi-indigo-3:128,144,211;--semi-indigo-4:94,111,196;--semi-indigo-5:63,81,181;--semi-indigo-6:51,66,161;--semi-indigo-7:40,52,140;--semi-indigo-8:31,40,120;--semi-indigo-9:23,29,99;--semi-light-blue-0:233,247,253;--semi-light-blue-1:201,236,252;--semi-light-blue-2:149,216,248;--semi-light-blue-3:98,195,245;--semi-light-blue-4:48,172,241;--semi-light-blue-5:0,149,238;--semi-light-blue-6:0,123,202;--semi-light-blue-7:0,99,167;--semi-light-blue-8:0,75,131;--semi-light-blue-9:0,53,95;--semi-light-green-0:243,248,236;--semi-light-green-1:227,240,208;--semi-light-green-2:200,226,165;--semi-light-green-3:173,211,126;--semi-light-green-4:147,197,91;--semi-light-green-5:123,182,60;--semi-light-green-6:100,152,48;--semi-light-green-7:78,121,38;--semi-light-green-8:57,91,27;--semi-light-green-9:37,61,18;--semi-lime-0:242,250,230;--semi-lime-1:227,246,197;--semi-lime-2:203,237,142;--semi-lime-3:183,227,91;--semi-lime-4:167,218,44;--semi-lime-5:155,209,0;--semi-lime-6:126,174,0;--semi-lime-7:99,139,0;--semi-lime-8:72,104,0;--semi-lime-9:47,70,0;--semi-orange-0:255,248,234;--semi-orange-1:254,238,204;--semi-orange-2:254,217,152;--semi-orange-3:253,193,101;--semi-orange-4:253,166,51;--semi-orange-5:252,136,0;--semi-orange-6:210,103,0;--semi-orange-7:168,74,0;--semi-orange-8:126,49,0;--semi-orange-9:84,29,0;--semi-pink-0:253,236,239;--semi-pink-1:251,207,216;--semi-pink-2:246,160,181;--semi-pink-3:242,115,150;--semi-pink-4:237,72,123;--semi-pink-5:233,30,99;--semi-pink-6:197,19,86;--semi-pink-7:162,11,72;--semi-pink-8:126,5,58;--semi-pink-9:90,1,43;--semi-purple-0:247,233,247;--semi-purple-1:239,202,240;--semi-purple-2:221,155,224;--semi-purple-3:201,111,209;--semi-purple-4:180,73,194;--semi-purple-5:158,40,179;--semi-purple-6:135,30,158;--semi-purple-7:113,22,138;--semi-purple-8:92,15,117;--semi-purple-9:73,10,97;--semi-red-0:254,242,237;--semi-red-1:254,221,210;--semi-red-2:253,183,165;--semi-red-3:251,144,120;--semi-red-4:250,102,76;--semi-red-5:249,57,32;--semi-red-6:213,37,21;--semi-red-7:178,20,12;--semi-red-8:142,8,5;--semi-red-9:106,1,3;--semi-teal-0:228,247,244;--semi-teal-1:192,240,232;--semi-teal-2:135,224,211;--semi-teal-3:84,209,193;--semi-teal-4:39,194,176;--semi-teal-5:0,179,161;--semi-teal-6:0,149,137;--semi-teal-7:0,119,111;--semi-teal-8:0,89,85;--semi-teal-9:0,60,58;--semi-violet-0:243,237,249;--semi-violet-1:226,209,244;--semi-violet-2:196,167,233;--semi-violet-3:166,127,221;--semi-violet-4:136,91,210;--semi-violet-5:106,58,199;--semi-violet-6:87,47,179;--semi-violet-7:70,37,158;--semi-violet-8:54,28,138;--semi-violet-9:40,20,117;--semi-white:255,255,255;--semi-yellow-0:255,253,234;--semi-yellow-1:254,251,203;--semi-yellow-2:253,243,152;--semi-yellow-3:252,232,101;--semi-yellow-4:251,218,50;--semi-yellow-5:250,200,0;--semi-yellow-6:208,170,0;--semi-yellow-7:167,139,0;--semi-yellow-8:125,106,0;--semi-yellow-9:83,72,0}:host .semi-always-dark,:host([theme-mode=dark]),body .semi-always-dark,body[theme-mode=dark]{--semi-red-0:108,9,11;--semi-red-1:144,17,16;--semi-red-2:180,32,25;--semi-red-3:215,51,36;--semi-red-4:251,73,50;--semi-red-5:252,114,90;--semi-red-6:253,153,131;--semi-red-7:253,190,172;--semi-red-8:254,224,213;--semi-red-9:255,243,239;--semi-pink-0:92,7,48;--semi-pink-1:128,14,65;--semi-pink-2:164,23,81;--semi-pink-3:199,34,97;--semi-pink-4:235,47,113;--semi-pink-5:239,86,134;--semi-pink-6:243,126,159;--semi-pink-7:247,168,188;--semi-pink-8:251,211,220;--semi-pink-9:253,238,241;--semi-purple-0:74,16,97;--semi-purple-1:94,23,118;--semi-purple-2:115,31,138;--semi-purple-3:137,40,159;--semi-purple-4:160,51,179;--semi-purple-5:181,83,194;--semi-purple-6:202,120,209;--semi-purple-7:221,160,225;--semi-purple-8:239,206,240;--semi-purple-9:247,235,247;--semi-violet-0:64,27,119;--semi-violet-1:76,36,140;--semi-violet-2:88,46,160;--semi-violet-3:100,57,181;--semi-violet-4:114,70,201;--semi-violet-5:136,101,212;--semi-violet-6:162,136,223;--semi-violet-7:190,173,233;--semi-violet-8:221,212,244;--semi-violet-9:241,238,250;--semi-indigo-0:23,30,101;--semi-indigo-1:32,41,122;--semi-indigo-2:41,54,142;--semi-indigo-3:52,68,163;--semi-indigo-4:64,83,183;--semi-indigo-5:95,113,197;--semi-indigo-6:129,145,212;--semi-indigo-7:167,180,226;--semi-indigo-8:209,216,241;--semi-indigo-9:237,239,248;--semi-blue-0:5,49,112;--semi-blue-1:10,70,148;--semi-blue-2:19,92,184;--semi-blue-3:29,117,219;--semi-blue-4:41,144,255;--semi-blue-5:84,169,255;--semi-blue-6:127,193,255;--semi-blue-7:169,215,255;--semi-blue-8:212,236,255;--semi-blue-9:239,248,255;--semi-light-blue-0:0,55,97;--semi-light-blue-1:0,77,133;--semi-light-blue-2:3,102,169;--semi-light-blue-3:10,129,204;--semi-light-blue-4:19,159,240;--semi-light-blue-5:64,180,243;--semi-light-blue-6:110,200,246;--semi-light-blue-7:157,220,249;--semi-light-blue-8:206,238,252;--semi-light-blue-9:235,248,254;--semi-cyan-0:4,52,61;--semi-cyan-1:7,79,92;--semi-cyan-2:10,108,123;--semi-cyan-3:14,137,153;--semi-cyan-4:19,168,184;--semi-cyan-5:56,187,198;--semi-cyan-6:98,205,212;--semi-cyan-7:145,223,227;--semi-cyan-8:198,239,241;--semi-cyan-9:231,247,248;--semi-teal-0:2,60,57;--semi-teal-1:4,90,85;--semi-teal-2:7,119,111;--semi-teal-3:10,149,136;--semi-teal-4:14,179,161;--semi-teal-5:51,194,176;--semi-teal-6:94,209,193;--semi-teal-7:142,225,211;--semi-teal-8:196,240,232;--semi-teal-9:230,247,244;--semi-green-0:18,60,25;--semi-green-1:28,90,37;--semi-green-2:39,119,49;--semi-green-3:50,149,61;--semi-green-4:62,179,73;--semi-green-5:93,194,100;--semi-green-6:127,209,132;--semi-green-7:166,225,168;--semi-green-8:208,240,209;--semi-green-9:236,247,236;--semi-light-green-0:38,61,19;--semi-light-green-1:59,92,29;--semi-light-green-2:81,123,40;--semi-light-green-3:103,153,52;--semi-light-green-4:127,184,64;--semi-light-green-5:151,198,95;--semi-light-green-6:176,212,129;--semi-light-green-7:201,227,167;--semi-light-green-8:228,241,209;--semi-light-green-9:243,248,237;--semi-lime-0:49,70,3;--semi-lime-1:75,105,5;--semi-lime-2:103,141,9;--semi-lime-3:132,176,12;--semi-lime-4:162,211,17;--semi-lime-5:174,220,58;--semi-lime-6:189,229,102;--semi-lime-7:207,237,150;--semi-lime-8:229,246,201;--semi-lime-9:243,251,233;--semi-yellow-0:84,73,3;--semi-yellow-1:126,108,6;--semi-yellow-2:168,142,10;--semi-yellow-3:210,175,15;--semi-yellow-4:252,206,20;--semi-yellow-5:253,222,67;--semi-yellow-6:253,235,113;--semi-yellow-7:254,245,160;--semi-yellow-8:254,251,208;--semi-yellow-9:255,254,236;--semi-amber-0:81,46,9;--semi-amber-1:121,75,15;--semi-amber-2:161,107,22;--semi-amber-3:202,143,30;--semi-amber-4:242,183,38;--semi-amber-5:245,202,80;--semi-amber-6:247,219,122;--semi-amber-7:250,234,166;--semi-amber-8:252,246,210;--semi-amber-9:254,251,237;--semi-orange-0:85,31,3;--semi-orange-1:128,53,6;--semi-orange-2:170,80,10;--semi-orange-3:213,111,15;--semi-orange-4:255,146,20;--semi-orange-5:255,174,67;--semi-orange-6:255,199,114;--semi-orange-7:255,221,161;--semi-orange-8:255,239,208;--semi-orange-9:255,249,237;--semi-grey-0:28,31,35;--semi-grey-1:46,50,56;--semi-grey-2:65,70,76;--semi-grey-3:85,91,97;--semi-grey-4:107,112,117;--semi-grey-5:136,141,146;--semi-grey-6:167,171,176;--semi-grey-7:198,202,205;--semi-grey-8:230,232,234;--semi-grey-9:249,249,249;--semi-white:255,255,255;--semi-black:0,0,0}:host,:host .semi-always-light,body,body[theme-mode=dark] .semi-always-light{-webkit-font-smoothing:antialiased;--semi-color-white:rgba(var(--semi-white),1);--semi-color-black:rgba(var(--semi-black),1);--semi-color-primary:rgba(var(--semi-blue-5),1);--semi-color-primary-hover:rgba(var(--semi-blue-6),1);--semi-color-primary-active:rgba(var(--semi-blue-7),1);--semi-color-primary-disabled:rgba(var(--semi-blue-2),1);--semi-color-primary-light-default:rgba(var(--semi-blue-0),1);--semi-color-primary-light-hover:rgba(var(--semi-blue-1),1);--semi-color-primary-light-active:rgba(var(--semi-blue-2),1);--semi-color-secondary:rgba(var(--semi-light-blue-5),1);--semi-color-secondary-hover:rgba(var(--semi-light-blue-6),1);--semi-color-secondary-active:rgba(var(--semi-light-blue-7),1);--semi-color-secondary-disabled:rgba(var(--semi-light-blue-2),1);--semi-color-secondary-light-default:rgba(var(--semi-light-blue-0),1);--semi-color-secondary-light-hover:rgba(var(--semi-light-blue-1),1);--semi-color-secondary-light-active:rgba(var(--semi-light-blue-2),1);--semi-color-tertiary:rgba(var(--semi-grey-5),1);--semi-color-tertiary-hover:rgba(var(--semi-grey-6),1);--semi-color-tertiary-active:rgba(var(--semi-grey-7),1);--semi-color-tertiary-light-default:rgba(var(--semi-grey-0),1);--semi-color-tertiary-light-hover:rgba(var(--semi-grey-1),1);--semi-color-tertiary-light-active:rgba(var(--semi-grey-2),1);--semi-color-default:rgba(var(--semi-grey-0),1);--semi-color-default-hover:rgba(var(--semi-grey-1),1);--semi-color-default-active:rgba(var(--semi-grey-2),1);--semi-color-info:rgba(var(--semi-blue-5),1);--semi-color-info-hover:rgba(var(--semi-blue-6),1);--semi-color-info-active:rgba(var(--semi-blue-7),1);--semi-color-info-disabled:rgba(var(--semi-blue-2),1);--semi-color-info-light-default:rgba(var(--semi-blue-0),1);--semi-color-info-light-hover:rgba(var(--semi-blue-1),1);--semi-color-info-light-active:rgba(var(--semi-blue-2),1);--semi-color-success:rgba(var(--semi-green-5),1);--semi-color-success-hover:rgba(var(--semi-green-6),1);--semi-color-success-active:rgba(var(--semi-green-7),1);--semi-color-success-disabled:rgba(var(--semi-green-2),1);--semi-color-success-light-default:rgba(var(--semi-green-0),1);--semi-color-success-light-hover:rgba(var(--semi-green-1),1);--semi-color-success-light-active:rgba(var(--semi-green-2),1);--semi-color-danger:rgba(var(--semi-red-5),1);--semi-color-danger-hover:rgba(var(--semi-red-6),1);--semi-color-danger-active:rgba(var(--semi-red-7),1);--semi-color-danger-light-default:rgba(var(--semi-red-0),1);--semi-color-danger-light-hover:rgba(var(--semi-red-1),1);--semi-color-danger-light-active:rgba(var(--semi-red-2),1);--semi-color-warning:rgba(var(--semi-orange-5),1);--semi-color-warning-hover:rgba(var(--semi-orange-6),1);--semi-color-warning-active:rgba(var(--semi-orange-7),1);--semi-color-warning-light-default:rgba(var(--semi-orange-0),1);--semi-color-warning-light-hover:rgba(var(--semi-orange-1),1);--semi-color-warning-light-active:rgba(var(--semi-orange-2),1);--semi-color-focus-border:rgba(var(--semi-blue-5),1);--semi-color-disabled-text:rgba(var(--semi-grey-9),.35);--semi-color-disabled-border:rgba(var(--semi-grey-1),1);--semi-color-disabled-bg:rgba(var(--semi-grey-1),1);--semi-color-disabled-fill:rgba(var(--semi-grey-8),.04);--semi-color-shadow:rgba(var(--semi-black),.04);--semi-color-link:rgba(var(--semi-blue-5),1);--semi-color-link-hover:rgba(var(--semi-blue-6),1);--semi-color-link-active:rgba(var(--semi-blue-7),1);--semi-color-link-visited:rgba(var(--semi-blue-5),1);--semi-color-border:rgba(var(--semi-grey-9),.08);--semi-color-nav-bg:rgba(var(--semi-white),1);--semi-color-overlay-bg:rgba(22,22,26,.6);--semi-color-fill-0:rgba(var(--semi-grey-8),.05);--semi-color-fill-1:rgba(var(--semi-grey-8),.09);--semi-color-fill-2:rgba(var(--semi-grey-8),.13);--semi-color-bg-0:rgba(var(--semi-white),1);--semi-color-bg-1:rgba(var(--semi-white),1);--semi-color-bg-2:rgba(var(--semi-white),1);--semi-color-bg-3:rgba(var(--semi-white),1);--semi-color-bg-4:rgba(var(--semi-white),1);--semi-color-text-0:rgba(var(--semi-grey-9),1);--semi-color-text-1:rgba(var(--semi-grey-9),.8);--semi-color-text-2:rgba(var(--semi-grey-9),.62);--semi-color-text-3:rgba(var(--semi-grey-9),.35);--semi-shadow-elevated:0 0 1px rgba(0,0,0,.3),0 4px 14px rgba(0,0,0,.1);--semi-border-radius-extra-small:3px;--semi-border-radius-small:3px;--semi-border-radius-medium:6px;--semi-border-radius-large:12px;--semi-border-radius-circle:50%;--semi-border-radius-full:9999px;--semi-color-highlight-bg:rgba(var(--semi-yellow-4),1);--semi-color-highlight:rgba(var(--semi-black),1);--semi-color-data-0:#5769ff;--semi-color-data-1:#8ed4e7;--semi-color-data-2:#f58700;--semi-color-data-3:#dcb7fc;--semi-color-data-4:#4a9cf7;--semi-color-data-5:#f3cc35;--semi-color-data-6:#fe8090;--semi-color-data-7:#8bd7d2;--semi-color-data-8:#83b023;--semi-color-data-9:#e9a5e5;--semi-color-data-10:#30a7ce;--semi-color-data-11:#f9c064;--semi-color-data-12:#b171f9;--semi-color-data-13:#77b6f9;--semi-color-data-14:#c88f02;--semi-color-data-15:#ffaab2;--semi-color-data-16:#33b0ab;--semi-color-data-17:#b6d781;--semi-color-data-18:#d458d4;--semi-color-data-19:#bcc6ff;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif}:host .semi-always-dark,:host([theme-mode=dark]),body .semi-always-dark,body[theme-mode=dark]{-webkit-font-smoothing:antialiased;--semi-color-white:#e4e7f5;--semi-color-black:rgba(var(--semi-black),1);--semi-color-primary:rgba(var(--semi-blue-5),1);--semi-color-primary-hover:rgba(var(--semi-blue-6),1);--semi-color-primary-active:rgba(var(--semi-blue-7),1);--semi-color-primary-disabled:rgba(var(--semi-blue-2),1);--semi-color-primary-light-default:rgba(var(--semi-blue-5),.2);--semi-color-primary-light-hover:rgba(var(--semi-blue-5),.3);--semi-color-primary-light-active:rgba(var(--semi-blue-5),.4);--semi-color-secondary:rgba(var(--semi-light-blue-5),1);--semi-color-secondary-hover:rgba(var(--semi-light-blue-6),1);--semi-color-secondary-active:rgba(var(--semi-light-blue-7),1);--semi-color-secondary-disabled:rgba(var(--semi-light-blue-2),1);--semi-color-secondary-light-default:rgba(var(--semi-light-blue-5),.2);--semi-color-secondary-light-hover:rgba(var(--semi-light-blue-5),.3);--semi-color-secondary-light-active:rgba(var(--semi-light-blue-5),.4);--semi-color-tertiary:rgba(var(--semi-grey-5),1);--semi-color-tertiary-hover:rgba(var(--semi-grey-6),1);--semi-color-tertiary-active:rgba(var(--semi-grey-7),1);--semi-color-tertiary-light-default:rgba(var(--semi-grey-5),.2);--semi-color-tertiary-light-hover:rgba(var(--semi-grey-5),.3);--semi-color-tertiary-light-active:rgba(var(--semi-grey-5),.4);--semi-color-default:rgba(var(--semi-grey-0),1);--semi-color-default-hover:rgba(var(--semi-grey-1),1);--semi-color-default-active:rgba(var(--semi-grey-2),1);--semi-color-info:rgba(var(--semi-blue-5),1);--semi-color-info-hover:rgba(var(--semi-blue-6),1);--semi-color-info-active:rgba(var(--semi-blue-7),1);--semi-color-info-disabled:rgba(var(--semi-blue-2),1);--semi-color-info-light-default:rgba(var(--semi-blue-5),.2);--semi-color-info-light-hover:rgba(var(--semi-blue-5),.3);--semi-color-info-light-active:rgba(var(--semi-blue-5),.4);--semi-color-success:rgba(var(--semi-green-5),1);--semi-color-success-hover:rgba(var(--semi-green-6),1);--semi-color-success-active:rgba(var(--semi-green-7),1);--semi-color-success-disabled:rgba(var(--semi-green-2),1);--semi-color-success-light-default:rgba(var(--semi-green-5),.2);--semi-color-success-light-hover:rgba(var(--semi-green-5),.3);--semi-color-success-light-active:rgba(var(--semi-green-5),.4);--semi-color-danger:rgba(var(--semi-red-5),1);--semi-color-danger-hover:rgba(var(--semi-red-6),1);--semi-color-danger-active:rgba(var(--semi-red-7),1);--semi-color-danger-light-default:rgba(var(--semi-red-5),.2);--semi-color-danger-light-hover:rgba(var(--semi-red-5),.3);--semi-color-danger-light-active:rgba(var(--semi-red-5),.4);--semi-color-warning:rgba(var(--semi-orange-5),1);--semi-color-warning-hover:rgba(var(--semi-orange-6),1);--semi-color-warning-active:rgba(var(--semi-orange-7),1);--semi-color-warning-light-default:rgba(var(--semi-orange-5),.2);--semi-color-warning-light-hover:rgba(var(--semi-orange-5),.3);--semi-color-warning-light-active:rgba(var(--semi-orange-5),.4);--semi-color-focus-border:rgba(var(--semi-blue-5),1);--semi-color-disabled-text:rgba(var(--semi-grey-9),.35);--semi-color-disabled-border:rgba(var(--semi-grey-1),1);--semi-color-disabled-bg:rgba(var(--semi-grey-1),1);--semi-color-disabled-fill:rgba(var(--semi-grey-8),.04);--semi-color-link:rgba(var(--semi-blue-5),1);--semi-color-link-hover:rgba(var(--semi-blue-6),1);--semi-color-link-active:rgba(var(--semi-blue-7),1);--semi-color-link-visited:rgba(var(--semi-blue-5),1);--semi-color-nav-bg:#232429;--semi-shadow-elevated:inset 0 0 0 1px hsla(0,0%,100%,.1),0 4px 14px rgba(0,0,0,.25);--semi-color-overlay-bg:rgba(22,22,26,.6);--semi-color-fill-0:rgba(var(--semi-white),.12);--semi-color-fill-1:rgba(var(--semi-white),.16);--semi-color-fill-2:rgba(var(--semi-white),.20);--semi-color-border:rgba(var(--semi-white),.08);--semi-color-shadow:rgba(var(--semi-black),.04);--semi-color-bg-0:#16161a;--semi-color-bg-1:#232429;--semi-color-bg-2:#35363c;--semi-color-bg-3:#43444a;--semi-color-bg-4:#4f5159;--semi-color-text-0:rgba(var(--semi-grey-9),1);--semi-color-text-1:rgba(var(--semi-grey-9),.8);--semi-color-text-2:rgba(var(--semi-grey-9),.6);--semi-color-text-3:rgba(var(--semi-grey-9),.35);--semi-border-radius-extra-small:3px;--semi-border-radius-small:3px;--semi-border-radius-medium:6px;--semi-border-radius-large:12px;--semi-border-radius-circle:50%;--semi-border-radius-full:9999px;--semi-color-highlight-bg:rgba(var(--semi-yellow-2),1);--semi-color-highlight:rgba(var(--semi-white),1);--semi-color-data-0:#5e6dc2;--semi-color-data-1:#086878;--semi-color-data-2:#faad3f;--semi-color-data-3:#4c2b9c;--semi-color-data-4:#107df8;--semi-color-data-5:#f8ca10;--semi-color-data-6:#c31e57;--semi-color-data-7:#057773;--semi-color-data-8:#9acf0d;--semi-color-data-9:#751d8a;--semi-color-data-10:#10a2b4;--semi-color-data-11:#d06e0b;--semi-color-data-12:#7142c5;--semi-color-data-13:#0764d4;--semi-color-data-14:#fbe86e;--semi-color-data-15:#a01349;--semi-color-data-16:#0bb3a7;--semi-color-data-17:#628a06;--semi-color-data-18:#a230b3;--semi-color-data-19:#28338a;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif}.semi-light-scrollbar ::-webkit-scrollbar,.semi-light-scrollbar::-webkit-scrollbar{height:8px;width:8px}.semi-light-scrollbar ::-webkit-scrollbar-track,.semi-light-scrollbar::-webkit-scrollbar-track{background:transparent}.semi-light-scrollbar ::-webkit-scrollbar-corner,.semi-light-scrollbar::-webkit-scrollbar-corner{background-color:transparent}.semi-light-scrollbar ::-webkit-scrollbar-thumb,.semi-light-scrollbar::-webkit-scrollbar-thumb{background:transparent;border-radius:6px;-webkit-transition:all 1s;transition:all 1s}.semi-light-scrollbar :hover::-webkit-scrollbar-thumb,.semi-light-scrollbar:hover::-webkit-scrollbar-thumb{background:var(--semi-color-fill-2)}.semi-light-scrollbar ::-webkit-scrollbar-thumb:hover,.semi-light-scrollbar::-webkit-scrollbar-thumb:hover{background:var(--semi-color-fill-1)}.semi-backtop{bottom:50px;box-sizing:border-box;cursor:pointer;overflow:hidden;position:fixed;right:100px;text-align:center;z-index:10}.semi-portal-rtl .semi-backtop,.semi-rtl .semi-backtop{direction:rtl;left:100px;right:auto}.semi-button.semi-button-with-icon{align-items:center;display:inline-flex}.semi-button.semi-button-with-icon .semi-button-content{align-items:center;display:flex;justify-content:center}.semi-button.semi-button-loading{cursor:not-allowed;pointer-events:none}.semi-button.semi-button-loading .semi-button-content>svg{-webkit-animation:semi-animation-rotate .6s linear infinite;animation:semi-animation-rotate .6s linear infinite;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards;height:16px;width:16px}.semi-button.semi-button-with-icon-only{align-items:center;justify-content:center;padding:8px}.semi-button.semi-button-with-icon-only.semi-button-size-small{padding:4px}.semi-button.semi-button-with-icon-only.semi-button-size-large{padding:12px}.semi-button-content-left{margin-right:8px}.semi-button-content-right{margin-left:8px}.semi-button-split{display:inline-block}.semi-button-split .semi-button{border-radius:0;margin-right:1px}.semi-button-split .semi-button-first{border-bottom-left-radius:var(--semi-border-radius-small);border-top-left-radius:var(--semi-border-radius-small)}.semi-button-split .semi-button-last{border-bottom-right-radius:var(--semi-border-radius-small);border-top-right-radius:var(--semi-border-radius-small);margin-right:0}.semi-button-split:hover .semi-button-borderless:active{background-color:var(--semi-color-fill-1)}.semi-button{align-items:center;border:0 solid transparent;border-radius:var(--semi-border-radius-small);box-shadow:none;cursor:pointer;display:inline-flex;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;font-weight:600;height:32px;justify-content:center;line-height:20px;outline:none;padding:6px 12px;-webkit-user-select:none;user-select:none;vertical-align:middle;white-space:nowrap}.semi-button.semi-button-danger:focus-visible,.semi-button.semi-button-primary:focus-visible,.semi-button.semi-button-secondary:focus-visible,.semi-button.semi-button-tertiary:focus-visible,.semi-button.semi-button-warning:focus-visible{outline:2px solid var(--semi-color-primary-light-active)}.semi-button-danger{background-color:var(--semi-color-danger);color:rgba(var(--semi-white),1);-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-button-danger-disabled{background-color:var(--semi-color-disabled-bg)}.semi-button-danger-disabled.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-border)}.semi-button-danger-disabled.semi-button-light{background-color:var(--semi-color-fill-0)}.semi-button-danger:hover{background-color:var(--semi-color-danger-hover)}.semi-button-danger:active{background-color:var(--semi-color-danger-active)}.semi-button-danger.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-danger)}.semi-button-danger.semi-button-borderless,.semi-button-danger.semi-button-light,.semi-button-danger.semi-button-outline{color:var(--semi-color-danger)}.semi-button-danger:not(.semi-button-borderless):not(.semi-button-light):focus-visible{outline:2px solid var(--semi-color-danger-light-active)}.semi-button-warning{background-color:var(--semi-color-warning);color:rgba(var(--semi-white),1);-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-button-warning-disabled{background-color:var(--semi-color-disabled-bg)}.semi-button-warning-disabled.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-border)}.semi-button-warning-disabled.semi-button-light{background-color:var(--semi-color-fill-0)}.semi-button-warning:hover{background-color:var(--semi-color-warning-hover)}.semi-button-warning:active{background-color:var(--semi-color-warning-active)}.semi-button-warning.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-warning)}.semi-button-warning.semi-button-borderless,.semi-button-warning.semi-button-light,.semi-button-warning.semi-button-outline{color:var(--semi-color-warning)}.semi-button-warning:not(.semi-button-borderless):not(.semi-button-light):focus-visible{outline:2px solid var(--semi-color-warning-light-active)}.semi-button-tertiary{background-color:var(--semi-color-tertiary);color:rgba(var(--semi-white),1);-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-button-tertiary-disabled{background-color:var(--semi-color-disabled-bg)}.semi-button-tertiary-disabled.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-border)}.semi-button-tertiary-disabled.semi-button-light{background-color:var(--semi-color-fill-0)}.semi-button-tertiary:hover{background-color:var(--semi-color-tertiary-hover)}.semi-button-tertiary:active{background-color:var(--semi-color-tertiary-active)}.semi-button-tertiary.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-border)}.semi-button-tertiary.semi-button-borderless,.semi-button-tertiary.semi-button-light,.semi-button-tertiary.semi-button-outline{color:var(--semi-color-text-1)}.semi-button-primary{background-color:var(--semi-color-primary);color:rgba(var(--semi-white),1);-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-button-primary-disabled{background-color:var(--semi-color-disabled-bg)}.semi-button-primary-disabled.semi-button-light{background:var(--semi-color-fill-0)}.semi-button-primary-disabled.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-border)}.semi-button-primary:not(.semi-button-borderless):not(.semi-button-light):not(.semi-button-outline):hover{background-color:var(--semi-color-primary-hover)}.semi-button-primary.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-border)}.semi-button-primary:not(.semi-button-borderless):not(.semi-button-light):not(.semi-button-outline):active{background-color:var(--semi-color-primary-active)}.semi-button-primary.semi-button-borderless,.semi-button-primary.semi-button-light,.semi-button-primary.semi-button-outline{color:var(--semi-color-primary)}.semi-button-secondary{background-color:var(--semi-color-secondary);color:rgba(var(--semi-white),1);outline-color:var(--semi-color-secondary);-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-button-secondary-disabled{background-color:var(--semi-color-disabled-bg)}.semi-button-secondary-disabled.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-border)}.semi-button-secondary-disabled.semi-button-light{background-color:var(--semi-color-fill-0)}.semi-button-secondary.semi-button-outline{background-color:initial;border:1px solid var(--semi-color-border)}.semi-button-secondary:hover{background-color:var(--semi-color-secondary-hover)}.semi-button-secondary:active{background-color:var(--semi-color-secondary-active)}.semi-button-secondary.semi-button-borderless,.semi-button-secondary.semi-button-light,.semi-button-secondary.semi-button-outline{color:var(--semi-color-secondary)}.semi-button-disabled{cursor:not-allowed}.semi-button-disabled,.semi-button-disabled.semi-button-borderless,.semi-button-disabled.semi-button-light,.semi-button-disabled:not(.semi-button-borderless):not(.semi-button-light):hover{color:var(--semi-color-disabled-text)}.semi-button-borderless{background-color:initial;border:0 solid transparent;-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-button-borderless:not(.semi-button-disabled):hover{background-color:var(--semi-color-fill-0);border:0 solid transparent}.semi-button-borderless:not(.semi-button-disabled):active{background-color:var(--semi-color-fill-1);border:0 solid transparent}.semi-button-outline{background-color:initial}.semi-button-outline:not(.semi-button-disabled):hover{background-color:var(--semi-color-fill-0)}.semi-button-outline:not(.semi-button-disabled):active{background-color:var(--semi-color-fill-1)}.semi-button-light{background-color:var(--semi-color-fill-0);border:0 solid transparent;-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-button-light:not(.semi-button-disabled):hover{background-color:var(--semi-color-fill-1);border:0 solid transparent}.semi-button-light:not(.semi-button-disabled):active{background-color:var(--semi-color-fill-2);border:0 solid transparent}.semi-button-size-small{height:24px;padding:2px 12px}.semi-button-size-large{height:40px;padding:10px 16px}.semi-button-block{width:100%}.semi-button-group{display:flex;flex-wrap:wrap}.semi-button-group>.semi-button{border-radius:0;margin:0;padding-left:0;padding-right:0}.semi-button-group>.semi-button .semi-button-content{padding-left:12px;padding-right:12px}.semi-button-group>.semi-button-size-large .semi-button-content{padding-left:16px;padding-right:16px}.semi-button-group>.semi-button-size-small .semi-button-content{padding-left:12px;padding-right:12px}.semi-button-group>.semi-button.semi-button-with-icon-only{padding-left:0;padding-right:0}.semi-button-group>.semi-button.semi-button-with-icon-only .semi-button-content{padding-left:8px;padding-right:8px}.semi-button-group>.semi-button.semi-button-with-icon-only.semi-button-size-small .semi-button-content{padding-left:4px;padding-right:4px}.semi-button-group>.semi-button.semi-button-with-icon-only.semi-button-size-large .semi-button-content{padding-left:12px;padding-right:12px}.semi-button-group>.semi-button:first-child{border-bottom-left-radius:var(--semi-border-radius-small);border-top-left-radius:var(--semi-border-radius-small)}.semi-button-group>.semi-button:last-child{border-bottom-right-radius:var(--semi-border-radius-small);border-top-right-radius:var(--semi-border-radius-small)}.semi-button-group>.semi-button-outline:not(:last-child){border-right-color:transparent;margin-right:-1px}.semi-button-group-line{align-items:center;background-color:var(--semi-color-border);display:inline-flex}.semi-button-group-line-primary{background-color:var(--semi-color-primary)}.semi-button-group-line-secondary{background-color:var(--semi-color-secondary)}.semi-button-group-line-tertiary{background-color:var(--semi-color-tertiary)}.semi-button-group-line-warning{background-color:var(--semi-color-warning)}.semi-button-group-line-danger{background-color:var(--semi-color-danger)}.semi-button-group-line-disabled{background-color:var(--semi-color-disabled-bg)}.semi-button-group-line-light{background-color:var(--semi-color-fill-0)}.semi-button-group-line-borderless{background-color:initial}.semi-button-group-line:before{background-color:var(--semi-color-border);content:"";display:block;height:20px;width:1px}.semi-portal-rtl .semi-button,.semi-rtl .semi-button{direction:rtl;padding-left:12px;padding-right:12px}.semi-portal-rtl .semi-button-size-small,.semi-rtl .semi-button-size-small{padding-left:12px;padding-right:12px}.semi-portal-rtl .semi-button-size-large,.semi-rtl .semi-button-size-large{padding-left:16px;padding-right:16px}.semi-portal-rtl .semi-button-group,.semi-rtl .semi-button-group{direction:rtl}.semi-portal-rtl .semi-button-group>.semi-button,.semi-rtl .semi-button-group>.semi-button{padding-left:0;padding-right:0}.semi-portal-rtl .semi-button-group>.semi-button .semi-button-content,.semi-rtl .semi-button-group>.semi-button .semi-button-content{padding-left:12px;padding-right:12px}.semi-portal-rtl .semi-button-group>.semi-button-size-large .semi-button-content,.semi-rtl .semi-button-group>.semi-button-size-large .semi-button-content{padding-left:16px;padding-right:16px}.semi-portal-rtl .semi-button-group>.semi-button-size-small .semi-button-content,.semi-rtl .semi-button-group>.semi-button-size-small .semi-button-content{padding-left:12px;padding-right:12px}.semi-portal-rtl .semi-button-group>.semi-button.semi-button-with-icon-only,.semi-rtl .semi-button-group>.semi-button.semi-button-with-icon-only{padding-left:0;padding-right:0}.semi-portal-rtl .semi-button-group>.semi-button.semi-button-with-icon-only .semi-button-content,.semi-rtl .semi-button-group>.semi-button.semi-button-with-icon-only .semi-button-content{padding-left:8px;padding-right:8px}.semi-portal-rtl .semi-button-group>.semi-button.semi-button-with-icon-only.semi-button-size-small .semi-button-content,.semi-rtl .semi-button-group>.semi-button.semi-button-with-icon-only.semi-button-size-small .semi-button-content{padding-left:4px;padding-right:4px}.semi-portal-rtl .semi-button-group>.semi-button.semi-button-with-icon-only.semi-button-size-large .semi-button-content,.semi-rtl .semi-button-group>.semi-button.semi-button-with-icon-only.semi-button-size-large .semi-button-content{padding-left:12px;padding-right:12px}.semi-portal-rtl .semi-button-group>.semi-button:first-child,.semi-rtl .semi-button-group>.semi-button:first-child{border-bottom-left-radius:0;border-bottom-right-radius:var(--semi-border-radius-small);border-top-left-radius:0;border-top-right-radius:var(--semi-border-radius-small)}.semi-portal-rtl .semi-button-group>.semi-button:not(:last-child) .semi-button-content,.semi-rtl .semi-button-group>.semi-button:not(:last-child) .semi-button-content{border-left:1px solid var(--semi-color-border);border-right:0}.semi-portal-rtl .semi-button-group>.semi-button:last-child,.semi-rtl .semi-button-group>.semi-button:last-child{border-bottom-left-radius:var(--semi-border-radius-small);border-bottom-right-radius:0;border-top-left-radius:var(--semi-border-radius-small);border-top-right-radius:0}.semi-portal-rtl .semi-button.semi-button-with-icon-only,.semi-rtl .semi-button.semi-button-with-icon-only{padding-left:8px;padding-right:8px}.semi-portal-rtl .semi-button.semi-button-with-icon-only.semi-button-size-small,.semi-rtl .semi-button.semi-button-with-icon-only.semi-button-size-small{padding-left:4px;padding-right:4px}.semi-portal-rtl .semi-button.semi-button-with-icon-only.semi-button-size-large,.semi-rtl .semi-button.semi-button-with-icon-only.semi-button-size-large{padding-left:12px;padding-right:12px}.semi-portal-rtl .semi-button-content-left,.semi-rtl .semi-button-content-left{margin-left:8px;margin-right:0}.semi-portal-rtl .semi-button-content-right,.semi-rtl .semi-button-content-right{margin-left:0;margin-right:8px}.semi-icon{fill:currentColor;display:inline-block;font-style:normal;line-height:0;text-align:center;text-rendering:optimizeLegibility;text-transform:none}.semi-icon-extra-small{font-size:8px}.semi-icon-small{font-size:12px}.semi-icon-default{font-size:16px}.semi-icon-large{font-size:20px}.semi-icon-extra-large{font-size:24px}.semi-icon-spinning{-webkit-animation:semi-icon-animation-rotate .6s linear infinite;animation:semi-icon-animation-rotate .6s linear infinite;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}@-webkit-keyframes semi-icon-animation-rotate{0%{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes semi-icon-animation-rotate{0%{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.semi-descriptions{line-height:20px}.semi-descriptions table,.semi-descriptions td,.semi-descriptions th,.semi-descriptions tr{border:0;margin:0;padding:0}.semi-descriptions th{padding-right:24px}.semi-descriptions .semi-descriptions-item{margin:0;padding-bottom:12px;text-align:left;vertical-align:top}.semi-descriptions-key{color:var(--semi-color-text-2);min-height:14px;white-space:nowrap}.semi-descriptions-key,.semi-descriptions-value{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:20px}.semi-descriptions-value{color:var(--semi-color-text-0)}.semi-descriptions-center .semi-descriptions-item-th{text-align:right}.semi-descriptions-center .semi-descriptions-item-td,.semi-descriptions-justify .semi-descriptions-item-th,.semi-descriptions-left .semi-descriptions-item-td,.semi-descriptions-left .semi-descriptions-item-th{text-align:left}.semi-descriptions-justify .semi-descriptions-item-td{text-align:right}.semi-descriptions-plain .semi-descriptions-key,.semi-descriptions-plain .semi-descriptions-value{display:inline-block}.semi-descriptions-plain .semi-descriptions-value{padding-left:8px}.semi-descriptions-plain .semi-descriptions-value .semi-tag{vertical-align:middle}.semi-descriptions-double tbody{display:flex;flex-wrap:wrap}.semi-descriptions-double tr{display:inline-flex;flex-direction:column}.semi-descriptions-double .semi-descriptions-item{flex:1 1;padding:0}.semi-descriptions-double .semi-descriptions-value{font-weight:600}.semi-descriptions-double-small .semi-descriptions-item{padding-right:48px}.semi-descriptions-double-small .semi-descriptions-key{font-size:12px;line-height:16px;padding-bottom:0}.semi-descriptions-double-small .semi-descriptions-key,.semi-descriptions-double-small .semi-descriptions-value{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif}.semi-descriptions-double-small .semi-descriptions-value{font-size:16px;line-height:22px}.semi-descriptions-double-medium .semi-descriptions-item{padding-right:60px}.semi-descriptions-double-medium .semi-descriptions-key{font-size:14px;padding-bottom:4px}.semi-descriptions-double-medium .semi-descriptions-value{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:20px;line-height:28px}.semi-descriptions-double-large .semi-descriptions-item{padding-right:80px}.semi-descriptions-double-large .semi-descriptions-key{font-size:14px;padding-bottom:4px}.semi-descriptions-double-large .semi-descriptions-value{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:28px;line-height:40px}.semi-descriptions-horizontal table{table-layout:fixed}.semi-descriptions-horizontal table,.semi-descriptions-horizontal tbody{width:100%}.semi-descriptions-horizontal .semi-descriptions-item{flex:0 1}.semi-portal-rtl .semi-descriptions,.semi-rtl .semi-descriptions{direction:rtl}.semi-portal-rtl .semi-descriptions th,.semi-rtl .semi-descriptions th{direction:rtl;padding-left:24px;padding-right:0}.semi-portal-rtl .semi-descriptions .semi-descriptions-item,.semi-rtl .semi-descriptions .semi-descriptions-item{text-align:right}.semi-portal-rtl .semi-descriptions-center .semi-descriptions-item-th,.semi-rtl .semi-descriptions-center .semi-descriptions-item-th{text-align:left}.semi-portal-rtl .semi-descriptions-center .semi-descriptions-item-td,.semi-rtl .semi-descriptions-center .semi-descriptions-item-td{text-align:right}.semi-portal-rtl .semi-descriptions-left .semi-descriptions-item-td,.semi-portal-rtl .semi-descriptions-left .semi-descriptions-item-th,.semi-rtl .semi-descriptions-left .semi-descriptions-item-td,.semi-rtl .semi-descriptions-left .semi-descriptions-item-th{text-align:left}.semi-portal-rtl .semi-descriptions-justify .semi-descriptions-item-th,.semi-rtl .semi-descriptions-justify .semi-descriptions-item-th{text-align:right}.semi-portal-rtl .semi-descriptions-justify .semi-descriptions-item-td,.semi-rtl .semi-descriptions-justify .semi-descriptions-item-td{text-align:left}.semi-portal-rtl .semi-descriptions-plain .semi-descriptions-key,.semi-portal-rtl .semi-descriptions-plain .semi-descriptions-value,.semi-rtl .semi-descriptions-plain .semi-descriptions-key,.semi-rtl .semi-descriptions-plain .semi-descriptions-value{display:inline-block}.semi-portal-rtl .semi-descriptions-plain .semi-descriptions-value,.semi-rtl .semi-descriptions-plain .semi-descriptions-value{padding-left:0;padding-right:8px}.semi-portal-rtl .semi-descriptions-plain .semi-descriptions-value .semi-tag,.semi-rtl .semi-descriptions-plain .semi-descriptions-value .semi-tag{vertical-align:middle}.semi-portal-rtl .semi-descriptions-double,.semi-rtl .semi-descriptions-double{direction:rtl}.semi-portal-rtl .semi-descriptions-double .semi-descriptions-item,.semi-rtl .semi-descriptions-double .semi-descriptions-item{text-align:right}.semi-portal-rtl .semi-descriptions-double-small .semi-descriptions-item,.semi-rtl .semi-descriptions-double-small .semi-descriptions-item{padding-left:48px;padding-right:0}.semi-portal-rtl .semi-descriptions-double-medium .semi-descriptions-item,.semi-rtl .semi-descriptions-double-medium .semi-descriptions-item{padding-left:60px;padding-right:0}.semi-portal-rtl .semi-descriptions-double-large .semi-descriptions-item,.semi-rtl .semi-descriptions-double-large .semi-descriptions-item{padding-left:80px;padding-right:0}.semi-divider{border-bottom:1px solid var(--semi-color-border);box-sizing:border-box;color:var(--semi-color-text-0);margin:1px 0}.semi-divider-dashed{border-bottom-style:dashed}.semi-divider-horizontal{display:flex;width:100%}.semi-divider-vertical{border-bottom:0;border-left:1px solid var(--semi-color-border);display:inline-block;height:20px;margin:0 1px;vertical-align:middle}.semi-divider-with-text{align-items:center;border-bottom:0;display:flex;white-space:nowrap}.semi-divider-with-text .semi-divider_inner-text{display:inline-block;font-weight:600;padding:0 8px}.semi-divider-with-text:after,.semi-divider-with-text:before{border-bottom:1px solid var(--semi-color-border);content:"";width:50%}.semi-divider-with-text-left:before{width:40px}.semi-divider-with-text-left:after,.semi-divider-with-text-right:before{flex:1 1}.semi-divider-with-text-right:after{width:40px}.semi-divider-dashed:after,.semi-divider-dashed:before{border-bottom:1px dashed var(--semi-color-border)}.semi-divider-vertical.semi-divider-dashed{border-left:1px dashed var(--semi-color-border)}.semi-modal{color:var(--semi-color-text-0);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;margin:80px auto;position:relative}.semi-modal-mask{background-color:var(--semi-color-overlay-bg);bottom:0;height:100%;left:0;position:fixed;right:0;top:0;z-index:1000}.semi-modal-mask-hidden{display:none}.semi-modal-icon-wrapper{display:inline-flex;margin-right:12px;width:24px}.semi-modal-wrap{-webkit-overflow-scrolling:touch;bottom:0;left:0;outline:0;overflow:auto;position:fixed;right:0;top:0;z-index:1000}.semi-modal-wrap-center{align-items:center;display:flex}.semi-modal-title{align-items:flex-start;display:inline-flex;justify-content:flex-start;margin:0;width:100%}.semi-modal-content{background-clip:padding-box;background-color:var(--semi-color-bg-2);border:1px solid var(--semi-color-border);border-radius:var(--semi-border-radius-large);box-shadow:var(--semi-shadow-elevated);box-sizing:border-box;display:flex;flex-direction:column;height:100%;overflow:hidden;padding:0 24px;position:relative;width:100%}.semi-modal-footerfill{display:flex}.semi-modal-content-fullScreen{border:none;border-radius:0;top:0}.semi-modal-header{background-color:initial;border-bottom:0 solid transparent;color:var(--semi-color-text-0);font-size:14px;font-weight:600;padding:0}.semi-modal-body-wrapper,.semi-modal-header{align-items:flex-start;display:flex;margin:24px 0}.semi-modal-body{flex:1 1 auto;margin:0;padding:0}.semi-modal-withIcon{margin-left:36px}.semi-modal-footer{background-color:initial;border-radius:0 0 5px 5px;border-top:0 solid transparent;margin:24px 0;padding:0;text-align:right}.semi-modal-footer .semi-button{margin-left:12px;margin-right:0}.semi-modal-confirm .semi-modal-header{margin-bottom:8px}.semi-modal-confirm-icon-wrapper{display:inline-flex;margin-right:12px;width:24px}.semi-modal-confirm-icon{color:var(--semi-color-primary);display:inline-flex}.semi-modal-info-icon{color:var(--semi-color-info)}.semi-modal-success-icon{color:var(--semi-color-success)}.semi-modal-error-icon{color:var(--semi-color-danger)}.semi-modal-warning-icon{color:var(--semi-color-warning)}.semi-modal-small{width:448px}.semi-modal-medium{width:684px}.semi-modal-large{width:920px}.semi-modal-full-width{width:calc(100vw - 64px)}.semi-modal-centered{margin:0 auto}.semi-modal-popup .semi-modal-mask,.semi-modal-popup .semi-modal-wrap{overflow:hidden;position:absolute}.semi-modal-fixed .semi-modal-mask,.semi-modal-fixed .semi-modal-wrap{overflow:hidden;position:fixed}.semi-modal-displayNone{display:none}.semi-modal-content-animate-show{-webkit-animation:semi-modal-content-keyframe-show .12s cubic-bezier(.215,.61,.355,1) 0ms forwards;animation:semi-modal-content-keyframe-show .12s cubic-bezier(.215,.61,.355,1) 0ms forwards;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.semi-modal-content-animate-hide{-webkit-animation:semi-modal-content-keyframe-hide .12s cubic-bezier(.215,.61,.355,1) 0ms forwards;animation:semi-modal-content-keyframe-hide .12s cubic-bezier(.215,.61,.355,1) 0ms forwards;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.semi-modal-mask-animate-show{-webkit-animation:semi-modal-mask-keyframe-show 90ms cubic-bezier(.215,.61,.355,1) 0ms forwards;animation:semi-modal-mask-keyframe-show 90ms cubic-bezier(.215,.61,.355,1) 0ms forwards;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.semi-modal-mask-animate-hide{-webkit-animation:semi-modal-mask-keyframe-hide 90ms cubic-bezier(.215,.61,.355,1) 0ms forwards;animation:semi-modal-mask-keyframe-hide 90ms cubic-bezier(.215,.61,.355,1) 0ms forwards;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}@-webkit-keyframes semi-modal-content-keyframe-show{0%{opacity:0;-webkit-transform:scale(.7);transform:scale(.7)}to{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@keyframes semi-modal-content-keyframe-show{0%{opacity:0;-webkit-transform:scale(.7);transform:scale(.7)}to{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@-webkit-keyframes semi-modal-content-keyframe-hide{0%{opacity:1;-webkit-transform:scale(1);transform:scale(1)}to{opacity:0;-webkit-transform:scale(.7);transform:scale(.7)}}@keyframes semi-modal-content-keyframe-hide{0%{opacity:1;-webkit-transform:scale(1);transform:scale(1)}to{opacity:0;-webkit-transform:scale(.7);transform:scale(.7)}}@-webkit-keyframes semi-modal-mask-keyframe-show{0%{opacity:0}to{opacity:1}}@keyframes semi-modal-mask-keyframe-show{0%{opacity:0}to{opacity:1}}@-webkit-keyframes semi-modal-mask-keyframe-hide{0%{opacity:1}to{opacity:0}}@keyframes semi-modal-mask-keyframe-hide{0%{opacity:1}to{opacity:0}}.semi-modal-rtl{direction:rtl}.semi-modal-confirm-rtl .semi-modal-icon-wrapper,.semi-modal-rtl .semi-modal-icon-wrapper{margin-left:12px;margin-right:0}.semi-modal-confirm-rtl .semi-modal-withIcon,.semi-modal-rtl .semi-modal-withIcon{margin-left:0;margin-right:36px}.semi-modal-confirm-rtl .semi-modal-footer,.semi-modal-rtl .semi-modal-footer{text-align:left}.semi-modal-confirm-rtl .semi-modal-footer .semi-button,.semi-modal-rtl .semi-modal-footer .semi-button{margin-left:0;margin-right:12px}.semi-modal-confirm-rtl{direction:rtl}.semi-modal-confirm .semi-modal-confirm-rtl .semi-button{margin-left:0;margin-right:12px}.semi-portal{left:0;position:absolute;top:0;width:100%;z-index:1}.semi-portal-inner{background-color:initial;min-width:-webkit-max-content;min-width:max-content;position:absolute}.semi-typography{color:var(--semi-color-text-0);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px}.semi-typography.semi-typography-secondary{color:var(--semi-color-text-1)}.semi-typography.semi-typography-tertiary{color:var(--semi-color-text-2)}.semi-typography.semi-typography-quaternary{color:var(--semi-color-text-3)}.semi-typography.semi-typography-warning{color:var(--semi-color-warning)}.semi-typography.semi-typography-success{color:var(--semi-color-success)}.semi-typography.semi-typography-danger{color:var(--semi-color-danger)}.semi-typography.semi-typography-link{color:var(--semi-color-link);font-weight:600}.semi-typography.semi-typography-disabled{color:var(--semi-color-disabled-text);cursor:not-allowed;-webkit-user-select:none;user-select:none}.semi-typography.semi-typography-disabled.semi-typography-link{color:var(--semi-color-link)}.semi-typography-icon{color:inherit;margin-right:4px;vertical-align:middle}.semi-typography-small{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:12px;font-weight:400;line-height:16px}.semi-typography-small.semi-typography-paragraph{font-weight:400}.semi-typography code{background-color:var(--semi-color-fill-1);border:1px solid var(--semi-color-border);border-radius:2px;color:var(--semi-color-text-2);padding:2px 4px}.semi-typography mark{background-color:var(--semi-color-primary-light-default)}.semi-typography u{-webkit-text-decoration-skip:ink;text-decoration:underline;text-decoration-skip-ink:auto}.semi-typography del{text-decoration:line-through}.semi-typography strong{font-weight:600}.semi-typography a{color:var(--semi-color-link);cursor:pointer;display:inline;text-decoration:none}.semi-typography a:visited{color:var(--semi-color-link-visited)}.semi-typography a:hover{color:var(--semi-color-link-hover)}.semi-typography a:active{color:var(--semi-color-link-active)}.semi-typography a .semi-typography-link-underline:hover{border-bottom:1px solid var(--semi-color-link-hover);margin-bottom:-1px}.semi-typography a .semi-typography-link-underline:active{border-bottom:1px solid var(--semi-color-link-active);margin-bottom:-1px}.semi-typography-ellipsis-single-line{overflow:hidden}.semi-typography-ellipsis-multiple-line{-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}.semi-typography-ellipsis-multiple-line.semi-typography-ellipsis-multiple-line-text{display:-webkit-inline-box}.semi-typography-ellipsis-overflow-ellipsis{display:block;text-overflow:ellipsis;white-space:nowrap}.semi-typography-ellipsis-overflow-ellipsis.semi-typography-ellipsis-overflow-ellipsis-text{display:inline-block;max-width:100%;vertical-align:top}.semi-typography-ellipsis-expand{display:inline;margin-left:8px}.semi-typography-action-copy{display:inline-flex;margin-left:4px;padding:0;vertical-align:middle}.semi-typography a.semi-typography-action-copy-icon{display:inline-flex}.semi-typography-action-copied{color:var(--semi-color-text-2);display:inline-flex;margin-left:4px;padding:0}.semi-typography-action-copied .semi-icon{color:var(--semi-color-success);vertical-align:middle}.semi-typography-paragraph{margin:0}.semi-typography-h1.semi-typography,h1.semi-typography{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:32px;font-weight:600;line-height:44px;margin:0}.semi-typography-h1.semi-typography.semi-typography-h1-weight-light,h1.semi-typography.semi-typography-h1-weight-light{font-weight:200}.semi-typography-h1.semi-typography.semi-typography-h1-weight-regular,h1.semi-typography.semi-typography-h1-weight-regular{font-weight:400}.semi-typography-h1.semi-typography.semi-typography-h1-weight-medium,h1.semi-typography.semi-typography-h1-weight-medium{font-weight:500}.semi-typography-h1.semi-typography.semi-typography-h1-weight-semibold,h1.semi-typography.semi-typography-h1-weight-semibold{font-weight:600}.semi-typography-h1.semi-typography.semi-typography-h1-weight-bold,h1.semi-typography.semi-typography-h1-weight-bold{font-weight:700}.semi-typography-h2.semi-typography,h2.semi-typography{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:28px;font-weight:600;line-height:40px;margin:0}.semi-typography-h2.semi-typography.semi-typography-h2-weight-light,h2.semi-typography.semi-typography-h2-weight-light{font-weight:200}.semi-typography-h2.semi-typography.semi-typography-h2-weight-regular,h2.semi-typography.semi-typography-h2-weight-regular{font-weight:400}.semi-typography-h2.semi-typography.semi-typography-h2-weight-medium,h2.semi-typography.semi-typography-h2-weight-medium{font-weight:500}.semi-typography-h2.semi-typography.semi-typography-h2-weight-semibold,h2.semi-typography.semi-typography-h2-weight-semibold{font-weight:600}.semi-typography-h2.semi-typography.semi-typography-h2-weight-bold,h2.semi-typography.semi-typography-h2-weight-bold{font-weight:700}.semi-typography-h3.semi-typography,h3.semi-typography{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:24px;font-weight:600;line-height:32px;margin:0}.semi-typography-h3.semi-typography.semi-typography-h3-weight-light,h3.semi-typography.semi-typography-h3-weight-light{font-weight:200}.semi-typography-h3.semi-typography.semi-typography-h3-weight-regular,h3.semi-typography.semi-typography-h3-weight-regular{font-weight:400}.semi-typography-h3.semi-typography.semi-typography-h3-weight-medium,h3.semi-typography.semi-typography-h3-weight-medium{font-weight:500}.semi-typography-h3.semi-typography.semi-typography-h3-weight-semibold,h3.semi-typography.semi-typography-h3-weight-semibold{font-weight:600}.semi-typography-h3.semi-typography.semi-typography-h3-weight-bold,h3.semi-typography.semi-typography-h3-weight-bold{font-weight:700}.semi-typography-h4.semi-typography,h4.semi-typography{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:20px;font-weight:600;line-height:28px;margin:0}.semi-typography-h4.semi-typography.semi-typography-h4-weight-light,h4.semi-typography.semi-typography-h4-weight-light{font-weight:200}.semi-typography-h4.semi-typography.semi-typography-h4-weight-regular,h4.semi-typography.semi-typography-h4-weight-regular{font-weight:400}.semi-typography-h4.semi-typography.semi-typography-h4-weight-medium,h4.semi-typography.semi-typography-h4-weight-medium{font-weight:500}.semi-typography-h4.semi-typography.semi-typography-h4-weight-semibold,h4.semi-typography.semi-typography-h4-weight-semibold{font-weight:600}.semi-typography-h4.semi-typography.semi-typography-h4-weight-bold,h4.semi-typography.semi-typography-h4-weight-bold{font-weight:700}.semi-typography-h5.semi-typography,h5.semi-typography{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:18px;font-weight:600;line-height:24px;margin:0}.semi-typography-h5.semi-typography.semi-typography-h5-weight-light,h5.semi-typography.semi-typography-h5-weight-light{font-weight:200}.semi-typography-h5.semi-typography.semi-typography-h5-weight-regular,h5.semi-typography.semi-typography-h5-weight-regular{font-weight:400}.semi-typography-h5.semi-typography.semi-typography-h5-weight-medium,h5.semi-typography.semi-typography-h5-weight-medium{font-weight:500}.semi-typography-h5.semi-typography.semi-typography-h5-weight-semibold,h5.semi-typography.semi-typography-h5-weight-semibold{font-weight:600}.semi-typography-h5.semi-typography.semi-typography-h5-weight-bold,h5.semi-typography.semi-typography-h5-weight-bold{font-weight:700}.semi-typography-h6.semi-typography,h6.semi-typography{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:16px;font-weight:600;line-height:22px;margin:0}.semi-typography-h6.semi-typography.semi-typography-h6-weight-light,h6.semi-typography.semi-typography-h6-weight-light{font-weight:200}.semi-typography-h6.semi-typography.semi-typography-h6-weight-regular,h6.semi-typography.semi-typography-h6-weight-regular{font-weight:400}.semi-typography-h6.semi-typography.semi-typography-h6-weight-medium,h6.semi-typography.semi-typography-h6-weight-medium{font-weight:500}.semi-typography-h6.semi-typography.semi-typography-h6-weight-semibold,h6.semi-typography.semi-typography-h6-weight-semibold{font-weight:600}.semi-typography-h6.semi-typography.semi-typography-h6-weight-bold,h6.semi-typography.semi-typography-h6-weight-bold{font-weight:700}.semi-typography-paragraph.semi-typography-extended,p.semi-typography-extended{font-weight:400;line-height:24px}.semi-portal-rtl .semi-typography,.semi-rtl .semi-typography{direction:rtl}.semi-portal-rtl .semi-typography a,.semi-portal-rtl .semi-typography-link a,.semi-rtl .semi-typography a,.semi-rtl .semi-typography-link a{display:inline-block}.semi-portal-rtl .semi-typography-icon,.semi-rtl .semi-typography-icon{margin-left:4px;margin-right:auto}.semi-portal-rtl .semi-typography-ellipsis-expand,.semi-rtl .semi-typography-ellipsis-expand{margin-left:auto}.semi-portal-rtl .semi-typography-action-copied,.semi-portal-rtl .semi-typography-action-copy,.semi-rtl .semi-typography-action-copied,.semi-rtl .semi-typography-action-copy{margin-left:auto;margin-right:4px}@-webkit-keyframes semi-tooltip-zoomIn{0%{opacity:0;-webkit-transform:scale(.8);transform:scale(.8)}50%{opacity:1}}@keyframes semi-tooltip-zoomIn{0%{opacity:0;-webkit-transform:scale(.8);transform:scale(.8)}50%{opacity:1}}@-webkit-keyframes semi-tooltip-bounceIn{0%{opacity:0;-webkit-transform:scale(.6);transform:scale(.6)}70%{opacity:1;-webkit-transform:scale(1.01);transform:scale(1.01)}to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@keyframes semi-tooltip-bounceIn{0%{opacity:0;-webkit-transform:scale(.6);transform:scale(.6)}70%{opacity:1;-webkit-transform:scale(1.01);transform:scale(1.01)}to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@-webkit-keyframes semi-tooltip-zoomOut{0%{opacity:1}60%{opacity:0;-webkit-transform:scale(.8);transform:scale(.8)}to{opacity:0}}@keyframes semi-tooltip-zoomOut{0%{opacity:1}60%{opacity:0;-webkit-transform:scale(.8);transform:scale(.8)}to{opacity:0}}.semi-tooltip-wrapper{word-wrap:break-word;background-color:rgba(var(--semi-grey-7),1);border-radius:var(--semi-border-radius-medium);color:var(--semi-color-bg-0);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;left:0;line-height:20px;max-width:240px;opacity:0;overflow-wrap:break-word;padding:8px 12px;position:relative;top:0}.semi-tooltip-wrapper-show{opacity:1}.semi-tooltip-content{min-width:0}.semi-tooltip-trigger{display:inline-block;height:auto;width:auto}.semi-tooltip-with-arrow{align-items:center;box-sizing:border-box;display:flex;justify-content:center}.semi-tooltip-animation-show{-webkit-animation:semi-tooltip-zoomIn .1s cubic-bezier(.215,.61,.355,1);animation:semi-tooltip-zoomIn .1s cubic-bezier(.215,.61,.355,1);-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.semi-tooltip-animation-hide{-webkit-animation:semi-tooltip-zoomOut .1s cubic-bezier(.215,.61,.355,1);animation:semi-tooltip-zoomOut .1s cubic-bezier(.215,.61,.355,1);-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.semi-tooltip-wrapper .semi-tooltip-icon-arrow{color:rgba(var(--semi-grey-7),1);height:7px;position:absolute;width:24px}.semi-tooltip-wrapper[x-placement=top] .semi-tooltip-icon-arrow{bottom:-6px;left:50%;-webkit-transform:translateX(-50%);transform:translateX(-50%)}.semi-tooltip-wrapper[x-placement=top] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=top].semi-tooltip-with-arrow{min-width:36px}.semi-tooltip-wrapper[x-placement=topLeft] .semi-tooltip-icon-arrow{bottom:-6px;left:6px}.semi-tooltip-wrapper[x-placement=topLeft] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=topLeft].semi-tooltip-with-arrow{min-width:36px}.semi-tooltip-wrapper[x-placement=topRight] .semi-tooltip-icon-arrow{bottom:-6px;right:6px}.semi-tooltip-wrapper[x-placement=topRight] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=topRight].semi-tooltip-with-arrow{min-width:36px}.semi-tooltip-wrapper[x-placement=leftTop] .semi-tooltip-icon-arrow{height:24px;right:-6px;top:5px;width:7px}.semi-tooltip-wrapper[x-placement=leftTop] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=leftTop].semi-tooltip-with-arrow{min-height:34px}.semi-tooltip-wrapper[x-placement=left] .semi-tooltip-icon-arrow{height:24px;right:-6px;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);width:7px}.semi-tooltip-wrapper[x-placement=left] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=left].semi-tooltip-with-arrow{min-height:34px}.semi-tooltip-wrapper[x-placement=leftBottom] .semi-tooltip-icon-arrow{bottom:5px;height:24px;right:-6px;width:7px}.semi-tooltip-wrapper[x-placement=leftBottom] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=leftBottom].semi-tooltip-with-arrow{min-height:34px}.semi-tooltip-wrapper[x-placement=rightTop] .semi-tooltip-icon-arrow{height:24px;left:-6px;top:5px;-webkit-transform:rotate(180deg);transform:rotate(180deg);width:7px}.semi-tooltip-wrapper[x-placement=rightTop] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=rightTop].semi-tooltip-with-arrow{min-height:34px}.semi-tooltip-wrapper[x-placement=right] .semi-tooltip-icon-arrow{height:24px;left:-6px;top:50%;-webkit-transform:translateY(-50%) rotate(180deg);transform:translateY(-50%) rotate(180deg);width:7px}.semi-tooltip-wrapper[x-placement=right] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=right].semi-tooltip-with-arrow{min-height:34px}.semi-tooltip-wrapper[x-placement=rightBottom] .semi-tooltip-icon-arrow{bottom:5px;height:24px;left:-6px;-webkit-transform:rotate(180deg);transform:rotate(180deg);width:7px}.semi-tooltip-wrapper[x-placement=rightBottom] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=rightBottom].semi-tooltip-with-arrow{min-height:34px}.semi-tooltip-wrapper[x-placement=bottomLeft] .semi-tooltip-icon-arrow{left:6px;top:-6px;-webkit-transform:rotate(180deg);transform:rotate(180deg)}.semi-tooltip-wrapper[x-placement=bottomLeft] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=bottomLeft].semi-tooltip-with-arrow{min-width:36px}.semi-tooltip-wrapper[x-placement=bottom] .semi-tooltip-icon-arrow{left:50%;top:-6px;-webkit-transform:translateX(-50%) rotate(180deg);transform:translateX(-50%) rotate(180deg)}.semi-tooltip-wrapper[x-placement=bottom] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=bottom].semi-tooltip-with-arrow{min-width:36px}.semi-tooltip-wrapper[x-placement=bottomRight] .semi-tooltip-icon-arrow{right:6px;top:-6px;-webkit-transform:rotate(180deg);transform:rotate(180deg)}.semi-tooltip-wrapper[x-placement=bottomRight] .semi-tooltip-with-arrow,.semi-tooltip-wrapper[x-placement=bottomRight].semi-tooltip-with-arrow{min-width:36px}.semi-portal-rtl .semi-tooltip-wrapper,.semi-rtl .semi-tooltip-wrapper{direction:rtl;left:auto;padding-left:12px;padding-right:12px;right:0}@-webkit-keyframes semi-popover-zoomIn{0%{opacity:0;-webkit-transform:scale(.8);transform:scale(.8)}50%{opacity:1}}@keyframes semi-popover-zoomIn{0%{opacity:0;-webkit-transform:scale(.8);transform:scale(.8)}50%{opacity:1}}@-webkit-keyframes semi-popover-zoomOut{0%{opacity:1}60%{opacity:0;-webkit-transform:scale(.8);transform:scale(.8)}to{opacity:0}}@keyframes semi-popover-zoomOut{0%{opacity:1}60%{opacity:0;-webkit-transform:scale(.8);transform:scale(.8)}to{opacity:0}}.semi-popover-wrapper{background-color:var(--semi-color-bg-3);border-radius:var(--semi-border-radius-medium);box-shadow:var(--semi-shadow-elevated);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;opacity:0;position:relative;z-index:1030}.semi-popover-wrapper-show{opacity:1}.semi-popover-trigger{display:inline-block;height:auto;width:auto}.semi-popover-title{border-bottom:1px solid var(--semi-color-border);padding:8px}.semi-popover-confirm{position:absolute}.semi-popover-with-arrow{box-sizing:border-box;padding:12px}.semi-popover-animation-show{-webkit-animation:semi-popover-zoomIn .1s cubic-bezier(.215,.61,.355,1);animation:semi-popover-zoomIn .1s cubic-bezier(.215,.61,.355,1);-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.semi-popover-animation-hide{-webkit-animation:semi-popover-zoomOut .1s cubic-bezier(.215,.61,.355,1);animation:semi-popover-zoomOut .1s cubic-bezier(.215,.61,.355,1);-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.semi-popover-wrapper .semi-popover-icon-arrow{color:inherit;height:8px;position:absolute;width:24px}.semi-popover-wrapper[x-placement=top] .semi-popover-icon-arrow{bottom:-7px;left:50%;-webkit-transform:translateX(-50%);transform:translateX(-50%)}.semi-popover-wrapper[x-placement=top] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=top].semi-popover-with-arrow{min-width:36px}.semi-popover-wrapper[x-placement=topLeft] .semi-popover-icon-arrow{bottom:-7px;left:6px}.semi-popover-wrapper[x-placement=topLeft] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=topLeft].semi-popover-with-arrow{min-width:36px}.semi-popover-wrapper[x-placement=topRight] .semi-popover-icon-arrow{bottom:-7px;right:6px}.semi-popover-wrapper[x-placement=topRight] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=topRight].semi-popover-with-arrow{min-width:36px}.semi-popover-wrapper[x-placement=leftTop] .semi-popover-icon-arrow{height:24px;right:-7px;top:6px;width:8px}.semi-popover-wrapper[x-placement=leftTop] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=leftTop].semi-popover-with-arrow{min-height:36px}.semi-popover-wrapper[x-placement=left] .semi-popover-icon-arrow{height:24px;right:-7px;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);width:8px}.semi-popover-wrapper[x-placement=left] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=left].semi-popover-with-arrow{min-height:36px}.semi-popover-wrapper[x-placement=leftBottom] .semi-popover-icon-arrow{bottom:6px;height:24px;right:-7px;width:8px}.semi-popover-wrapper[x-placement=leftBottom] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=leftBottom].semi-popover-with-arrow{min-height:36px}.semi-popover-wrapper[x-placement=rightTop] .semi-popover-icon-arrow{height:24px;left:-7px;top:6px;-webkit-transform:rotate(180deg);transform:rotate(180deg);width:8px}.semi-popover-wrapper[x-placement=rightTop] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=rightTop].semi-popover-with-arrow{min-height:36px}.semi-popover-wrapper[x-placement=right] .semi-popover-icon-arrow{height:24px;left:-7px;top:50%;-webkit-transform:translateY(-50%) rotate(180deg);transform:translateY(-50%) rotate(180deg);width:8px}.semi-popover-wrapper[x-placement=right] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=right].semi-popover-with-arrow{min-height:36px}.semi-popover-wrapper[x-placement=rightBottom] .semi-popover-icon-arrow{bottom:6px;height:24px;left:-7px;-webkit-transform:rotate(180deg);transform:rotate(180deg);width:8px}.semi-popover-wrapper[x-placement=rightBottom] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=rightBottom].semi-popover-with-arrow{min-height:36px}.semi-popover-wrapper[x-placement=bottomLeft] .semi-popover-icon-arrow{left:6px;top:-7px;-webkit-transform:rotate(180deg);transform:rotate(180deg)}.semi-popover-wrapper[x-placement=bottomLeft] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=bottomLeft].semi-popover-with-arrow{min-width:36px}.semi-popover-wrapper[x-placement=bottom] .semi-popover-icon-arrow{left:50%;top:-7px;-webkit-transform:translateX(-50%) rotate(180deg);transform:translateX(-50%) rotate(180deg)}.semi-popover-wrapper[x-placement=bottom] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=bottom].semi-popover-with-arrow{min-width:36px}.semi-popover-wrapper[x-placement=bottomRight] .semi-popover-icon-arrow{right:6px;top:-7px;-webkit-transform:rotate(180deg);transform:rotate(180deg)}.semi-popover-wrapper[x-placement=bottomRight] .semi-popover-with-arrow,.semi-popover-wrapper[x-placement=bottomRight].semi-popover-with-arrow{min-width:36px}.semi-popover.semi-popover-rtl{direction:rtl}.semi-dropdown{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px}.semi-dropdown-wrapper{background:var(--semi-color-bg-3);border-radius:var(--semi-border-radius-medium);box-shadow:var(--semi-shadow-elevated);opacity:0;overflow-y:auto;position:relative;z-index:1050}.semi-dropdown-wrapper-show{opacity:1}.semi-dropdown-trigger{display:inline-block}.semi-dropdown-menu{list-style:none;margin:0;padding:4px 0}.semi-dropdown-title{color:var(--semi-color-text-2);cursor:default;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:12px;line-height:16px;padding:8px 16px 4px}.semi-dropdown-title-withTick{padding-left:31px}.semi-dropdown-item{align-items:center;color:var(--semi-color-text-0);display:flex;max-width:280px;padding:8px 16px;transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeOut) 0ms}.semi-dropdown-item-hover{background-color:var(--semi-color-fill-0)}.semi-dropdown-item:not(.semi-dropdown-item-active):hover{background-color:var(--semi-color-fill-0);cursor:pointer}.semi-dropdown-item:not(.semi-dropdown-item-active):active{background-color:var(--semi-color-fill-1)}.semi-dropdown-item:focus-visible{background-color:var(--semi-color-fill-0);outline:0}.semi-dropdown-item-icon{align-items:center;display:inline-flex;margin-right:8px}.semi-dropdown-item-danger{color:var(--semi-color-danger)}.semi-dropdown-item-secondary{color:var(--semi-color-secondary)}.semi-dropdown-item-warning{color:var(--semi-color-warning)}.semi-dropdown-item-tertiary{color:var(--semi-color-tertiary)}.semi-dropdown-item-primary{color:var(--semi-color-primary)}.semi-dropdown-item-withTick{padding-left:12px}.semi-dropdown-item>.semi-icon{flex-shrink:0;font-size:12px;margin-right:9px}.semi-dropdown-item-active{font-weight:600}.semi-dropdown-item.semi-dropdown-item-disabled{color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-dropdown-item.semi-dropdown-item-disabled:active,.semi-dropdown-item.semi-dropdown-item-disabled:hover{background-color:initial;cursor:not-allowed}.semi-dropdown-divider{background:var(--semi-color-border);clear:both;display:block;height:1px;margin:4px 0;min-width:100%;width:100%}.semi-portal-rtl .semi-dropdown-wrapper,.semi-rtl .semi-dropdown-wrapper{direction:rtl}.semi-portal-rtl .semi-dropdown-title-withTick,.semi-rtl .semi-dropdown-title-withTick{padding-left:0;padding-right:31px}.semi-portal-rtl .semi-dropdown-item-withTick,.semi-rtl .semi-dropdown-item-withTick{padding-left:auto;padding-right:12px}.semi-portal-rtl .semi-dropdown-item>.semi-icon,.semi-rtl .semi-dropdown-item>.semi-icon{margin-left:9px;margin-right:0}.semi-layout{display:flex;flex:auto;flex-direction:column;min-height:auto}.semi-layout,.semi-layout-content,.semi-layout-footer,.semi-layout-header,.semi-layout-sider,.semi-layout-sider-children{box-sizing:border-box}.semi-layout-footer,.semi-layout-header{flex:0 0 auto}.semi-layout-content{flex:auto;min-height:auto}.semi-layout-sider{min-width:auto;position:relative}.semi-layout-sider-children{height:100%;margin-top:-.1px;padding-top:.1px}.semi-layout-has-sider{flex-direction:row}.semi-layout-has-sider>.semi-layout,.semi-layout-has-sider>.semi-layout-content{overflow-x:hidden}.semi-portal-rtl .semi-layout,.semi-rtl .semi-layout{direction:rtl}.semi-input-textarea-wrapper{background-color:var(--semi-color-fill-0);border:1px solid transparent;border-radius:var(--semi-border-radius-small);box-sizing:border-box;display:inline-block;position:relative;transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);vertical-align:bottom;width:100%}.semi-input-textarea-wrapper:hover{background-color:var(--semi-color-fill-1)}.semi-input-textarea-wrapper-focus{border:1px solid var(--semi-color-focus-border)}.semi-input-textarea-wrapper-focus,.semi-input-textarea-wrapper-focus:active,.semi-input-textarea-wrapper-focus:hover{background-color:var(--semi-color-fill-0)}.semi-input-textarea-wrapper:active{background-color:var(--semi-color-fill-2)}.semi-input-textarea-wrapper .semi-input-clearbtn{color:var(--semi-color-text-2);height:32px;min-width:24px;position:absolute;right:4px;top:0}.semi-input-textarea-wrapper .semi-input-clearbtn>svg{pointer-events:none}.semi-input-textarea-wrapper .semi-input-clearbtn:hover{cursor:pointer}.semi-input-textarea-wrapper .semi-input-clearbtn:hover .semi-icon{color:var(--semi-color-primary-hover)}.semi-input-textarea-wrapper .semi-input-clearbtn-hidden{visibility:hidden}.semi-input-textarea-wrapper-disabled,.semi-input-textarea-wrapper-readonly{background-color:var(--semi-color-disabled-fill);color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-input-textarea-wrapper-disabled:hover,.semi-input-textarea-wrapper-readonly:hover{background-color:var(--semi-color-disabled-fill)}.semi-input-textarea-wrapper-disabled::-webkit-input-placeholder,.semi-input-textarea-wrapper-readonly::-webkit-input-placeholder{color:var(--semi-color-disabled-text)}.semi-input-textarea-wrapper-disabled::placeholder,.semi-input-textarea-wrapper-readonly::placeholder{color:var(--semi-color-disabled-text)}.semi-input-textarea-wrapper-readonly{cursor:text}.semi-input-textarea-wrapper-error{background-color:var(--semi-color-danger-light-default);border-color:var(--semi-color-danger-light-default)}.semi-input-textarea-wrapper-error:hover{background-color:var(--semi-color-danger-light-hover);border-color:var(--semi-color-danger-light-hover)}.semi-input-textarea-wrapper-error.semi-input-textarea-wrapper-focus{background-color:var(--semi-color-danger-light-default);border-color:var(--semi-color-danger)}.semi-input-textarea-wrapper-error:active{background-color:var(--semi-color-danger-light-active);border-color:var(--semi-color-danger)}.semi-input-textarea-wrapper-warning{background-color:var(--semi-color-warning-light-default);border-color:var(--semi-color-warning-light-default)}.semi-input-textarea-wrapper-warning:hover{background-color:var(--semi-color-warning-light-hover);border-color:var(--semi-color-warning-light-hover)}.semi-input-textarea-wrapper-warning.semi-input-textarea-wrapper-focus{background-color:var(--semi-color-warning-light-default);border-color:var(--semi-color-warning)}.semi-input-textarea-wrapper-warning:active{background-color:var(--semi-color-warning-light-active);border-color:var(--semi-color-warning)}.semi-input-textarea{background-color:initial;border:0 solid transparent;box-shadow:none;box-sizing:border-box;color:var(--semi-color-text-0);cursor:text;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;outline:none;padding:5px 12px;position:relative;resize:none;vertical-align:bottom;width:100%}.semi-input-textarea:hover{border-color:transparent}.semi-input-textarea::-webkit-input-placeholder{color:var(--semi-color-text-2)}.semi-input-textarea::placeholder{color:var(--semi-color-text-2)}.semi-input-textarea-showClear{padding-right:36px}.semi-input-textarea-disabled,.semi-input-textarea-readonly{background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-input-textarea-disabled:hover,.semi-input-textarea-readonly:hover{background-color:initial}.semi-input-textarea-disabled::-webkit-input-placeholder,.semi-input-textarea-readonly::-webkit-input-placeholder{color:var(--semi-color-disabled-text)}.semi-input-textarea-disabled::placeholder,.semi-input-textarea-readonly::placeholder{color:var(--semi-color-disabled-text)}.semi-input-textarea-readonly{cursor:text}.semi-input-textarea-autosize{overflow:hidden}.semi-input-textarea-counter{color:var(--semi-color-text-2);display:flex;flex-direction:column;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:12px;justify-content:center;line-height:16px;min-height:24px;padding:3px 12px 5px;text-align:right}.semi-input-textarea-counter-exceed{color:var(--semi-color-danger)}.semi-input-textarea-borderless:not(:focus-within):not(:hover){background-color:initial;border-color:transparent}.semi-input-textarea-borderless:focus-within:not(:active){background-color:initial}.semi-input-textarea-borderless.semi-input-textarea-wrapper-error:not(:focus-within){border-color:var(--semi-color-danger)}.semi-input-textarea-borderless.semi-input-textarea-wrapper-warning:not(:focus-within){border-color:var(--semi-color-warning)}.semi-input-textarea-borderless.semi-input-textarea-wrapper-error .semi-input-textarea-counter{color:var(--semi-color-danger)}.semi-input-textarea-borderless.semi-input-textarea-wrapper-warning .semi-input-textarea-counter{color:var(--semi-color-warning)}.semi-navigation{background-color:var(--semi-color-nav-bg);border-right:1px solid var(--semi-color-border);box-sizing:border-box;display:inline-flex;margin:0;outline:none;overflow:hidden;padding-left:8px;padding-right:8px;transition:padding-left .1s ease-out,width .2s cubic-bezier(.62,.05,.36,.95);-webkit-user-select:none;user-select:none;width:240px}.semi-navigation-inner{display:flex;height:100%;justify-content:space-between;width:100%}.semi-navigation-list{list-style:none;margin:0;padding:0}.semi-navigation-list>.semi-navigation-item-normal{height:36px}.semi-navigation-list>.semi-navigation-item,.semi-navigation-list>.semi-navigation-item>.semi-navigation-sub-title{font-weight:600}.semi-navigation-collapsed{padding-left:8px;padding-right:8px;transition:padding-left .1s ease-out,width .2s cubic-bezier(.62,.05,.36,.95);width:60px}.semi-navigation-collapsed .semi-navigation-item-icon:last-child,.semi-navigation-collapsed .semi-navigation-item-text{opacity:0;transition:opacity .2s cubic-bezier(.5,-.1,1,.4)}.semi-navigation-item,.semi-navigation-sub-wrap .semi-navigation-sub-title{border-radius:var(--semi-border-radius-small);box-sizing:border-box;color:var(--semi-color-text-0);cursor:pointer;display:flex;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:20px;margin-bottom:8px;margin-top:0;padding:8px 12px;transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);width:100%}.semi-navigation-item-text,.semi-navigation-sub-wrap .semi-navigation-sub-title-text{opacity:1;overflow:hidden;text-overflow:ellipsis;transition:opacity .2s cubic-bezier(.5,-.1,1,.4);white-space:nowrap}.semi-navigation-item-indent,.semi-navigation-sub-wrap .semi-navigation-sub-title-indent{width:32px}.semi-navigation-item:focus-visible,.semi-navigation-sub-wrap .semi-navigation-sub-title:focus-visible{outline:2px solid var(--semi-color-primary-light-active);outline-offset:-2px}.semi-navigation-header-link,.semi-navigation-item-link{align-items:center;color:inherit;display:flex;justify-content:flex-start;text-decoration:none;width:100%}.semi-navigation-item-has-link{padding:0}.semi-navigation-item-has-link .semi-navigation-item-link{padding:8px 12px}.semi-navigation-item-sub{padding:0}.semi-navigation-sub-wrap>.semi-navigation-item-inner{width:100%}.semi-navigation-sub-wrap .semi-navigation-sub-title>.semi-navigation-item-inner{display:flex}.semi-navigation-item-inner{align-items:center;display:flex;flex:0 0 auto;width:100%}.semi-navigation-item-title{opacity:1;transition:opacity .1s ease-out 100s}.semi-navigation .semi-navigation-sub-title{margin-bottom:0}.semi-navigation-item-icon-info{margin-left:0}.semi-navigation-item-icon-info,.semi-navigation-item-icon-toggle-left{color:var(--semi-color-text-2);display:inline-flex;margin-right:12px;min-width:20px}.semi-navigation-item-icon-toggle-right{color:var(--semi-color-text-2);display:inline-flex;margin-left:auto;opacity:1;transition:opacity .2s cubic-bezier(.5,-.1,1,.4)}.semi-navigation-item-selected{background-color:var(--semi-color-primary-light-default);color:var(--semi-color-text-0)}.semi-navigation-item-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-item-selected.semi-navigation-item-disabled{background-color:initial;color:var(--semi-color-primary-disabled);cursor:not-allowed}.semi-navigation-item-selected.semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-primary-disabled)}.semi-navigation-item-disabled{background-color:initial;cursor:not-allowed}.semi-navigation-item-disabled,.semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-inner>.semi-navigation-item{color:var(--semi-color-text-0)}.semi-navigation-item-normal:hover:not(.semi-navigation-item-selected){background-color:var(--semi-color-fill-0);color:var(--semi-color-text-0)}.semi-navigation-item-normal:hover:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child{color:var(--semi-color-text-2)}.semi-navigation-item-normal:hover.semi-navigation-item-selected{background-color:var(--semi-color-fill-0);color:var(--semi-color-text-0)}.semi-navigation-item-normal:hover.semi-navigation-item-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-item-normal:hover.semi-navigation-item-disabled{background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-item-normal:hover.semi-navigation-item-selected.semi-navigation-item-disabled{background-color:initial;color:var(--semi-color-primary-disabled);cursor:not-allowed}.semi-navigation-item-normal:hover.semi-navigation-item-selected.semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-primary-disabled)}.semi-navigation-inner>.semi-navigation-item-normal:active:not(.semi-navigation-item-selected),.semi-navigation-item-normal:active:not(.semi-navigation-item-selected){background-color:var(--semi-color-fill-1);color:var(--semi-color-text-0)}.semi-navigation-inner>.semi-navigation-item-normal:active:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child,.semi-navigation-item-normal:active:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child{color:var(--semi-color-text-2)}.semi-navigation-inner>.semi-navigation-item-normal:active.semi-navigation-item-selected,.semi-navigation-item-normal:active.semi-navigation-item-selected{background-color:var(--semi-color-fill-1);color:var(--semi-color-text-0)}.semi-navigation-inner>.semi-navigation-item-normal:active.semi-navigation-item-selected .semi-navigation-item-icon:first-child,.semi-navigation-item-normal:active.semi-navigation-item-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-inner>.semi-navigation-item-normal:active.semi-navigation-item-disabled,.semi-navigation-item-normal:active.semi-navigation-item-disabled{background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-navigation-inner>.semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-inner>.semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon:first-child,.semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-sub-wrap .semi-navigation-item-inner{display:block}.semi-navigation-sub-wrap{display:block;height:inherit;margin-top:0;padding:0}.semi-navigation-sub-wrap .semi-navigation-sub-title{align-items:center;display:flex;height:36px;justify-content:flex-start}.semi-navigation-sub{font-size:14px;font-weight:400;list-style:none;outline:none;overflow:hidden;padding:0;text-overflow:ellipsis;white-space:nowrap}.semi-navigation-sub .semi-navigation-item{background-color:initial;color:var(--semi-color-text-0);font-weight:400;height:36px;width:100%}.semi-navigation-sub .semi-navigation-item:first-child{margin-top:8px}.semi-navigation-sub .semi-navigation-item>.semi-navigation-sub .semi-navigation-item-text:first-child{margin-left:44px}.semi-navigation-sub .semi-navigation-item:hover:not(.semi-navigation-sub-wrap):not(.semi-navigation-item-selected):not(.semi-navigation-item-disabled){background-color:var(--semi-color-fill-0);color:var(--semi-color-text-0)}.semi-navigation-sub .semi-navigation-item:hover:not(.semi-navigation-sub-wrap):not(.semi-navigation-item-selected):not(.semi-navigation-item-disabled) .semi-navigation-item-icon:first-child{color:var(--semi-color-text-2)}.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-disabled{background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-selected{background-color:var(--semi-color-primary-light-default);color:var(--semi-color-text-0)}.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-sub .semi-navigation-item:active:not(.semi-navigation-sub-wrap):not(.semi-navigation-item-selected):not(.semi-navigation-item-disabled){background-color:var(--semi-color-fill-1);color:var(--semi-color-text-0)}.semi-navigation-sub .semi-navigation-item:active:not(.semi-navigation-sub-wrap):not(.semi-navigation-item-selected):not(.semi-navigation-item-disabled) .semi-navigation-item-icon:first-child{color:var(--semi-color-text-2)}.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-disabled{background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-selected{background-color:var(--semi-color-primary-light-default);color:var(--semi-color-text-0)}.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-sub .semi-navigation-item-selected{background-color:var(--semi-color-primary-light-default);color:var(--semi-color-text-0)}.semi-navigation-sub .semi-navigation-item-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-sub .semi-navigation-item-selected.semi-navigation-item-disabled{background-color:var(--semi-color-primary-light-default);color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-navigation-sub .semi-navigation-item-disabled{background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-navigation-sub .semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-sub .semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-sub .semi-navigation-sub-wrap{height:inherit}.semi-navigation-icon-rotate-0{-webkit-transform:rotate(0);transform:rotate(0)}.semi-navigation-icon-rotate-0,.semi-navigation-icon-rotate-180{transition:-webkit-transform .2s ease-in-out;transition:transform .2s ease-in-out;transition:transform .2s ease-in-out,-webkit-transform .2s ease-in-out}.semi-navigation-icon-rotate-180{-webkit-transform:rotate(-180deg);transform:rotate(-180deg)}.semi-navigation-header{align-items:center;box-sizing:border-box;display:inline-flex}.semi-navigation-header-logo{display:inline-flex;margin-left:0;margin-right:8px}.semi-navigation-header-logo>.semi-icon,.semi-navigation-header-logo>img{height:36px;object-fit:scale-down;width:36px}.semi-navigation-header-text{color:var(--semi-color-text-0);display:inline-flex;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:18px;font-weight:600;line-height:24px;opacity:1;text-overflow:ellipsis;transition:opacity .2s cubic-bezier(.5,-.1,1,.4);white-space:nowrap}.semi-navigation-footer{align-items:center;box-sizing:border-box;display:inline-flex;padding:16px 24px}.semi-navigation-footer .semi-navigation-collapse-btn{text-overflow:ellipsis;white-space:nowrap}.semi-navigation-collapsed .semi-navigation-header{justify-content:center}.semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo{margin-right:0;width:100%}.semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo>.semi-icon,.semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo>img{max-height:100%;max-width:100%;width:36px}.semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-text{opacity:0;transition:opacity .2s cubic-bezier(.5,-.1,1,.4)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-item-selected:not(.semi-navigation-item-disabled).semi-navigation-item-normal:hover .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title{background-color:initial;color:var(--semi-color-text-0)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title-selected{background-color:var(--semi-color-primary-light-default);background-color:initial;color:var(--semi-color-text-0);font-weight:600}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title-selected.semi-navigation-sub-title-disabled{background-color:initial;color:var(--semi-color-primary-disabled);cursor:not-allowed}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title-selected.semi-navigation-sub-title-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-primary-disabled)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title-disabled{background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed;font-weight:600}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title-disabled .semi-navigation-item-icon,.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:hover:not(.semi-navigation-sub-title-selected){background-color:var(--semi-color-fill-0);color:var(--semi-color-text-0)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:hover:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon:first-child{color:var(--semi-color-text-2)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:hover.semi-navigation-sub-title-selected{background-color:var(--semi-color-fill-0);color:var(--semi-color-text-0)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:hover.semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:active:not(.semi-navigation-sub-title-selected){background-color:var(--semi-color-fill-1);color:var(--semi-color-text-0)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:active:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon:first-child{color:var(--semi-color-text-2)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:active.semi-navigation-sub-title-selected{background-color:var(--semi-color-fill-1);color:var(--semi-color-text-0)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:active.semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:active.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected),.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected){background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:active.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon,.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:active.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon:first-child,.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon,.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:active.semi-navigation-sub-title-disabled.semi-navigation-sub-title-selected,.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled.semi-navigation-sub-title-selected{background-color:initial;color:var(--semi-color-primary-disabled);cursor:not-allowed}.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:active.semi-navigation-sub-title-disabled.semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child,.semi-navigation-vertical .semi-navigation-list>.semi-navigation-sub-wrap>.semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled.semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-primary-disabled)}.semi-navigation-vertical .semi-navigation-item:last-of-type{margin-bottom:0}.semi-navigation-vertical .semi-navigation-inner{flex-direction:column}.semi-navigation-vertical .semi-navigation-header-list-outer{height:100%}.semi-navigation-vertical .semi-navigation-list-wrapper{overflow-x:hidden;overflow-y:auto;padding-top:12px}.semi-navigation-vertical .semi-navigation-header{padding:32px 8px 36px 5.5px;width:100%}.semi-navigation-vertical .semi-navigation-header-collapsed{padding-left:5.5px;padding-right:0;transition:padding-left .1s ease-out,width .2s cubic-bezier(.62,.05,.36,.95)}.semi-navigation-vertical .semi-navigation-footer{color:var(--semi-color-text-2);padding-left:8px;padding-right:8px}.semi-navigation-vertical .semi-navigation-footer .semi-navigation-collapse-btn .semi-button-content-right{margin-left:12px;opacity:1;transition:opacity .2s cubic-bezier(.5,-.1,1,.4)}.semi-navigation-vertical .semi-navigation-footer .semi-navigation-collapse-btn>.semi-button{padding-left:8px;padding-right:8px}.semi-navigation-vertical .semi-navigation-footer-collapsed{justify-content:center}.semi-navigation-vertical .semi-navigation-footer-collapsed .semi-navigation-collapse-btn{width:100%}.semi-navigation-vertical .semi-navigation-footer-collapsed .semi-navigation-collapse-btn .semi-button-content-right{opacity:0;transition:opacity .2s cubic-bezier(.5,-.1,1,.4)}.semi-navigation-horizontal{border-bottom:1px solid var(--semi-color-border);border-right:none;height:60px;padding-left:24px;padding-right:24px;width:100%}.semi-navigation-horizontal .semi-navigation-inner{flex-direction:row}.semi-navigation-horizontal .semi-navigation-header-list-outer{align-items:center;display:inline-flex}.semi-navigation-horizontal .semi-navigation-header-list-outer-collapsed{align-items:baseline}.semi-navigation-horizontal .semi-navigation-header{margin-right:24px;width:inherit}.semi-navigation-horizontal .semi-navigation-list{align-items:center;display:inline-flex}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item{background-color:initial;color:var(--semi-color-text-2);margin-bottom:0}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item .semi-navigation-item-icon:first-child{color:var(--semi-color-text-2)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-selected{background-color:initial;color:var(--semi-color-text-0)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-selected .semi-navigation-item-icon:first-child{color:var(--semi-color-text-0)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-disabled{background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover:not(.semi-navigation-item-selected){background-color:initial;color:var(--semi-color-text-1)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child{color:var(--semi-color-text-1)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover:not(.semi-navigation-item-selected) .semi-navigation-item-text{background-color:initial;color:var(--semi-color-text-1)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active:not(.semi-navigation-item-selected){background-color:initial;color:var(--semi-color-text-0)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child{color:var(--semi-color-text-0)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active.semi-navigation-item-disabled,.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover.semi-navigation-item-disabled{background-color:initial;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon:first-child,.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-icon,.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-icon:first-child{color:var(--semi-color-disabled-text)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-text,.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-text{background-color:initial;color:var(--semi-color-disabled-text)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item:not(:last-of-type){margin-right:8px}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title .semi-navigation-item-text{background-color:initial;color:var(--semi-color-text-2)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child,.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-selected .semi-navigation-item-text{background-color:initial;color:var(--semi-color-text-0)}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-disabled{cursor:not-allowed}.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-disabled .semi-navigation-item-icon:first-child,.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-disabled .semi-navigation-item-text{background-color:initial;color:var(--semi-color-disabled-text)}.semi-navigation-horizontal .semi-navigation-item-inner{width:auto}.semi-navigation-horizontal .semi-navigation-item-icon:last-child{margin-left:8px}.semi-navigation-horizontal .semi-navigation-item-icon:first-child{margin-right:8px}.semi-navigation-horizontal .semi-navigation-item{width:auto}.semi-navigation-horizontal .semi-navigation-item-collapsed{word-wrap:none;text-overflow:ellipsis}.semi-navigation-horizontal .semi-navigation-footer{border-top:none;padding-right:0}.semi-navigation-horizontal .semi-navigation-footer-collapsed{align-items:center;flex-direction:row;justify-content:center}.semi-navigation-popover .semi-navigation-sub-title{width:100%}.semi-navigation-popover .semi-navigation-item-selected{font-weight:400}.semi-dropdown-item .semi-navigation-sub-title{box-sizing:border-box;padding:8px 12px;width:100%}.semi-dropdown-item.semi-navigation-item{margin-bottom:0;margin-top:0;min-width:150px}.semi-dropdown-menu .semi-navigation-item-sub{padding:0}.semi-portal-rtl .semi-navigation,.semi-rtl .semi-navigation{border-left:1px solid var(--semi-color-border);border-right:0;direction:rtl}.semi-portal-rtl .semi-navigation,.semi-portal-rtl .semi-navigation-collapsed,.semi-rtl .semi-navigation,.semi-rtl .semi-navigation-collapsed{transition:padding-right .1s ease-out,width .2s cubic-bezier(.62,.05,.36,.95)}.semi-portal-rtl .semi-navigation-item-icon:first-child,.semi-rtl .semi-navigation-item-icon:first-child{margin-left:12px;margin-right:0}.semi-portal-rtl .semi-navigation-item-icon:last-child,.semi-rtl .semi-navigation-item-icon:last-child{margin-left:0;margin-right:auto}.semi-portal-rtl .semi-navigation-sub .semi-navigation-item>.semi-portal-rtl .semi-navigation-sub .semi-navigation-item-text:first-child,.semi-portal-rtl .semi-navigation-sub .semi-navigation-item>.semi-rtl .semi-navigation-sub .semi-navigation-item-text:first-child,.semi-rtl .semi-navigation-sub .semi-navigation-item>.semi-portal-rtl .semi-navigation-sub .semi-navigation-item-text:first-child,.semi-rtl .semi-navigation-sub .semi-navigation-item>.semi-rtl .semi-navigation-sub .semi-navigation-item-text:first-child{margin-left:auto;margin-right:44px}.semi-portal-rtl .semi-navigation-sub .semi-navigation-item>.semi-navigation-item-icon:first-child,.semi-rtl .semi-navigation-sub .semi-navigation-item>.semi-navigation-item-icon:first-child{margin-right:12px}.semi-portal-rtl .semi-navigation-header,.semi-rtl .semi-navigation-header{align-items:center;box-sizing:border-box;display:inline-flex}.semi-portal-rtl .semi-navigation-header-logo,.semi-rtl .semi-navigation-header-logo{display:inline-flex;margin-left:8px;margin-right:0}.semi-portal-rtl .semi-navigation-collapsed,.semi-rtl .semi-navigation-collapsed{direction:rtl}.semi-portal-rtl .semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo,.semi-rtl .semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo{margin-left:0;margin-right:auto}.semi-portal-rtl .semi-navigation-vertical,.semi-rtl .semi-navigation-vertical{direction:rtl}.semi-portal-rtl .semi-navigation-vertical .semi-navigation-header,.semi-rtl .semi-navigation-vertical .semi-navigation-header{padding-left:8px;padding-right:5.5px}.semi-portal-rtl .semi-navigation-vertical .semi-navigation-header-collapsed,.semi-rtl .semi-navigation-vertical .semi-navigation-header-collapsed{padding-left:0;padding-right:5.5px;transition:padding-right .1s ease-out,width .2s cubic-bezier(.62,.05,.36,.95)}.semi-portal-rtl .semi-navigation-vertical .semi-navigation-footer .semi-navigation-collapse-btn .semi-button-content-right,.semi-rtl .semi-navigation-vertical .semi-navigation-footer .semi-navigation-collapse-btn .semi-button-content-right{margin-left:auto;margin-right:12px;transition:opacity .2s cubic-bezier(.5,-.1,1,.4)}.semi-portal-rtl .semi-navigation-horizontal,.semi-rtl .semi-navigation-horizontal{border-left:none;border-right:auto;direction:rtl;padding-left:24px;padding-right:24px}.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-header,.semi-rtl .semi-navigation-horizontal .semi-navigation-header{margin-left:24px;margin-right:auto}.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-list .semi-navigation-item:not(:last-of-type),.semi-rtl .semi-navigation-horizontal .semi-navigation-list .semi-navigation-item:not(:last-of-type){margin-left:8px;margin-right:auto}.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-item-icon:last-child,.semi-rtl .semi-navigation-horizontal .semi-navigation-item-icon:last-child{margin-left:auto;margin-right:8px}.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-item-icon:first-child,.semi-rtl .semi-navigation-horizontal .semi-navigation-item-icon:first-child{margin-left:8px;margin-right:auto}.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-footer,.semi-rtl .semi-navigation-horizontal .semi-navigation-footer{padding-left:0;padding-right:auto}.semi-collapsible-transition{transition:height .25s ease var(--semi-transition_delay-none),opacity .25s var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-popconfirm{box-sizing:border-box;max-width:400px}.semi-popconfirm-inner{display:flex;flex-direction:column;padding:24px 24px 24px 20px;position:relative}.semi-popconfirm-header{display:flex;justify-content:flex-start}.semi-popconfirm-header-title{color:var(--semi-color-text-0);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:16px;font-weight:600;line-height:22px;margin-bottom:8px}.semi-popconfirm-header-icon{height:24px;margin-right:12px;width:24px}.semi-popconfirm-header .semi-icon-alert_triangle{color:var(--semi-color-warning)}.semi-popconfirm-header-body{display:inline-flex;flex-direction:column;flex-grow:1}.semi-popconfirm-body{color:var(--semi-color-text-2)}.semi-popconfirm-body-withIcon{margin-left:36px}.semi-popconfirm-body>p{margin:0;padding:0}.semi-popconfirm-footer{display:flex;justify-content:flex-end;margin-top:25px}.semi-popconfirm-footer>.semi-button:first-child:not(:last-child){margin-right:8px}.semi-popconfirm-popover{border-radius:var(--semi-border-radius-medium)}.semi-popover-with-arrow .semi-popconfirm-inner{padding:12px 12px 12px 8px}.semi-popconfirm-rtl{direction:rtl}.semi-popconfirm-rtl .semi-popconfirm-inner{padding:24px 20px 24px 24px}.semi-popconfirm-rtl .semi-popconfirm-header{margin-right:0}.semi-popconfirm-rtl .semi-popconfirm-header-icon{margin-left:12px;margin-right:0}.semi-popconfirm-rtl .semi-popconfirm-footer{justify-content:flex-end}.semi-popconfirm-rtl .semi-popconfirm-footer>.semi-button:first-child:not(:last-child){margin-left:8px;margin-right:0}.semi-popover-with-arrow.semi-popconfirm-rtl{direction:rtl}.semi-popover-with-arrow.semi-popconfirm-rtl .semi-popconfirm-inner{padding:12px 8px 12px 12px}.semi-space{display:inline-flex}.semi-space-vertical{flex-direction:column}.semi-space-horizontal{flex-direction:row}.semi-space-align-center{align-items:center}.semi-space-align-end{align-items:flex-end}.semi-space-align-start{align-items:flex-start}.semi-space-align-baseline{align-items:baseline}.semi-space-wrap{flex-wrap:wrap}.semi-space-tight-horizontal{-webkit-column-gap:8px;column-gap:8px}.semi-space-tight-vertical{row-gap:8px}.semi-space-medium-horizontal{-webkit-column-gap:16px;column-gap:16px}.semi-space-medium-vertical{row-gap:16px}.semi-space-loose-horizontal{-webkit-column-gap:24px;column-gap:24px}.semi-space-loose-vertical{row-gap:24px}.semi-portal-rtl .semi-space,.semi-rtl .semi-space{direction:rtl}.semi-table-panel-operation{background-color:var(--semi-color-primary);color:var(--semi-color-text-2);padding:8px 16px}.semi-table-panel-operation,.semi-table-panel-operation-left,.semi-table-panel-operation-right{display:flex;justify-content:space-between}.semi-table-panel-operation-selected{color:var(--semi-color-primary-light-active)}.semi-table-pagination-info{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:20px}.semi-table-pagination-outer{align-items:center;display:flex;justify-content:space-between}.semi-table{border-collapse:initial;border-spacing:0;display:table;font-size:inherit;text-align:left;width:100%}.semi-table-wrapper{zoom:1;box-sizing:border-box;clear:both;color:var(--semi-color-text-0);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;margin:0;padding:0;position:relative;width:100%}.semi-table-wrapper[data-column-fixed=true]{z-index:1}.semi-table-wrapper-ltr,.semi-table-wrapper-ltr .semi-spin{direction:ltr}.semi-table-middle .semi-table-tbody>.semi-table-row>.semi-table-row-cell{padding-bottom:12px;padding-top:12px}.semi-table-small .semi-table-tbody>.semi-table-row>.semi-table-row-cell{padding-bottom:8px;padding-top:8px}.semi-table-title{padding:16px 0;position:relative}.semi-table-container{position:relative}.semi-table-header{scrollbar-base-color:transparent;overflow:hidden}.semi-table-header::-webkit-scrollbar{background-color:initial;border-bottom:2px solid var(--semi-color-border)}.semi-table-header-sticky{position:-webkit-sticky;position:sticky;z-index:102}.semi-table-header-sticky .semi-table-thead>.semi-table-row>.semi-table-row-head{background-color:var(--semi-color-bg-1)}.semi-table-header-hidden{height:0}.semi-table-align-center .semi-table-operate-wrapper{justify-content:center}.semi-table-align-right .semi-table-operate-wrapper{justify-content:flex-end}.semi-table-operate-wrapper{display:flex;justify-content:flex-start}.semi-table-body{box-sizing:border-box;overflow:auto;width:100%}.semi-table-colgroup{display:table-column-group}.semi-table-colgroup .semi-table-col{display:table-column}.semi-table-colgroup .semi-table-column-expand,.semi-table-colgroup .semi-table-column-selection{width:48px}.semi-table-thead>.semi-table-row>.semi-table-row-head{background-color:var(--semi-color-bg-1);border-bottom:2px solid var(--semi-color-border);color:var(--semi-color-text-2);font-weight:600;overflow-wrap:break-word;padding:8px 16px;position:relative;text-align:left;vertical-align:middle}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-left,.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right{background-color:var(--semi-color-bg-1);position:-webkit-sticky;position:sticky;z-index:101}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-left:before,.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right:before{background-color:var(--semi-color-bg-1);bottom:0;content:"";display:block;left:0;position:absolute;right:0;top:0;z-index:-1}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-left-last{border-right:1px solid var(--semi-color-border);box-shadow:3px 0 0 0 var(--semi-color-shadow)}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-left-last.resizing{border-right:2px solid var(--semi-color-primary)}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-left-last.resizing .react-resizable-handle:hover{background-color:initial}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right-first{border-left:1px solid var(--semi-color-border);box-shadow:-3px 0 0 0 var(--semi-color-shadow)}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right-first.resizing{border-right:2px solid var(--semi-color-primary)}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right-first.resizing .react-resizable-handle:hover{background-color:initial}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right-first[x-type=column-scrollbar]{border-left:transparent;box-shadow:none}.semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-column-selection,.semi-table-thead>.semi-table-row>.semi-table-row-head[colspan]:not([colspan="1"]){text-align:center}.semi-table-thead>.semi-table-row>.semi-table-row-head .semi-table-header-column{align-items:center;display:inline-flex}.semi-table-thead>.semi-table-row>.semi-table-row-head-ellipsis,.semi-table-thead>.semi-table-row>.semi-table-row-head-ellipsis .semi-table-row-head-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.semi-table-thead>.semi-table-row .react-resizable{background-clip:padding-box;position:relative}.semi-table-thead>.semi-table-row .resizing{border-right:2px solid var(--semi-color-primary)}.semi-table-thead>.semi-table-row .resizing .react-resizable-handle:hover{background-color:initial}.semi-table-thead>.semi-table-row .react-resizable-handle{background-color:var(--semi-color-border);bottom:4px;cursor:col-resize;height:calc(100% - 8px);position:absolute;right:-1px;width:9px;z-index:0}.semi-table-thead>.semi-table-row .react-resizable-handle:hover{background-color:var(--semi-color-primary)}.semi-table-tbody{display:table-row-group}.semi-table-tbody>.semi-table-row{background-color:var(--semi-color-bg-1);display:table-row}.semi-table-tbody>.semi-table-row:hover>.semi-table-row-cell{background-color:var(--semi-color-bg-0);background-image:linear-gradient(0deg,var(--semi-color-fill-0),var(--semi-color-fill-0))}.semi-table-tbody>.semi-table-row:hover>.semi-table-row-cell.semi-table-cell-fixed-left,.semi-table-tbody>.semi-table-row:hover>.semi-table-row-cell.semi-table-cell-fixed-right{background-image:linear-gradient(0deg,var(--semi-color-bg-1),var(--semi-color-bg-1))}.semi-table-tbody>.semi-table-row:hover>.semi-table-row-cell.semi-table-cell-fixed-left:before,.semi-table-tbody>.semi-table-row:hover>.semi-table-row-cell.semi-table-cell-fixed-right:before{background-color:var(--semi-color-fill-0);bottom:0;content:"";display:block;left:0;position:absolute;right:0;top:0;z-index:-1}.semi-table-tbody>.semi-table-row>.semi-table-row-cell{border-bottom:1px solid var(--semi-color-border);border-left:none;border-right:none;box-sizing:border-box;display:table-cell;overflow-wrap:break-word;padding:16px;position:relative;vertical-align:middle}.semi-table-tbody>.semi-table-row>.semi-table-row-cell.resizing{border-right:2px solid var(--semi-color-primary)}.semi-table-tbody>.semi-table-row>.semi-table-row-cell-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.semi-table-tbody>.semi-table-row.semi-table-row-expand>.semi-table-row-cell{background-color:var(--semi-color-fill-0)}.semi-table-tbody>.semi-table-row.semi-table-row-hidden{display:none}.semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-left,.semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-right{background-color:var(--semi-color-bg-1);position:-webkit-sticky;position:sticky;z-index:101}.semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-left-last{border-right:1px solid var(--semi-color-border);box-shadow:3px 0 0 0 var(--semi-color-shadow)}.semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-right-first{border-left:1px solid var(--semi-color-border);box-shadow:-3px 0 0 0 var(--semi-color-shadow)}.semi-table-tbody>.semi-table-row>*,.semi-table-tbody>.semi-table-row>.semi-table-cell-fixed>*{transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeOut) 0ms}.semi-table-tbody>.semi-table-row-section{display:table-row}.semi-table-tbody>.semi-table-row-section>.semi-table-row-cell{background-color:rgba(var(--semi-grey-0),1);border-bottom:1px solid var(--semi-color-border)}.semi-table-tbody>.semi-table-row-section>.semi-table-row-cell:not(.semi-table-column-selection){padding:10px 16px}.semi-table-tbody>.semi-table-row-section .semi-table-section-inner{align-items:center;display:inline-flex}.semi-table-virtualized .semi-table-tbody{display:block}.semi-table-virtualized .semi-table-tbody>.semi-table-row{display:flex}.semi-table-virtualized .semi-table-tbody>.semi-table-row>.semi-table-row-cell{word-wrap:inherit;align-items:center;display:inline-flex;overflow:hidden;white-space:nowrap;word-break:inherit}.semi-table-virtualized .semi-table-tbody>.semi-table-row-section>.semi-table-row-cell{display:flex;padding-bottom:16px;padding-top:16px}.semi-table-virtualized .semi-table-tbody>.semi-table-row-expand>.semi-table-row-cell{overflow:visible;overflow:initial;padding:0}.semi-table-footer{background-color:var(--semi-color-fill-0);margin:0;padding:16px;position:relative}.semi-table .semi-table-selection-wrap{display:inline-flex;vertical-align:bottom}.semi-table .semi-table-selection-disabled{cursor:not-allowed}.semi-table .semi-table-selection-disabled>.semi-checkbox{pointer-events:none}.semi-table .semi-table-column-hidden{display:none}.semi-table .semi-table-column-selection{text-align:center}.semi-table .semi-table-column-selection .semi-checkbox-inner-display .semi-icon{left:0;top:0}.semi-table .semi-table-column-expand .semi-table-expand-icon{-webkit-transform:translateY(2px);transform:translateY(2px)}.semi-table .semi-table-column-expand .semi-table-expand-icon:last-child{margin-right:0}.semi-table .semi-table-column-sorter{display:inline-block;height:16px;text-align:center;vertical-align:middle;width:16px}.semi-table .semi-table-column-sorter-wrapper{align-items:center;cursor:pointer;display:flex;gap:4px;overflow:hidden}.semi-table .semi-table-column-sorter-down,.semi-table .semi-table-column-sorter-up{color:var(--semi-color-text-2);display:block;height:0}.semi-table .semi-table-column-sorter-down:hover .anticon,.semi-table .semi-table-column-sorter-up:hover .anticon{color:var(--semi-color-text-2)}.semi-table .semi-table-column-sorter-down svg,.semi-table .semi-table-column-sorter-up svg{height:16px;width:16px}.semi-table .semi-table-column-sorter-down.on .semi-icon-caretdown,.semi-table .semi-table-column-sorter-down.on .semi-icon-caretup,.semi-table .semi-table-column-sorter-up.on .semi-icon-caretdown,.semi-table .semi-table-column-sorter-up.on .semi-icon-caretup{color:var(--semi-color-primary)}.semi-table .semi-table-column-filter{align-items:center;color:var(--semi-color-text-2);cursor:pointer;display:inline-flex;margin-left:4px}.semi-table .semi-table-column-filter svg{height:16px;width:16px}.semi-table .semi-table-column-filter.on{color:var(--semi-color-primary)}.semi-table-bordered .semi-table-title{border-left:1px solid var(--semi-color-border);border-right:1px solid var(--semi-color-border);border-top:1px solid var(--semi-color-border);padding-left:16px;padding-right:16px}.semi-table-bordered .semi-table-container{border:1px solid var(--semi-color-border);border-bottom:0;border-right:0}.semi-table-bordered .semi-table-header::-webkit-scrollbar{border-right:1px solid var(--semi-color-border)}.semi-table-bordered .semi-table-footer{border-bottom:1px solid var(--semi-color-border);border-left:1px solid var(--semi-color-border);border-right:1px solid var(--semi-color-border)}.semi-table-bordered .semi-table-thead>.semi-table-row>.semi-table-row-head .react-resizable-handle{background-color:initial}.semi-table-bordered .semi-table-placeholder,.semi-table-bordered .semi-table-tbody>.semi-table-row>.semi-table-row-cell,.semi-table-bordered .semi-table-thead>.semi-table-row>.semi-table-row-head{border-right:1px solid var(--semi-color-border)}.semi-table-placeholder{background:transparent;border-bottom:1px solid var(--semi-color-border);color:var(--semi-color-text-2);font-size:14px;left:0;padding:16px 12px;position:-webkit-sticky;position:sticky;text-align:center;z-index:1}.semi-table-fixed{min-width:100%;table-layout:fixed}.semi-table-fixed>.semi-table-tbody>.semi-table-row-expand>.semi-table-row-cell>.semi-table-expand-inner,.semi-table-fixed>.semi-table-tbody>.semi-table-row-section>.semi-table-row-cell>.semi-table-section-inner{align-items:center;display:flex;height:100%;left:0;margin-left:-16px;margin-right:-16px;overflow:auto;padding-left:16px;padding-right:16px;position:-webkit-sticky;position:sticky}.semi-table-fixed-header table{table-layout:fixed}.semi-table-scroll-position-left .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-left-last,.semi-table-scroll-position-left .semi-table-thead>.semi-table-row>.semi-table-cell-fixed-left-last,.semi-table-scroll-position-right .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-right-first,.semi-table-scroll-position-right .semi-table-thead>.semi-table-row>.semi-table-cell-fixed-right-first{box-shadow:none}.semi-table-pagination-outer{color:var(--semi-color-text-2);min-height:60px}.semi-table-expand-icon{align-items:center;background:transparent;color:var(--semi-color-text-2);cursor:pointer;display:inline-flex;margin-right:8px;position:relative;-webkit-user-select:none;user-select:none}.semi-table-expand-icon-cell{align-items:center;display:inline-flex;justify-content:center}.semi-table-expand-icon .semi-table-expandedIcon-show{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.semi-table-expand-icon .semi-table-expandedIcon-hide,.semi-table-expand-icon .semi-table-expandedIcon-show{transition:-webkit-transform .15s cubic-bezier(.62,.05,.36,.95);transition:transform .15s cubic-bezier(.62,.05,.36,.95);transition:transform .15s cubic-bezier(.62,.05,.36,.95),-webkit-transform .15s cubic-bezier(.62,.05,.36,.95)}.semi-table-expand-icon .semi-table-expandedIcon-hide{-webkit-transform:rotate(0deg);transform:rotate(0deg)}.semi-table-column-filter-dropdown .semi-dropdown-menu{max-height:290px;overflow-y:auto}.semi-table-wrapper-rtl .semi-table{direction:rtl;text-align:right}.semi-table-wrapper-rtl .semi-table-align-left .semi-table-operate-wrapper{justify-content:flex-end}.semi-table-wrapper-rtl .semi-table-align-right .semi-table-operate-wrapper{justify-content:flex-start}.semi-table-wrapper-rtl .semi-table-thead>.semi-table-row>.semi-table-row-head{text-align:right}.semi-table-wrapper-rtl .semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-left-last{border-left:1px solid var(--semi-color-border);border-right:0}.semi-table-wrapper-rtl .semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-left-last.resizing{border-left:2px solid var(--semi-color-primary)}.semi-table-wrapper-rtl .semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right-first{border-left:0;border-right:1px solid var(--semi-color-border)}.semi-table-wrapper-rtl .semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right-first.resizing{border-left:2px solid var(--semi-color-primary)}.semi-table-wrapper-rtl .semi-table-thead>.semi-table-row>.semi-table-row-head.semi-table-cell-fixed-right-first[x-type=column-scrollbar]{border-right:transparent;box-shadow:none}.semi-table-wrapper-rtl .semi-table-thead>.semi-table-row .resizing{border-left:2px solid var(--semi-color-primary)}.semi-table-wrapper-rtl .semi-table-thead>.semi-table-row .react-resizable-handle{left:-1px;right:auto}.semi-table-wrapper-rtl .semi-table-tbody{display:table-row-group}.semi-table-wrapper-rtl .semi-table-tbody>.semi-table-row>.semi-table-row-cell.resizing{border-left:2px solid var(--semi-color-primary);border-right:0}.semi-table-wrapper-rtl .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-left-last{border-left:1px solid var(--semi-color-border);border-right:0}.semi-table-wrapper-rtl .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-right-first{border-left:0;border-right:1px solid var(--semi-color-border)}.semi-table-wrapper-rtl .semi-table .semi-table-column-selection .semi-checkbox-inner-display .semi-icon{left:auto;right:0}.semi-table-wrapper-rtl .semi-table .semi-table-column-expand .semi-table-expand-icon{-webkit-transform:scaleX(-1) translateY(2px);transform:scaleX(-1) translateY(2px)}.semi-table-wrapper-rtl .semi-table .semi-table-column-expand .semi-table-expand-icon:last-child{margin-left:0;margin-right:auto}.semi-table-wrapper-rtl .semi-table .semi-table-column-filter,.semi-table-wrapper-rtl .semi-table .semi-table-column-sorter{margin-left:0;margin-right:4px}.semi-table-wrapper-rtl .semi-table-bordered .semi-table-container{border-left:0;border-right:1px solid var(--semi-color-border)}.semi-table-wrapper-rtl .semi-table-bordered .semi-table-placeholder,.semi-table-wrapper-rtl .semi-table-bordered .semi-table-tbody>.semi-table-row>.semi-table-row-cell,.semi-table-wrapper-rtl .semi-table-bordered .semi-table-thead>.semi-table-row>.semi-table-row-head{border-left:1px solid var(--semi-color-border);border-right:0}.semi-table-wrapper-rtl .semi-table-bordered .semi-table-header::-webkit-scrollbar{border-left:1px solid var(--semi-color-border);border-right:0}.semi-table-wrapper-rtl .semi-table-fixed>.semi-table-tbody>.semi-table-row-expand>.semi-table-row-cell>.semi-table-expand-inner,.semi-table-wrapper-rtl .semi-table-fixed>.semi-table-tbody>.semi-table-row-section>.semi-table-row-cell>.semi-table-section-inner{left:auto;margin-left:-16px;margin-right:-16px;padding-left:16px;padding-right:16px;right:0}.semi-table-wrapper-rtl .semi-table-scroll-position-left .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-left-last,.semi-table-wrapper-rtl .semi-table-scroll-position-left .semi-table-thead>.semi-table-row>.semi-table-cell-fixed-left-last{box-shadow:3px 0 0 0 var(--semi-color-shadow)}.semi-table-wrapper-rtl .semi-table-scroll-position-left .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-right-first,.semi-table-wrapper-rtl .semi-table-scroll-position-left .semi-table-thead>.semi-table-row>.semi-table-cell-fixed-right-first,.semi-table-wrapper-rtl .semi-table-scroll-position-right .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-left-last,.semi-table-wrapper-rtl .semi-table-scroll-position-right .semi-table-thead>.semi-table-row>.semi-table-cell-fixed-left-last{box-shadow:none}.semi-table-wrapper-rtl .semi-table-scroll-position-right .semi-table-tbody>.semi-table-row>.semi-table-cell-fixed-right-first,.semi-table-wrapper-rtl .semi-table-scroll-position-right .semi-table-thead>.semi-table-row>.semi-table-cell-fixed-right-first{box-shadow:-3px 0 0 0 var(--semi-color-shadow)}.semi-table-wrapper-rtl .semi-table-expand-icon{margin-left:8px;margin-right:auto;-webkit-transform:scaleX(-1) translateY(2px);transform:scaleX(-1) translateY(2px)}.semi-table-wrapper-rtl .semi-spin{direction:rtl}.semi-checkbox{align-items:flex-start;box-sizing:border-box;-webkit-column-gap:8px;column-gap:8px;cursor:pointer;display:flex;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;position:relative;-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-checkbox input[type=checkbox]{height:100%;left:0;margin:0;opacity:0;position:absolute;top:0;width:100%}.semi-checkbox-content{display:flex;flex:1 1;flex-direction:column;row-gap:4px}.semi-checkbox-addon{align-items:center;color:var(--semi-color-text-0);display:flex;flex:1 1;line-height:20px;-webkit-user-select:none;user-select:none}.semi-checkbox:hover .semi-checkbox-inner-display{box-shadow:inset 0 0 0 1px var(--semi-color-focus-border)}.semi-checkbox:active .semi-checkbox-inner-checked .semi-checkbox-inner-display,.semi-checkbox:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display{box-shadow:none}.semi-checkbox.semi-checkbox-disabled:active .semi-checkbox-inner-display,.semi-checkbox.semi-checkbox-disabled:hover .semi-checkbox-inner-display{background:var(--semi-color-disabled-fill);box-shadow:inset 0 0 0 1px var(--semi-color-border)}.semi-checkbox.semi-checkbox-disabled:active .semi-checkbox-inner-checked .semi-checkbox-inner-display,.semi-checkbox.semi-checkbox-disabled:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display{background:var(--semi-color-primary-disabled);box-shadow:none}.semi-checkbox-inner{align-items:center;cursor:pointer;display:flex;height:20px;position:relative;-webkit-user-select:none;user-select:none;width:16px}.semi-checkbox-inner-display{background:transparent;border-radius:var(--semi-border-radius-extra-small);box-shadow:inset 0 0 0 1px var(--semi-color-text-3);box-sizing:border-box;height:16px;margin:0;position:relative;transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);width:16px}.semi-checkbox-inner-display .semi-icon{font-size:16px}.semi-checkbox-inner-checked .semi-checkbox-inner-display{background:var(--semi-color-primary);border-radius:var(--semi-border-radius-extra-small);box-shadow:inset 0 0 0 1px var(--semi-color-primary);color:var(--semi-color-white)}.semi-checkbox-inner-checked>.semi-checkbox-addon{color:var(--semi-color-text-0)}.semi-checkbox:hover .semi-checkbox-inner-display{background:var(--semi-color-fill-0)}.semi-checkbox:hover.semi-checkbox-indeterminate .semi-checkbox-inner-display{background:var(--semi-color-primary-hover);box-shadow:none;color:var(--semi-color-white)}.semi-checkbox:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display{background:var(--semi-color-primary-hover);border-color:var(--semi-color-primary-hover);color:var(--semi-color-white)}.semi-checkbox:hover.semi-checkbox-cardType.semi-checkbox-unChecked.semi-checkbox-cardType_unDisabled .semi-checkbox-inner-display{background:var(--semi-color-white)}.semi-checkbox:active .semi-checkbox-inner-display{background:var(--semi-color-fill-1)}.semi-checkbox:active.semi-checkbox-indeterminate .semi-checkbox-inner-display{box-shadow:none}.semi-checkbox:active .semi-checkbox-inner-checked .semi-checkbox-inner-display,.semi-checkbox:active.semi-checkbox-indeterminate .semi-checkbox-inner-display{background:var(--semi-color-primary-active);border-color:var(--semi-color-primary-active);color:var(--semi-color-white)}.semi-checkbox:active.semi-checkbox-cardType.semi-checkbox-unChecked.semi-checkbox-cardType_unDisabled .semi-checkbox-inner-display{background:var(--semi-color-white)}.semi-checkbox-cardType{align-items:flex-start;background:transparent;border:1px solid transparent;border-radius:3px;flex-wrap:nowrap;padding:12px 16px}.semi-checkbox-cardType .semi-checkbox-inner{flex-shrink:0;position:relative}.semi-checkbox-cardType .semi-checkbox-inner-display{background:var(--semi-color-white)}.semi-checkbox-cardType .semi-checkbox-inner-pureCardType{opacity:0;width:0}.semi-checkbox-cardType .semi-checkbox-addon{color:var(--semi-color-text-0);font-size:14px;font-weight:600;line-height:20px}.semi-checkbox-cardType .semi-checkbox-extra{color:var(--semi-color-text-2);font-size:14px;font-weight:400;line-height:20px}.semi-checkbox-cardType .semi-checkbox-extra.semi-checkbox-cardType_extra_noChildren{margin-top:0}.semi-checkbox-cardType:hover{background:var(--semi-color-fill-0)}.semi-checkbox-cardType:active{background:var(--semi-color-fill-1)}.semi-checkbox-cardType_checked{background:var(--semi-color-primary-light-default);border:1px solid var(--semi-color-primary)}.semi-checkbox-cardType_checked:hover{background:var(--semi-color-primary-light-default);border-color:var(--semi-color-primary-hover)}.semi-checkbox-cardType_checked:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display{box-shadow:none}.semi-checkbox-cardType_checked:active{background:var(--semi-color-primary-light-default);border-color:var(--semi-color-primary-active)}.semi-checkbox-cardType_disabled:active,.semi-checkbox-cardType_disabled:hover{background:transparent}.semi-checkbox-cardType_checked_disabled.semi-checkbox-cardType{background:var(--semi-color-primary-light-default);border:1px solid var(--semi-color-primary-disabled)}.semi-checkbox-cardType_checked_disabled.semi-checkbox-cardType:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display{box-shadow:none}.semi-checkbox-checked .semi-checkbox-inner-display,.semi-checkbox-indeterminate .semi-checkbox-inner-display{background:var(--semi-color-primary);border-radius:var(--semi-border-radius-extra-small);box-shadow:inset 0 0 0 1px var(--semi-color-primary);color:var(--semi-color-white)}.semi-checkbox-checked .semi-checkbox-inner-display:hover,.semi-checkbox-indeterminate .semi-checkbox-inner-display:hover{background:var(--semi-color-primary-hover);border-color:var(--semi-color-primary-hover);color:var(--semi-color-white)}.semi-checkbox-checked .semi-checkbox-inner-display:active,.semi-checkbox-indeterminate .semi-checkbox-inner-display:active{background:var(--semi-color-primary-active);border-color:var(--semi-color-primary-active);color:var(--semi-color-white)}.semi-checkbox-checked .semi-checkbox-inner-addon,.semi-checkbox-indeterminate .semi-checkbox-inner-addon{color:var(--semi-color-text-0)}.semi-checkbox-disabled,.semi-checkbox-disabled .semi-checkbox-inner{cursor:not-allowed}.semi-checkbox-disabled .semi-checkbox-inner-display{background:var(--semi-color-disabled-fill);box-shadow:inset 0 0 0 1px var(--semi-color-border);color:var(--semi-color-white)}.semi-checkbox-disabled .semi-checkbox-inner-display:hover{background:transparent;color:var(--semi-color-white)}.semi-checkbox-disabled .semi-checkbox-inner-checked{color:var(--semi-color-white)}.semi-checkbox-disabled .semi-checkbox-inner-checked .semi-checkbox-inner-display{background:var(--semi-color-primary-disabled);box-shadow:inset 0 0 0 1px var(--semi-color-primary-disabled);opacity:.75}.semi-checkbox-disabled .semi-checkbox-inner-checked .semi-checkbox-inner-display:hover{background:var(--semi-color-primary-disabled);color:var(--semi-color-white)}.semi-checkbox-disabled .semi-checkbox-addon,.semi-checkbox-disabled .semi-checkbox-extra{color:var(--semi-color-disabled-text)}.semi-checkbox.semi-checkbox-disabled.semi-checkbox-indeterminate .semi-checkbox-inner-display{background:var(--semi-color-primary-disabled);box-shadow:inset 0 0 0 1px var(--semi-color-primary-disabled);color:var(--semi-color-white);opacity:.75}.semi-checkbox-extra{box-sizing:border-box;color:var(--semi-color-text-2);flex-basis:100%;flex-grow:1;flex-shrink:0}.semi-checkbox-focus{outline:2px solid var(--semi-color-primary-light-active)}.semi-checkbox-focus-border{box-shadow:inset 0 0 0 1px var(--semi-color-focus-border)}.semi-checkboxGroup{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;line-height:14px}.semi-checkboxGroup .semi-checkbox.semi-checkbox-vertical{margin-bottom:16px}.semi-checkboxGroup-horizontal{display:flex;flex-wrap:wrap;gap:16px}.semi-checkboxGroup-horizontal .semi-checkbox{display:inline-flex}.semi-checkboxGroup-vertical{display:flex;flex-direction:column;row-gap:12px}.semi-checkboxGroup-vertical-cardType{row-gap:16px}.semi-checkboxGroup-vertical-pureCardType .semi-checkbox{-webkit-column-gap:0;column-gap:0}.semi-portal-rtl .semi-checkbox,.semi-rtl .semi-checkbox{direction:rtl}.semi-portal-rtl .semi-checkbox input[type=checkbox],.semi-rtl .semi-checkbox input[type=checkbox]{left:auto;right:0}.semi-portal-rtl .semi-checkboxGroup,.semi-rtl .semi-checkboxGroup{direction:rtl}.semi-radio{box-sizing:border-box;-webkit-column-gap:8px;column-gap:8px;cursor:pointer;display:inline-flex;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;min-height:20px;min-width:16px;position:relative;text-align:left;transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);vertical-align:bottom}.semi-radio.semi-radio-vertical{display:block}.semi-radio input[type=checkbox],.semi-radio input[type=radio]{cursor:pointer;height:100%;left:0;margin:0;opacity:0;position:absolute;top:0;width:100%}.semi-radio:hover .semi-radio-inner-display{border:1px solid var(--semi-color-focus-border)}.semi-radio:hover.semi-radio-cardRadioGroup .semi-radio-inner-display{background:var(--semi-color-white)}.semi-radio:hover .semi-radio-inner-checked .semi-radio-inner-display{background:var(--semi-color-primary-hover);border-color:var(--semi-color-primary-hover)}.semi-radio:active.semi-radio-cardRadioGroup .semi-radio-inner-display{background:var(--semi-color-white)}.semi-radio:active .semi-radio-inner-checked .semi-radio-inner-display{background:var(--semi-color-primary-active);border-color:var(--semi-color-primary-active)}.semi-radio-buttonRadioComponent{background:var(--semi-color-fill-0);border-radius:var(--semi-border-radius-small);padding:4px}.semi-radio-buttonRadioGroup{border-radius:var(--semi-border-radius-small);line-height:16px;padding:4px;position:relative}.semi-radio-buttonRadioGroup:not(:last-child){padding-right:0}.semi-radio-buttonRadioGroup-small{line-height:16px;padding:2px 4px}.semi-radio-buttonRadioGroup-large{line-height:20px;padding:4px}.semi-radio-cardRadioGroup{background:transparent;border:1px solid transparent;border-radius:var(--semi-border-radius-small);flex-wrap:nowrap;padding:12px 16px;transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-radio-cardRadioGroup .semi-radio-inner{flex-shrink:0}.semi-radio-cardRadioGroup .semi-radio-inner-display{background:var(--semi-color-white)}.semi-radio-cardRadioGroup .semi-radio-addon{color:var(--semi-color-text-0);font-size:14px;font-weight:600;line-height:20px}.semi-radio-cardRadioGroup .semi-radio-extra{color:var(--semi-color-text-2);font-size:14px;font-weight:400;line-height:20px;padding-left:0}.semi-radio-cardRadioGroup:active{background:var(--semi-color-fill-1)}.semi-radio-cardRadioGroup_checked{background:var(--semi-color-primary-light-default);border:1px solid var(--semi-color-primary)}.semi-radio-cardRadioGroup_checked:hover{border:1px solid var(--semi-color-primary-hover)}.semi-radio-cardRadioGroup_checked:hover .semi-radio-inner-checked .semi-radio-inner-display{border-color:var(--semi-color-primary-hover)}.semi-radio-cardRadioGroup_checked:active{background:var(--semi-color-primary-light-default);border:1px solid var(--semi-color-primary-active)}.semi-radio-cardRadioGroup_checked:active .semi-radio-inner-checked .semi-radio-inner-display{border-color:var(--semi-color-primary-active)}.semi-radio-cardRadioGroup_checked:active .semi-radio-inner-checked:hover .semi-radio-inner-display{background:var(--semi-color-primary-active)}.semi-radio-cardRadioGroup_hover{background:var(--semi-color-fill-0)}.semi-radio-cardRadioGroup_disabled:active{background:transparent}.semi-radio-cardRadioGroup_checked_disabled.semi-radio-cardRadioGroup{background:var(--semi-color-primary-light-default);border:1px solid var(--semi-color-primary-disabled)}.semi-radio-cardRadioGroup_checked_disabled.semi-radio-cardRadioGroup .semi-radio-inner-checked .semi-radio-inner-display,.semi-radio-cardRadioGroup_checked_disabled.semi-radio-cardRadioGroup:hover .semi-radio-inner-checked .semi-radio-inner-display{border-color:var(--semi-color-primary-disabled)}.semi-radio.semi-radio-disabled:active .semi-radio-inner-display,.semi-radio.semi-radio-disabled:hover .semi-radio-inner-display{background:var(--semi-color-disabled-fill);border:1px solid var(--semi-color-border)}.semi-radio.semi-radio-disabled:active .semi-radio-inner-checked .semi-radio-inner-display,.semi-radio.semi-radio-disabled:hover .semi-radio-inner-checked .semi-radio-inner-display{background:var(--semi-color-primary-disabled);border-color:var(--semi-color-primary-disabled)}.semi-radio-inner{display:inline-flex;height:16px;margin-top:2px;position:relative;-webkit-user-select:none;user-select:none;vertical-align:sub;width:16px}.semi-radio-inner-display{align-items:center;background:transparent;border:1px solid var(--semi-color-text-3);border-radius:16px;box-sizing:border-box;display:inline-flex;height:16px;justify-content:center;transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);width:16px}.semi-radio-inner-display .semi-icon{font-size:14px;height:100%;width:100%}.semi-radio-content{display:flex;flex-direction:column;row-gap:4px}.semi-radio:hover .semi-radio-inner-display{background:var(--semi-color-fill-0)}.semi-radio:active .semi-radio-inner-display{background:var(--semi-color-fill-1)}.semi-radio-addon{align-items:center;color:var(--semi-color-text-0);display:inline-flex;-webkit-user-select:none;user-select:none}.semi-radio-addon-buttonRadio{border-radius:var(--semi-border-radius-small);color:var(--semi-color-text-1);font-size:12px;font-weight:600;padding:4px 16px;text-align:center;transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-radio-addon-buttonRadio-hover{background:var(--semi-color-fill-1);font-weight:600}.semi-radio-addon-buttonRadio-checked{background:var(--semi-color-bg-3);color:var(--semi-color-primary);font-weight:600}.semi-radio-addon-buttonRadio-disabled{color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-radio-addon-buttonRadio-small{font-size:12px;padding:2px 16px}.semi-radio-addon-buttonRadio-large{font-size:14px;padding:6px 24px}.semi-radio .semi-radio-inner-checked:hover .semi-radio-inner-display{background:var(--semi-color-primary-hover)}.semi-radio .semi-radio-inner-checked:active .semi-radio-inner-display{background:var(--semi-color-primary-active)}.semi-radio .semi-radio-inner-checked .semi-radio-inner-display{background:var(--semi-color-primary);border:1px solid var(--semi-color-primary);border-radius:16px;color:rgba(var(--semi-white),1)}.semi-radio .semi-radio-inner-checked>.semi-radio-addon{color:var(--semi-color-text-0)}.semi-radio .semi-radio-inner-buttonRadio,.semi-radio .semi-radio-inner-pureCardRadio{height:100%;left:0;margin-top:0;opacity:0;position:absolute;top:0;width:100%;z-index:-1}.semi-radio-disabled,.semi-radio-disabled .semi-radio-inner,.semi-radio-disabled:hover{cursor:not-allowed}.semi-radio-disabled .semi-radio-inner-display{background:var(--semi-color-disabled-fill);border-color:var(--semi-color-border);opacity:.75}.semi-radio-disabled .semi-radio-inner-display:hover{background:transparent}.semi-radio-disabled .semi-radio-inner-checked .semi-radio-inner-display,.semi-radio-disabled .semi-radio-inner-checked .semi-radio-inner-display:hover{background:var(--semi-color-primary-disabled);border-color:var(--semi-color-primary-disabled)}.semi-radio-disabled .semi-radio-addon,.semi-radio-disabled .semi-radio-extra{color:var(--semi-color-disabled-text)}.semi-radio-extra{box-sizing:border-box;color:var(--semi-color-text-2)}.semi-radio-focus{outline:2px solid var(--semi-color-primary-light-active)}.semi-radio-focus-border{border:1px solid var(--semi-color-focus-border)}.semi-radioGroup{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px}.semi-radioGroup-vertical{display:flex;flex-direction:column;row-gap:12px}.semi-radioGroup-vertical-card .semi-radio,.semi-radioGroup-vertical-default .semi-radio{display:flex}.semi-radioGroup-horizontal{display:inline-flex;flex-wrap:wrap;gap:16px;vertical-align:bottom}.semi-radioGroup-buttonRadio{background:var(--semi-color-fill-0);border-radius:var(--semi-border-radius-small);display:inline-block;vertical-align:middle}.semi-portal-rtl .semi-radio,.semi-rtl .semi-radio{direction:rtl}.semi-portal-rtl .semi-radio input[type=checkbox],.semi-portal-rtl .semi-radio input[type=radio],.semi-rtl .semi-radio input[type=checkbox],.semi-rtl .semi-radio input[type=radio]{left:auto;right:0}.semi-portal-rtl .semi-radio-buttonRadioGroup:not(:last-child),.semi-rtl .semi-radio-buttonRadioGroup:not(:last-child){padding-left:0}.semi-portal-rtl .semi-radioGroup,.semi-rtl .semi-radioGroup{direction:rtl}.semi-page{-webkit-margin-before:0;-webkit-margin-after:0;align-items:center;display:flex;list-style:none;margin-block-end:0;margin-block-start:0}.semi-page,.semi-page-small{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;padding:0}.semi-page-small{color:var(--semi-color-text-2);font-size:14px;font-weight:400;line-height:20px}.semi-page-disabled{cursor:not-allowed}.semi-page-disabled .semi-page-total{color:var(--semi-color-disabled-text)}.semi-page-item{align-items:center;border:0 solid transparent;border-radius:var(--semi-border-radius-small);color:var(--semi-color-text-0);cursor:pointer;display:flex;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;height:32px;justify-content:center;line-height:20px;line-height:32px;margin-left:4px;margin-right:4px;min-width:32px;text-align:center;-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);-webkit-user-select:none;user-select:none}.semi-page-item:hover{border-color:transparent}.semi-page-item-rest-opening,.semi-page-item:hover{background-color:var(--semi-color-fill-0);color:var(--semi-color-text-0)}.semi-page-item:active{background-color:var(--semi-color-fill-1);border-color:transparent;color:var(--semi-color-text-0)}.semi-page-item-active{font-weight:600}.semi-page-item-active,.semi-page-item-active:hover{background-color:var(--semi-color-primary-light-default);border-color:transparent;color:var(--semi-color-primary)}.semi-page-item-disabled{background-color:initial;border-color:transparent;color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-page-item-disabled:hover{background-color:initial}.semi-page-item-small{margin:0;min-width:44px}.semi-page-item-all-disabled{border-color:transparent;cursor:not-allowed}.semi-page-item-all-disabled,.semi-page-item-all-disabled:hover{background-color:initial;color:var(--semi-color-disabled-text)}.semi-page-item-all-disabled-active{font-weight:600}.semi-page-item-all-disabled-active,.semi-page-item-all-disabled-active:hover{background-color:var(--semi-color-disabled-fill)}.semi-page-total{color:var(--semi-color-text-2);font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px}.semi-page-next,.semi-page-prev{color:var(--semi-color-tertiary);cursor:pointer}.semi-page-next.semi-page-item-disabled,.semi-page-prev.semi-page-item-disabled{color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-page-quickjump{align-items:center;color:var(--semi-color-text-0);display:flex;flex-shrink:0;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;justify-content:center;line-height:20px;margin-left:24px}.semi-page-quickjump-input-number{margin-left:4px;margin-right:4px;max-width:50px}.semi-page-quickjump-disabled{color:var(--semi-color-disabled-text)}.semi-page .semi-select,.semi-select-dropdown{-webkit-user-select:none;user-select:none}.semi-page-rest-list{padding-bottom:4px;padding-top:4px}.semi-page-rest-list>div{position:relative}.semi-page-rest-item{box-sizing:border-box;cursor:pointer;display:flex;height:32px;justify-content:center;line-height:32px}.semi-page-rest-item:hover{background-color:var(--semi-color-fill-0)}.semi-page-rest-item:active{background-color:var(--semi-color-fill-1)}.semi-portal-rtl .semi-page,.semi-rtl .semi-page{direction:rtl}.semi-portal-rtl .semi-page-item,.semi-rtl .semi-page-item{margin-left:4px;margin-right:4px}.semi-portal-rtl .semi-page-next,.semi-portal-rtl .semi-page-prev,.semi-rtl .semi-page-next,.semi-rtl .semi-page-prev{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.semi-select-option{align-items:center;border-radius:0;box-sizing:border-box;color:var(--semi-color-text-0);cursor:pointer;display:flex;flex-wrap:nowrap;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px;padding:8px 12px;position:relative;transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);word-break:break-all}.semi-select-option-icon{align-content:center;color:transparent;display:flex;justify-content:center;margin-right:8px;width:12px}.semi-select-option-text{display:flex;flex-wrap:wrap;white-space:pre}.semi-select-option-keyword{background-color:inherit;color:var(--semi-color-primary);font-weight:600}.semi-select-option:active{background-color:var(--semi-color-fill-1)}.semi-select-option-empty{color:var(--semi-color-disabled-text);cursor:not-allowed;justify-content:center}.semi-select-option-empty:active,.semi-select-option-empty:hover{background-color:inherit}.semi-select-option-disabled{color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-select-option-disabled:hover{background-color:var(--semi-color-fill-0)}.semi-select-option-selected{background:transparent;font-weight:600}.semi-select-option-selected .semi-select-option-icon{color:var(--semi-color-text-2)}.semi-select,.semi-select-option-focused{background-color:var(--semi-color-fill-0)}.semi-select{border:1px solid transparent;border-radius:var(--semi-border-radius-small);box-sizing:border-box;cursor:pointer;display:inline-flex;font-weight:400;height:32px;max-height:300px;outline:none;overflow-y:auto;position:relative;-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);vertical-align:middle}.semi-select:hover{background-color:var(--semi-color-fill-1);border:1px solid transparent}.semi-select:focus{background-color:var(--semi-color-fill-0);border:1px solid var(--semi-color-focus-border);outline:0}.semi-select:active{background-color:var(--semi-color-fill-2)}.semi-select-small{height:24px;line-height:24px}.semi-select-large{line-height:40px;min-height:40px}.semi-select-large .semi-select-selection{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:16px;line-height:22px}.semi-select-focus,.semi-select-open{border:1px solid var(--semi-color-focus-border);outline:0}.semi-select-focus:hover,.semi-select-open:hover{background-color:var(--semi-color-fill-0);border:1px solid var(--semi-color-focus-border)}.semi-select-focus:active,.semi-select-open:active{background-color:var(--semi-color-fill-2);border:1px solid var(--semi-color-focus-border)}.semi-select-warning{background-color:var(--semi-color-warning-light-default);border-color:var(--semi-color-warning-light-default)}.semi-select-warning:hover{background-color:var(--semi-color-warning-light-hover);border-color:var(--semi-color-warning-light-hover)}.semi-select-warning:focus{background-color:var(--semi-color-warning-light-default);border-color:var(--semi-color-warning)}.semi-select-warning:active{background-color:var(--semi-color-warning-light-active);border-color:var(--semi-color-warning-light-active)}.semi-select-error{background-color:var(--semi-color-danger-light-default);border-color:var(--semi-color-danger-light-default)}.semi-select-error:hover{background-color:var(--semi-color-danger-light-hover);border-color:var(--semi-color-danger-light-hover)}.semi-select-error:focus{background-color:var(--semi-color-danger-light-default);border-color:var(--semi-color-danger)}.semi-select-error:active{background-color:var(--semi-color-danger-light-active);border-color:var(--semi-color-danger-light-active)}.semi-select-disabled{cursor:not-allowed}.semi-select-disabled,.semi-select-disabled:hover{background-color:var(--semi-color-disabled-fill)}.semi-select-disabled:focus{border:1px solid transparent}.semi-select-disabled .semi-select-selection,.semi-select-disabled .semi-select-selection-placeholder{color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-select-disabled .semi-select-arrow,.semi-select-disabled .semi-select-prefix,.semi-select-disabled .semi-select-suffix,.semi-select-disabled .semi-tag{color:var(--semi-color-disabled-text)}.semi-select-disabled .semi-tag{background-color:initial}.semi-select-selection{align-items:center;color:var(--semi-color-text-0);cursor:pointer;display:flex;flex-grow:1;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;height:100%;line-height:20px;margin-left:12px;overflow:hidden}.semi-select-selection-text{overflow:hidden;text-overflow:ellipsis;width:100%}.semi-select-selection-text-inactive{display:flex;opacity:.4}.semi-select-selection-text-hide{display:none}.semi-select-selection-placeholder{color:var(--semi-color-text-2)}.semi-select-selection .semi-tag{margin-bottom:1px;margin-right:4px;margin-top:1px}.semi-select-selection .semi-tag:first-of-type{margin-left:0}.semi-select-selection .semi-tag-group{height:inherit}.semi-select-selection .semi-tag-group .semi-tag{margin-bottom:1px;margin-right:4px;margin-top:1px}.semi-select-content-wrapper{align-items:center;display:flex;height:100%;overflow:hidden;white-space:nowrap}.semi-select-content-wrapper-collapse{display:inline-flex;flex-shrink:0;width:100%}.semi-select-content-wrapper-collapse .semi-overflow-list-overflow{max-width:100%;min-width:50px}.semi-select-content-wrapper-collapse>.semi-select-content-wrapper-collapse-tag{background-color:initial}.semi-select-content-wrapper-collapse>.semi-select-content-wrapper-collapse-N{background-color:initial;color:var(--semi-color-text-0);font-size:12px;padding:4px}.semi-select-multiple{height:auto}.semi-select-multiple .semi-select-selection{margin-left:4px}.semi-select-multiple .semi-select-content-wrapper{flex-wrap:wrap;min-height:30px;width:100%}.semi-select-multiple .semi-select-content-wrapper-empty{margin-left:8px}.semi-select-multiple .semi-select-content-wrapper .semi-tag-group{align-items:center;display:flex}.semi-select-multiple .semi-select-content-wrapper-one-line{flex-wrap:nowrap}.semi-select-multiple .semi-select-content-wrapper-one-line .semi-tag-group{flex-shrink:0;flex-wrap:nowrap;justify-content:flex-start;overflow:hidden}.semi-select-multiple .semi-select-inline-label-wrapper{flex-shrink:0}.semi-select-multiple.semi-select-large .semi-select-content-wrapper{min-height:38px}.semi-select-multiple.semi-select-small .semi-select-content-wrapper{min-height:22px}.semi-select-arrow{align-items:center;color:var(--semi-color-text-2);display:flex;flex-shrink:0;justify-content:center;-webkit-transform:rotate(var(--semi-transform-rotate-none));transform:rotate(var(--semi-transform-rotate-none));width:32px}.semi-select-arrow-empty{display:flex;width:12px}.semi-select-prefix,.semi-select-suffix{align-items:center;display:flex;justify-content:center}.semi-select-prefix-text,.semi-select-suffix-text{margin:0 12px}.semi-select-prefix-icon,.semi-select-suffix-icon{color:var(--semi-color-text-2);margin:0 8px}.semi-select-clear,.semi-select-suffix{align-items:center;display:flex;justify-content:center}.semi-select-clear{color:var(--semi-color-text-2);flex-shrink:0;width:32px}.semi-select-clear:hover{color:var(--semi-color-primary)}.semi-select-inset-label-wrapper{display:inline}.semi-select-inset-label{color:var(--semi-color-text-2);flex-shrink:0;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;font-weight:600;line-height:20px;margin-right:12px;white-space:nowrap}.semi-select-create-tips{color:var(--semi-color-text-2);margin-right:4px}.semi-select-with-prefix .semi-select-selection{margin-left:0}.semi-select-single.semi-select-filterable .semi-select-content-wrapper{flex-grow:1;height:100%;overflow:hidden;position:relative}.semi-select-single.semi-select-filterable .semi-input-wrapper{background-color:initial;border:none;height:100%;left:0;position:absolute;top:0;width:100%}.semi-select-single.semi-select-filterable .semi-input-wrapper-focus{border:none}.semi-select-single.semi-select-filterable .semi-input{height:100%;padding-left:0;padding-right:0}.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper{flex-grow:1;height:100%;overflow:hidden;position:relative}.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper{height:24px;line-height:24px}.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper .semi-input-default{height:24px}.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper-empty .semi-input-wrapper{height:100%;left:0;position:absolute;top:0}.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper-empty .semi-input-wrapper .semi-input-default{height:100%}.semi-select-multiple.semi-select-filterable .semi-input-wrapper{background-color:initial;border:none;height:100%;width:100%}.semi-select-multiple.semi-select-filterable .semi-input-wrapper-focus{border:none}.semi-select-multiple.semi-select-filterable .semi-input{padding-left:0;padding-right:0}.semi-select-multiple.semi-select-filterable.semi-select-large .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper-large{height:24px;line-height:24px}.semi-select-multiple.semi-select-filterable.semi-select-large .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper-large .semi-input-large{height:24px}.semi-select-multiple.semi-select-filterable.semi-select-small .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper{height:20px;line-height:20px}.semi-select-multiple.semi-select-filterable.semi-select-small .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper .semi-input-small{height:20px}.semi-select-option-list-wrapper{padding:4px 0}.semi-select-option-list{overflow-x:hidden;overflow-y:auto}.semi-select-option-list-chosen .semi-select-option-icon{display:flex}.semi-select-group{color:var(--semi-color-text-2);cursor:default;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:12px;line-height:16px;margin-top:4px;padding:12px 16px 4px 32px}.semi-select-group:not(:first-of-type){border-top:1px solid var(--semi-color-border)}.semi-select-loading-wrapper{box-sizing:initial;cursor:not-allowed;height:20px;padding:8px 16px}.semi-select-borderless:not(:focus-within):not(:hover){background-color:initial;border-color:transparent}.semi-select-borderless:not(:focus-within):not(:hover) .semi-select-arrow{opacity:0}.semi-select-borderless:focus-within:not(:active){background-color:initial}.semi-select-borderless.semi-select-error:not(:focus-within){border-color:var(--semi-color-danger)}.semi-select-borderless.semi-select-warning:not(:focus-within){border-color:var(--semi-color-warning)}.semi-select-borderless.semi-select-error:focus-within{border-color:var(--semi-color-danger)}.semi-select-borderless.semi-select-warning:focus-within{border-color:var(--semi-color-warning)}.semi-select-dropdown-search-wrapper{border-bottom:1px solid transparent;padding:8px 12px}.semi-portal-rtl .semi-select,.semi-rtl .semi-select{direction:rtl}.semi-portal-rtl .semi-select-selection,.semi-rtl .semi-select-selection{margin-left:0;margin-right:12px}.semi-portal-rtl .semi-select-selection .semi-tag:first-of-type,.semi-rtl .semi-select-selection .semi-tag:first-of-type{margin-right:0}.semi-portal-rtl .semi-select-selection .semi-tag-group .semi-tag,.semi-rtl .semi-select-selection .semi-tag-group .semi-tag{margin-left:4px;margin-right:0}.semi-portal-rtl .semi-select-multiple .semi-select-selection,.semi-rtl .semi-select-multiple .semi-select-selection{margin-left:0;margin-right:4px}.semi-portal-rtl .semi-select-multiple .semi-select-content-wrapper-empty,.semi-rtl .semi-select-multiple .semi-select-content-wrapper-empty{margin-left:0;margin-right:8px}.semi-portal-rtl .semi-select-inset-label,.semi-rtl .semi-select-inset-label{margin-left:12px}.semi-portal-rtl .semi-select-create-tips,.semi-rtl .semi-select-create-tips{margin-left:4px;margin-right:0}.semi-portal-rtl .semi-select-with-prefix .semi-select-selection,.semi-rtl .semi-select-with-prefix .semi-select-selection{margin-left:auto;margin-right:0}.semi-portal-rtl .semi-select-multiple.semi-select-filterable .semi-select-content-wrapper-empty .semi-input-wrapper,.semi-portal-rtl .semi-select-single.semi-select-filterable .semi-input-wrapper,.semi-rtl .semi-select-multiple.semi-select-filterable .semi-select-content-wrapper-empty .semi-input-wrapper,.semi-rtl .semi-select-single.semi-select-filterable .semi-input-wrapper{left:auto;right:0}.semi-portal-rtl .semi-select-group,.semi-rtl .semi-select-group{padding-left:32px;padding-right:16px}.semi-portal-rtl .semi-select-option-icon,.semi-rtl .semi-select-option-icon{margin-left:8px;margin-right:0}.semi-tag{align-items:center;background-color:initial;border-radius:var(--semi-border-radius-small);box-sizing:border-box;display:flex;display:inline-flex;justify-content:center;overflow:hidden;position:relative;-webkit-user-select:none;user-select:none;vertical-align:bottom;white-space:nowrap}.semi-tag-default,.semi-tag-small{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:12px;height:20px;line-height:16px;padding:2px 8px}.semi-tag-default:focus-visible,.semi-tag-small:focus-visible{outline:2px solid var(--semi-color-primary-light-active)}.semi-tag-square{border-radius:var(--semi-border-radius-small)}.semi-tag-circle{border-radius:var(--semi-border-radius-full)}.semi-tag-large{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:12px;height:24px;line-height:16px;padding:4px 8px}.semi-tag-large:focus-visible{outline:2px solid var(--semi-color-primary-light-active)}.semi-tag-invisible{display:none}.semi-tag-prefix-icon{display:flex;padding-right:4px}.semi-tag-suffix-icon{display:flex;padding-left:4px}.semi-tag-content{flex:1 1}.semi-tag-content-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.semi-tag-content-center{height:100%;min-width:0}.semi-tag-close,.semi-tag-content-center{align-items:center;display:flex;justify-content:center}.semi-tag-close{color:var(--semi-color-text-2);cursor:pointer;padding-left:4px}.semi-tag-close:hover{color:var(--semi-color-text-1)}.semi-tag-close:active{color:var(--semi-color-text-0)}.semi-tag-closable{padding:4px 4px 4px 8px}.semi-tag-avatar-circle .semi-avatar,.semi-tag-avatar-square .semi-avatar{margin-right:4px}.semi-tag-avatar-square{padding:0 4px 0 0}.semi-tag-avatar-square .semi-avatar>img{background-color:var(--semi-color-default)}.semi-tag-avatar-circle{padding:2px 4px 2px 2px}.semi-tag-avatar-square.semi-tag-default .semi-avatar,.semi-tag-avatar-square.semi-tag-small .semi-avatar{height:20px;width:20px}.semi-tag-avatar-square.semi-tag-large .semi-avatar{height:24px;width:24px}.semi-tag-avatar-circle.semi-tag-default,.semi-tag-avatar-circle.semi-tag-small{border-radius:11px}.semi-tag-avatar-circle.semi-tag-default .semi-avatar,.semi-tag-avatar-circle.semi-tag-small .semi-avatar{height:16px;width:16px}.semi-tag-avatar-circle.semi-tag-large{border-radius:13px}.semi-tag-avatar-circle.semi-tag-large .semi-avatar{height:20px;width:20px}.semi-tag-group{display:block;height:auto}.semi-tag-group .semi-tag{margin-bottom:0;margin-right:8px}.semi-tag-group-max.semi-tag-group-small{height:22px}.semi-tag-group-max.semi-tag-group-large{height:26px}.semi-tag-rest-group-popover .semi-tag{margin-bottom:0;margin-right:8px}.semi-tag-rest-group-popover .semi-tag:last-of-type{margin-right:0}.semi-tag-amber-ghost{background-color:initial;border:1px solid rgba(var(--semi-amber-4),1);color:rgba(var(--semi-amber-5),1)}.semi-tag-amber-solid{background-color:rgba(var(--semi-amber-5),1);color:rgba(var(--semi-white),1)}.semi-tag-amber-light{background-color:rgba(var(--semi-amber-5),.15);color:rgba(var(--semi-amber-8),1)}.semi-tag-blue-ghost{background-color:initial;border:1px solid rgba(var(--semi-blue-4),1);color:rgba(var(--semi-blue-5),1)}.semi-tag-blue-solid{background-color:rgba(var(--semi-blue-5),1);color:rgba(var(--semi-white),1)}.semi-tag-blue-light{background-color:rgba(var(--semi-blue-5),.15);color:rgba(var(--semi-blue-8),1)}.semi-tag-cyan-ghost{background-color:initial;border:1px solid rgba(var(--semi-cyan-4),1);color:rgba(var(--semi-cyan-5),1)}.semi-tag-cyan-solid{background-color:rgba(var(--semi-cyan-5),1);color:rgba(var(--semi-white),1)}.semi-tag-cyan-light{background-color:rgba(var(--semi-cyan-5),.15);color:rgba(var(--semi-cyan-8),1)}.semi-tag-green-ghost{background-color:initial;border:1px solid rgba(var(--semi-green-4),1);color:rgba(var(--semi-green-5),1)}.semi-tag-green-solid{background-color:rgba(var(--semi-green-5),1);color:rgba(var(--semi-white),1)}.semi-tag-green-light{background-color:rgba(var(--semi-green-5),.15);color:rgba(var(--semi-green-8),1)}.semi-tag-grey-ghost{background-color:initial;border:1px solid rgba(var(--semi-grey-4),1);color:rgba(var(--semi-grey-5),1)}.semi-tag-grey-solid{background-color:rgba(var(--semi-grey-5),1);color:rgba(var(--semi-white),1)}.semi-tag-grey-light{background-color:rgba(var(--semi-grey-5),.15);color:rgba(var(--semi-grey-8),1)}.semi-tag-indigo-ghost{background-color:initial;border:1px solid rgba(var(--semi-indigo-4),1);color:rgba(var(--semi-indigo-5),1)}.semi-tag-indigo-solid{background-color:rgba(var(--semi-indigo-5),1);color:rgba(var(--semi-white),1)}.semi-tag-indigo-light{background-color:rgba(var(--semi-indigo-5),.15);color:rgba(var(--semi-indigo-8),1)}.semi-tag-light-blue-ghost{background-color:initial;border:1px solid rgba(var(--semi-light-blue-4),1);color:rgba(var(--semi-light-blue-5),1)}.semi-tag-light-blue-solid{background-color:rgba(var(--semi-light-blue-5),1);color:rgba(var(--semi-white),1)}.semi-tag-light-blue-light{background-color:rgba(var(--semi-light-blue-5),.15);color:rgba(var(--semi-light-blue-8),1)}.semi-tag-light-green-ghost{background-color:initial;border:1px solid rgba(var(--semi-light-green-4),1);color:rgba(var(--semi-light-green-5),1)}.semi-tag-light-green-solid{background-color:rgba(var(--semi-light-green-5),1);color:rgba(var(--semi-white),1)}.semi-tag-light-green-light{background-color:rgba(var(--semi-light-green-5),.15);color:rgba(var(--semi-light-green-8),1)}.semi-tag-lime-ghost{background-color:initial;border:1px solid rgba(var(--semi-lime-4),1);color:rgba(var(--semi-lime-5),1)}.semi-tag-lime-solid{background-color:rgba(var(--semi-lime-5),1);color:rgba(var(--semi-white),1)}.semi-tag-lime-light{background-color:rgba(var(--semi-lime-5),.15);color:rgba(var(--semi-lime-8),1)}.semi-tag-orange-ghost{background-color:initial;border:1px solid rgba(var(--semi-orange-4),1);color:rgba(var(--semi-orange-5),1)}.semi-tag-orange-solid{background-color:rgba(var(--semi-orange-5),1);color:rgba(var(--semi-white),1)}.semi-tag-orange-light{background-color:rgba(var(--semi-orange-5),.15);color:rgba(var(--semi-orange-8),1)}.semi-tag-pink-ghost{background-color:initial;border:1px solid rgba(var(--semi-pink-4),1);color:rgba(var(--semi-pink-5),1)}.semi-tag-pink-solid{background-color:rgba(var(--semi-pink-5),1);color:rgba(var(--semi-white),1)}.semi-tag-pink-light{background-color:rgba(var(--semi-pink-5),.15);color:rgba(var(--semi-pink-8),1)}.semi-tag-purple-ghost{background-color:initial;border:1px solid rgba(var(--semi-purple-4),1);color:rgba(var(--semi-purple-5),1)}.semi-tag-purple-solid{background-color:rgba(var(--semi-purple-5),1);color:rgba(var(--semi-white),1)}.semi-tag-purple-light{background-color:rgba(var(--semi-purple-5),.15);color:rgba(var(--semi-purple-8),1)}.semi-tag-red-ghost{background-color:initial;border:1px solid rgba(var(--semi-red-4),1);color:rgba(var(--semi-red-5),1)}.semi-tag-red-solid{background-color:rgba(var(--semi-red-5),1);color:rgba(var(--semi-white),1)}.semi-tag-red-light{background-color:rgba(var(--semi-red-5),.15);color:rgba(var(--semi-red-8),1)}.semi-tag-teal-ghost{background-color:initial;border:1px solid rgba(var(--semi-teal-4),1);color:rgba(var(--semi-teal-5),1)}.semi-tag-teal-solid{background-color:rgba(var(--semi-teal-5),1);color:rgba(var(--semi-white),1)}.semi-tag-teal-light{background-color:rgba(var(--semi-teal-5),.15);color:rgba(var(--semi-teal-8),1)}.semi-tag-violet-ghost{background-color:initial;border:1px solid rgba(var(--semi-violet-4),1);color:rgba(var(--semi-violet-5),1)}.semi-tag-violet-solid{background-color:rgba(var(--semi-violet-5),1);color:rgba(var(--semi-white),1)}.semi-tag-violet-light{background-color:rgba(var(--semi-violet-5),.15);color:rgba(var(--semi-violet-8),1)}.semi-tag-yellow-ghost{background-color:initial;border:1px solid rgba(var(--semi-yellow-4),1);color:rgba(var(--semi-yellow-5),1)}.semi-tag-yellow-solid{background-color:rgba(var(--semi-yellow-5),1);color:rgba(var(--semi-white),1)}.semi-tag-yellow-light{background-color:rgba(var(--semi-yellow-5),.15);color:rgba(var(--semi-yellow-8),1)}.semi-tag-white-ghost,.semi-tag-white-light,.semi-tag-white-solid{background-color:var(--semi-color-bg-4);border:1px solid rgba(var(--semi-grey-2),.7);color:var(--semi-color-text-0)}.semi-tag-white-ghost .semi-tag-close,.semi-tag-white-light .semi-tag-close,.semi-tag-white-solid .semi-tag-close{color:var(--semi-color-text-2)}.semi-tag-avatar-circle,.semi-tag-avatar-square{background-color:var(--semi-color-bg-4);border:1px solid var(--semi-color-border);color:var(--semi-color-text-0)}.semi-tag-solid .semi-tag-close{color:var(--semi-color-white);opacity:.8}.semi-tag-solid .semi-tag-close:hover{opacity:1}.semi-tag-solid .semi-tag-close:active{opacity:.9}.semi-portal-rtl .semi-tag,.semi-rtl .semi-tag{direction:rtl}.semi-portal-rtl .semi-tag-close,.semi-rtl .semi-tag-close{padding-left:auto;padding-right:4px}.semi-portal-rtl .semi-tag-closable,.semi-rtl .semi-tag-closable{padding:4px 8px 4px 4px}.semi-portal-rtl .semi-tag-avatar-circle .semi-avatar,.semi-portal-rtl .semi-tag-avatar-square .semi-avatar,.semi-rtl .semi-tag-avatar-circle .semi-avatar,.semi-rtl .semi-tag-avatar-square .semi-avatar{margin-left:4px;margin-right:auto}.semi-portal-rtl .semi-tag-avatar-square,.semi-rtl .semi-tag-avatar-square{padding-left:4px;padding-right:auto}.semi-portal-rtl .semi-tag-avatar-circle,.semi-rtl .semi-tag-avatar-circle{padding:2px 2px 2px 4px}.semi-portal-rtl .semi-tag-group,.semi-rtl .semi-tag-group{direction:rtl}.semi-portal-rtl .semi-tag-group .semi-tag,.semi-rtl .semi-tag-group .semi-tag{margin-left:8px;margin-right:auto}.semi-portal-rtl .semi-tag-rest-group-popover,.semi-rtl .semi-tag-rest-group-popover{direction:rtl}.semi-portal-rtl .semi-tag-rest-group-popover .semi-tag,.semi-rtl .semi-tag-rest-group-popover .semi-tag{margin-left:8px;margin-right:0}.semi-portal-rtl .semi-tag-rest-group-popover .semi-tag:last-of-type,.semi-rtl .semi-tag-rest-group-popover .semi-tag:last-of-type{margin-left:0;margin-right:auto}.semi-avatar{align-items:center;display:inline-flex;justify-content:center;overflow:hidden;position:relative;text-align:center;vertical-align:middle;white-space:nowrap}.semi-avatar:focus-visible{outline:2px solid var(--semi-color-primary-light-active)}.semi-avatar-focus{outline:2px solid var(--semi-color-primary-light-active)}.semi-avatar-no-focus-visible:focus-visible{outline:none}.semi-avatar .semi-avatar-label{align-items:center;display:flex;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;font-weight:600;line-height:20px}.semi-avatar-content{-webkit-user-select:none;user-select:none}.semi-avatar-extra-extra-small{border-radius:3px;height:20px;width:20px}.semi-avatar-extra-extra-small .semi-avatar-content{-webkit-transform:scale(.8);transform:scale(.8);-webkit-transform-origin:center;transform-origin:center}.semi-avatar-extra-extra-small .semi-avatar-label{font-size:10px;line-height:15px}.semi-avatar-extra-small{border-radius:3px;height:24px;width:24px}.semi-avatar-extra-small .semi-avatar-content{-webkit-transform:scale(.8);transform:scale(.8);-webkit-transform-origin:center;transform-origin:center}.semi-avatar-extra-small .semi-avatar-label{font-size:10px;line-height:15px}.semi-avatar-small{border-radius:3px;height:32px;width:32px}.semi-avatar-small .semi-avatar-label{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:12px;line-height:16px}.semi-avatar-default{border-radius:3px;height:40px;width:40px}.semi-avatar-default .semi-avatar-label{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:18px;line-height:24px}.semi-avatar-medium{border-radius:3px;height:48px;width:48px}.semi-avatar-medium .semi-avatar-label{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:20px;line-height:28px}.semi-avatar-large{border-radius:6px;height:72px;width:72px}.semi-avatar-large .semi-avatar-label{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:32px;line-height:44px}.semi-avatar-extra-large{border-radius:12px;height:128px;width:128px}.semi-avatar-extra-large .semi-avatar-label{font-size:64px;line-height:77px}.semi-avatar-circle{border-radius:var(--semi-border-radius-circle)}.semi-avatar-image{background-color:initial}.semi-avatar>img{display:block;height:100%;object-fit:cover;width:100%}.semi-avatar-hover{height:100%;left:0;position:absolute;top:0;width:100%}.semi-avatar:hover{cursor:pointer}.semi-avatar-wrapper{align-items:center;display:inline-flex;flex-direction:column;position:relative;width:-webkit-fit-content;width:-moz-fit-content;width:fit-content}.semi-avatar-wrapper .semi-avatar-top_slot-bg{border-radius:50%;display:flex;justify-content:center;overflow:hidden;position:absolute}.semi-avatar-wrapper .semi-avatar-top_slot-bg-small{height:32px;width:32px}.semi-avatar-wrapper .semi-avatar-top_slot-bg-default{height:40px;width:40px}.semi-avatar-wrapper .semi-avatar-top_slot-bg-medium{height:48px;width:48px}.semi-avatar-wrapper .semi-avatar-top_slot-bg-large{height:72px;width:72px}.semi-avatar-wrapper .semi-avatar-top_slot-bg-extra-large{height:128px;width:128px}.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg{position:absolute}.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-small{scale:.4;top:-28px}.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-default{scale:.7;top:-32px}.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-medium{scale:.8;top:-30px}.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-large{scale:1.1;top:-30px}.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-extra-large{scale:1.4;top:-32px}.semi-avatar-wrapper .semi-avatar-top_slot-wrapper{display:flex;justify-content:center;position:absolute}.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot{color:var(--semi-color-bg-0);font-weight:600}.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content{line-height:normal;position:relative;-webkit-user-select:none;user-select:none}.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-small{font-size:5px;margin-top:0}.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-default{font-size:6px;margin-top:-2px}.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-medium{font-size:8px;margin-top:0}.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-large{font-size:14px;margin-top:0}.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-extra-large{font-size:16px;margin-top:0}.semi-avatar-wrapper .semi-avatar-bottom_slot{bottom:3.5px;color:var(--semi-color-bg-0);cursor:pointer;position:absolute;-webkit-transform:translateY(50%);transform:translateY(50%);-webkit-user-select:none;user-select:none}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle{align-items:center;background:var(--semi-color-primary);border-radius:var(--semi-border-radius-circle);display:flex;justify-content:center;line-height:normal}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-extra-small,.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-small{font-size:5px;height:12px;width:12px}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-default{font-size:12px;height:16px;width:16px}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-medium{font-size:12px;height:18px;width:18px}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-large{font-size:12px;height:28px;width:28px}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-extra-large{font-size:14px;height:28px;width:28px}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square{align-items:center;background:var(--semi-color-primary);border-color:var(--semi-color-bg-0);border-radius:4px;border-style:solid;display:flex;font-weight:600;justify-content:center;padding:1px 4px}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-extra_small,.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-small{border-width:2px;font-size:5px}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-default,.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-large,.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-medium{border-width:2px;font-size:12px}.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-extra-large{border-width:2px;font-size:14px}.semi-avatar-group{display:inline-block}.semi-avatar-group .semi-avatar{box-sizing:border-box}.semi-avatar-group .semi-avatar:first-child{margin-left:0}.semi-avatar-group .semi-avatar-extra-large{border:3px solid var(--semi-color-bg-1);margin-left:-32px}.semi-avatar-group .semi-avatar-large{border:3px solid var(--semi-color-bg-1);margin-left:-18px}.semi-avatar-group .semi-avatar-default,.semi-avatar-group .semi-avatar-medium,.semi-avatar-group .semi-avatar-small{border:2px solid var(--semi-color-bg-1);margin-left:-12px}.semi-avatar-group .semi-avatar-extra-small{border:1px solid var(--semi-color-bg-1);margin-left:-10px}.semi-avatar-group .semi-avatar-extra-extra-small{border:1px solid var(--semi-color-bg-1);margin-left:-4px}.semi-avatar-group .semi-avatar-item-start-0{z-index:100}.semi-avatar-group .semi-avatar-item-end-0{z-index:80}.semi-avatar-group .semi-avatar-item-start-1{z-index:99}.semi-avatar-group .semi-avatar-item-end-1{z-index:81}.semi-avatar-group .semi-avatar-item-start-2{z-index:98}.semi-avatar-group .semi-avatar-item-end-2{z-index:82}.semi-avatar-group .semi-avatar-item-start-3{z-index:97}.semi-avatar-group .semi-avatar-item-end-3{z-index:83}.semi-avatar-group .semi-avatar-item-start-4{z-index:96}.semi-avatar-group .semi-avatar-item-end-4{z-index:84}.semi-avatar-group .semi-avatar-item-start-5{z-index:95}.semi-avatar-group .semi-avatar-item-end-5{z-index:85}.semi-avatar-group .semi-avatar-item-start-6{z-index:94}.semi-avatar-group .semi-avatar-item-end-6{z-index:86}.semi-avatar-group .semi-avatar-item-start-7{z-index:93}.semi-avatar-group .semi-avatar-item-end-7{z-index:87}.semi-avatar-group .semi-avatar-item-start-8{z-index:92}.semi-avatar-group .semi-avatar-item-end-8{z-index:88}.semi-avatar-group .semi-avatar-item-start-9{z-index:91}.semi-avatar-group .semi-avatar-item-end-9{z-index:89}.semi-avatar-group .semi-avatar-item-end-10,.semi-avatar-group .semi-avatar-item-start-10{z-index:90}.semi-avatar-group .semi-avatar-item-start-11{z-index:89}.semi-avatar-group .semi-avatar-item-end-11{z-index:91}.semi-avatar-group .semi-avatar-item-start-12{z-index:88}.semi-avatar-group .semi-avatar-item-end-12{z-index:92}.semi-avatar-group .semi-avatar-item-start-13{z-index:87}.semi-avatar-group .semi-avatar-item-end-13{z-index:93}.semi-avatar-group .semi-avatar-item-start-14{z-index:86}.semi-avatar-group .semi-avatar-item-end-14{z-index:94}.semi-avatar-group .semi-avatar-item-start-15{z-index:85}.semi-avatar-group .semi-avatar-item-end-15{z-index:95}.semi-avatar-group .semi-avatar-item-start-16{z-index:84}.semi-avatar-group .semi-avatar-item-end-16{z-index:96}.semi-avatar-group .semi-avatar-item-start-17{z-index:83}.semi-avatar-group .semi-avatar-item-end-17{z-index:97}.semi-avatar-group .semi-avatar-item-start-18{z-index:82}.semi-avatar-group .semi-avatar-item-end-18{z-index:98}.semi-avatar-group .semi-avatar-item-start-19{z-index:81}.semi-avatar-group .semi-avatar-item-end-19{z-index:99}.semi-avatar-group .semi-avatar-item-start-20{z-index:80}.semi-avatar-group .semi-avatar-item-end-20{z-index:100}.semi-avatar-group .semi-avatar-item-more{background-color:rgba(var(--semi-grey-5),1)}.semi-avatar-amber{background-color:rgba(var(--semi-amber-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-blue{background-color:rgba(var(--semi-blue-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-cyan{background-color:rgba(var(--semi-cyan-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-green{background-color:rgba(var(--semi-green-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-grey{background-color:rgba(var(--semi-grey-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-indigo{background-color:rgba(var(--semi-indigo-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-light-blue{background-color:rgba(var(--semi-light-blue-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-light-green{background-color:rgba(var(--semi-light-green-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-lime{background-color:rgba(var(--semi-lime-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-orange{background-color:rgba(var(--semi-orange-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-pink{background-color:rgba(var(--semi-pink-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-purple{background-color:rgba(var(--semi-purple-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-red{background-color:rgba(var(--semi-red-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-teal{background-color:rgba(var(--semi-teal-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-violet{background-color:rgba(var(--semi-violet-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-yellow{background-color:rgba(var(--semi-yellow-3),1);color:rgba(var(--semi-white),1)}.semi-avatar-additionalBorder{border-color:var(--semi-color-primary);border-style:solid;border-width:1.5px;box-sizing:border-box;display:inline-block;left:-3.5px;position:absolute;top:-3.5px}.semi-avatar-additionalBorder-extra-extra-small{height:27px;width:27px}.semi-avatar-additionalBorder-extra-small{height:31px;width:31px}.semi-avatar-additionalBorder-small{height:39px;width:39px}.semi-avatar-additionalBorder-default{height:47px;width:47px}.semi-avatar-additionalBorder-medium{height:55px;width:55px}.semi-avatar-additionalBorder-large{height:79px;width:79px}.semi-avatar-additionalBorder-extra-large{height:135px;width:135px}.semi-avatar-square.semi-avatar-additionalBorder-default,.semi-avatar-square.semi-avatar-additionalBorder-extra_extra_small,.semi-avatar-square.semi-avatar-additionalBorder-extra_small,.semi-avatar-square.semi-avatar-additionalBorder-medium,.semi-avatar-square.semi-avatar-additionalBorder-small{border-radius:3px}.semi-avatar-square.semi-avatar-additionalBorder-large{border-radius:6px}.semi-avatar-additionalBorder-circle{border-radius:var(--semi-border-radius-circle)}.semi-avatar-additionalBorder-animated{-webkit-animation:semi-avatar-additionalBorder .8s linear infinite;animation:semi-avatar-additionalBorder .8s linear infinite}.semi-avatar-animated{-webkit-animation:semi-avatar-content 1s linear infinite;animation:semi-avatar-content 1s linear infinite}@-webkit-keyframes semi-avatar-additionalBorder{0%{opacity:1;-webkit-transform:scale(1);transform:scale(1)}to{border-width:0;opacity:0;-webkit-transform:scale(1.15);transform:scale(1.15)}}@keyframes semi-avatar-additionalBorder{0%{opacity:1;-webkit-transform:scale(1);transform:scale(1)}to{border-width:0;opacity:0;-webkit-transform:scale(1.15);transform:scale(1.15)}}@-webkit-keyframes semi-avatar-content{0%{-webkit-transform:scale(1);transform:scale(1)}50%{-webkit-transform:scale(.9);transform:scale(.9)}to{-webkit-transform:scale(1);transform:scale(1)}}@keyframes semi-avatar-content{0%{-webkit-transform:scale(1);transform:scale(1)}50%{-webkit-transform:scale(.9);transform:scale(.9)}to{-webkit-transform:scale(1);transform:scale(1)}}.semi-portal-rtl .semi-avatar,.semi-rtl .semi-avatar{direction:rtl}.semi-portal-rtl .semi-avatar-extra-extra-small .semi-avatar-content,.semi-portal-rtl .semi-avatar-extra-small .semi-avatar-content,.semi-rtl .semi-avatar-extra-extra-small .semi-avatar-content,.semi-rtl .semi-avatar-extra-small .semi-avatar-content{-webkit-transform:scale(.8);transform:scale(.8)}.semi-portal-rtl .semi-avatar-hover,.semi-rtl .semi-avatar-hover{left:auto;right:0}.semi-portal-rtl .semi-avatar-group,.semi-rtl .semi-avatar-group{direction:rtl}.semi-portal-rtl .semi-avatar-group .semi-avatar:first-child,.semi-rtl .semi-avatar-group .semi-avatar:first-child{margin-left:auto;margin-right:0}.semi-portal-rtl .semi-avatar-group .semi-avatar-extra-large,.semi-rtl .semi-avatar-group .semi-avatar-extra-large{margin-left:auto;margin-right:-32px}.semi-portal-rtl .semi-avatar-group .semi-avatar-large,.semi-rtl .semi-avatar-group .semi-avatar-large{margin-left:auto;margin-right:-18px}.semi-portal-rtl .semi-avatar-group .semi-avatar-medium,.semi-portal-rtl .semi-avatar-group .semi-avatar-small,.semi-rtl .semi-avatar-group .semi-avatar-medium,.semi-rtl .semi-avatar-group .semi-avatar-small{margin-left:auto;margin-right:-12px}.semi-portal-rtl .semi-avatar-group .semi-avatar-extra-small,.semi-rtl .semi-avatar-group .semi-avatar-extra-small{margin-left:auto;margin-right:-10px}.semi-portal-rtl .semi-avatar-group .semi-avatar-extra-extra-small,.semi-rtl .semi-avatar-group .semi-avatar-extra-extra-small{margin-left:auto;margin-right:-4px}@-webkit-keyframes semi-input-active{0%{-webkit-transform:scale(1);transform:scale(1)}to{-webkit-transform:scale(.97);transform:scale(.97)}}@keyframes semi-input-active{0%{-webkit-transform:scale(1);transform:scale(1)}to{-webkit-transform:scale(.97);transform:scale(.97)}}@-webkit-keyframes semi-input-inactive{0%{-webkit-transform:scale(.97);transform:scale(.97)}to{-webkit-transform:scale(1);transform:scale(1)}}@keyframes semi-input-inactive{0%{-webkit-transform:scale(.97);transform:scale(.97)}to{-webkit-transform:scale(1);transform:scale(1)}}.semi-input,.semi-input-wrapper{-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-input-wrapper{background-color:var(--semi-color-fill-0);border:1px solid transparent;border-radius:var(--semi-border-radius-small);box-shadow:none;box-sizing:border-box;color:var(--semi-color-text-0);cursor:text;display:inline-block;outline:none;position:relative;vertical-align:middle;width:100%}.semi-input-wrapper,.semi-input-wrapper-default{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;line-height:20px}.semi-input-wrapper-default{height:32px;line-height:30px}.semi-input-wrapper-small{font-size:14px;height:24px;line-height:20px}.semi-input-wrapper-large,.semi-input-wrapper-small{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;line-height:22px}.semi-input-wrapper-large{font-size:16px;height:40px;line-height:38px}.semi-input-wrapper:hover{background-color:var(--semi-color-fill-1);border-color:transparent}.semi-input-wrapper-focus{background-color:var(--semi-color-fill-0);border:1px solid var(--semi-color-focus-border)}.semi-input-wrapper-focus:hover{background-color:var(--semi-color-fill-0);border-color:var(--semi-color-focus-border)}.semi-input-wrapper-focus:active{background-color:var(--semi-color-fill-2);border-color:var(--semi-color-focus-border)}.semi-input-wrapper.semi-input-readonly{cursor:default}.semi-input-wrapper-error{background-color:var(--semi-color-danger-light-default);border-color:var(--semi-color-danger-light-default)}.semi-input-wrapper-error:hover{background-color:var(--semi-color-danger-light-hover);border-color:var(--semi-color-danger-light-hover)}.semi-input-wrapper-error.semi-input-wrapper-focus{background-color:var(--semi-color-danger-light-default);border-color:var(--semi-color-danger)}.semi-input-wrapper-error:active{background-color:var(--semi-color-danger-light-active);border-color:var(--semi-color-danger)}.semi-input-wrapper-warning{background-color:var(--semi-color-warning-light-default);border-color:var(--semi-color-warning-light-default)}.semi-input-wrapper-warning:hover{background-color:var(--semi-color-warning-light-hover);border-color:var(--semi-color-warning-light-hover)}.semi-input-wrapper-warning.semi-input-wrapper-focus{background-color:var(--semi-color-warning-light-default);border-color:var(--semi-color-warning)}.semi-input-wrapper-warning:active{background-color:var(--semi-color-warning-light-active);border-color:var(--semi-color-warning)}.semi-input-wrapper__with-prefix{align-items:center;display:inline-flex}.semi-input-wrapper__with-prefix .semi-input{padding-left:0}.semi-input-wrapper__with-suffix{align-items:center;display:inline-flex}.semi-input-wrapper__with-suffix .semi-input{padding-right:0}.semi-input-wrapper-clearable,.semi-input-wrapper-modebtn{align-items:center;display:inline-flex}.semi-input-wrapper-hidden{border:none}.semi-input-wrapper .semi-icon{color:var(--semi-color-text-2)}.semi-input-wrapper .semi-input-clearbtn,.semi-input-wrapper .semi-input-modebtn{color:var(--semi-color-primary-hover)}.semi-input-wrapper .semi-input-clearbtn>svg,.semi-input-wrapper .semi-input-modebtn>svg{pointer-events:none}.semi-input-wrapper .semi-input-clearbtn:hover,.semi-input-wrapper .semi-input-modebtn:hover{cursor:pointer}.semi-input-wrapper .semi-input-clearbtn:hover .semi-icon,.semi-input-wrapper .semi-input-modebtn:hover .semi-icon{color:var(--semi-color-primary-hover)}.semi-input-wrapper .semi-input-clearbtn:focus-visible,.semi-input-wrapper .semi-input-modebtn:focus-visible{border-radius:var(--semi-border-radius-small);outline:2px solid var(--semi-color-primary-light-active);outline-offset:-1px}.semi-input-wrapper__with-suffix-icon.semi-input-wrapper-clearable:not(.semi-input-wrapper__with-suffix-hidden) .semi-input-clearbtn{justify-content:flex-end;min-width:24px}.semi-input-wrapper-modebtn.semi-input-wrapper-clearable .semi-input-clearbtn{justify-content:center;min-width:16px}.semi-input-wrapper.semi-input-wrapper__with-append-only .semi-input{border-radius:0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0}.semi-input-wrapper.semi-input-wrapper__with-append-only .semi-input:not(:last-child){border-radius:0;border-right-style:none}.semi-input-wrapper.semi-input-wrapper__with-prepend-only .semi-input{border-radius:var(--semi-border-radius-small) 0 0 var(--semi-border-radius-small)}.semi-input-wrapper.semi-input-wrapper__with-prepend-only .semi-input:not(:last-child){border-right-style:none}.semi-input-wrapper.semi-input-wrapper__with-append,.semi-input-wrapper.semi-input-wrapper__with-prepend{align-items:center;background-color:initial;display:inline-flex}.semi-input-wrapper.semi-input-wrapper__with-append:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend:hover{background-color:initial}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-focus,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-focus{background-color:initial;border:1px solid transparent}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input{background-color:var(--semi-color-fill-0);border:1px solid transparent}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:hover,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:hover+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:hover~.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:hover+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:hover~.semi-input-modebtn{background-color:var(--semi-color-fill-1)}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus{background-color:var(--semi-color-fill-0);border:1px solid var(--semi-color-focus-border)}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus.semi-input-sibling-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus.semi-input-sibling-modebtn,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus.semi-input-sibling-modebtn+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus.semi-input-sibling-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus.semi-input-sibling-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus.semi-input-sibling-modebtn+.semi-input-clearbtn{border-right-style:none}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus~.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus~.semi-input-modebtn{background-color:var(--semi-color-fill-0);box-sizing:border-box}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus+.semi-input-clearbtn{border-left-style:solid;border:1px solid var(--semi-color-focus-border);border-left:1px var(--semi-color-focus-border);border-radius:0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus+.semi-input-clearbtn:not(:last-child),.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus+.semi-input-clearbtn:not(:last-child){border-radius:0}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus~.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus~.semi-input-modebtn{border-left-style:solid;border:1px solid var(--semi-color-focus-border);border-left:1px var(--semi-color-focus-border);border-radius:0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus~.semi-input-modebtn:not(:last-child),.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus~.semi-input-modebtn:not(:last-child){border-radius:0}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:active,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:active+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input:active~.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:active,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:active+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:active~.semi-input-modebtn{background-color:var(--semi-color-fill-2)}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input-clearbtn:hover,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input-modebtn:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-clearbtn:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-modebtn:hover{background-color:var(--semi-color-fill-0)}.semi-input-wrapper.semi-input-wrapper__with-append .semi-input-clearbtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input-clearbtn:last-child,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input-modebtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-append .semi-input-modebtn:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-clearbtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-clearbtn:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-modebtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-modebtn:last-child{border-radius:0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error{border-color:transparent}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input{background-color:var(--semi-color-danger-light-default);border-color:var(--semi-color-danger-light-default)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:hover{background-color:var(--semi-color-danger-light-hover);border-color:var(--semi-color-danger-light-hover)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:hover+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:hover+.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:hover+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:hover+.semi-input-modebtn{background-color:var(--semi-color-danger-light-hover)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:focus,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:focus+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:focus+.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:focus,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:focus+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:focus+.semi-input-modebtn{background-color:var(--semi-color-danger-light-default);border-color:var(--semi-color-danger)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:active,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:active{background-color:var(--semi-color-danger-light-active)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:active+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:active+.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:active+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:active+.semi-input-modebtn{background-color:var(--semi-color-danger-light-active);border-color:var(--semi-color-danger)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-clearbtn:hover,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-modebtn:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-clearbtn:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-modebtn:hover{background-color:var(--semi-color-danger-light-default)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-clearbtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-clearbtn:last-child,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-modebtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-modebtn:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-clearbtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-clearbtn:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-modebtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-modebtn:last-child{border-radius:0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning{border-color:transparent}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input{background-color:var(--semi-color-warning-light-default);border-color:var(--semi-color-warning-light-default)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:hover{background-color:var(--semi-color-warning-light-hover);border-color:var(--semi-color-warning-light-hover)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:hover+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:hover+.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:hover+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:hover+.semi-input-modebtn{background-color:var(--semi-color-warning-light-hover)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:focus,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:focus+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:focus+.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:focus,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:focus+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:focus+.semi-input-modebtn{background-color:var(--semi-color-warning-light-default);border-color:var(--semi-color-warning)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:active,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:active{background-color:var(--semi-color-warning-light-active)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:active+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:active+.semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:active+.semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:active+.semi-input-modebtn{background-color:var(--semi-color-warning-light-active);border-color:var(--semi-color-warning)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-clearbtn:hover,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-modebtn:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-clearbtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-clearbtn:hover,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-modebtn,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-modebtn:hover{background-color:var(--semi-color-warning-light-default)}.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-clearbtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-clearbtn:last-child,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-modebtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-modebtn:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-clearbtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-clearbtn:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-modebtn:hover:last-child,.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-modebtn:last-child{border-radius:0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0}.semi-input-wrapper-disabled{-webkit-text-fill-color:var(--semi-color-disabled-text);color:var(--semi-color-disabled-text);cursor:not-allowed}.semi-input-wrapper-disabled,.semi-input-wrapper-disabled:hover{background-color:var(--semi-color-disabled-fill)}.semi-input-wrapper-disabled .semi-icon,.semi-input-wrapper-disabled .semi-input-append,.semi-input-wrapper-disabled .semi-input-prefix,.semi-input-wrapper-disabled .semi-input-prepend,.semi-input-wrapper-disabled .semi-input-suffix{color:var(--semi-color-disabled-text)}.semi-input{background-color:initial;border:none;box-sizing:border-box;color:inherit;outline:none;padding-left:12px;padding-right:12px;width:100%}.semi-input[type=password]::-ms-clear,.semi-input[type=password]::-ms-reveal{display:none}.semi-input[type=search]::-webkit-search-cancel-button{display:none}.semi-input::-webkit-input-placeholder{color:var(--semi-color-text-2)}.semi-input::placeholder{color:var(--semi-color-text-2)}.semi-input-large{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:16px;height:38px;line-height:22px;line-height:38px}.semi-input-small{height:22px;line-height:20px;line-height:22px}.semi-input-default,.semi-input-small{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px}.semi-input-default{height:30px;line-height:20px;line-height:30px}.semi-input-disabled{color:inherit;cursor:not-allowed}.semi-input-inset-label{color:var(--semi-color-text-2);flex-shrink:0;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;font-weight:600;line-height:20px;margin-right:12px;white-space:nowrap}.semi-input-prefix,.semi-input-suffix{align-items:center;display:flex;justify-content:center}.semi-input-prefix-text,.semi-input-suffix-text{color:var(--semi-color-text-2);font-weight:600;margin:0 12px;white-space:nowrap}.semi-input-prefix-icon,.semi-input-suffix-icon{color:var(--semi-color-text-2);margin:0 8px}.semi-input-clearbtn,.semi-input-modebtn,.semi-input-suffix{align-items:center;display:flex;justify-content:center}.semi-input-clearbtn,.semi-input-modebtn{height:100%;min-width:32px}.semi-input-clearbtn+.semi-input-suffix+.semi-input-suffix-icon,.semi-input-clearbtn+.semi-input-suffix+.semi-input-suffix-text{margin-left:0}.semi-input-suffix-hidden{display:none}.semi-input-append,.semi-input-prepend{align-items:center;background-color:var(--semi-color-fill-0);color:var(--semi-color-text-2);display:flex;flex-shrink:0;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;height:100%;line-height:20px}.semi-input-append-icon,.semi-input-append-text,.semi-input-prepend-icon,.semi-input-prepend-text{padding:0 12px}.semi-input-append{border-left:1px solid transparent;border-radius:0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0}.semi-input-prepend{border-radius:var(--semi-border-radius-small) 0 0 var(--semi-border-radius-small);border-right:1px solid transparent}.semi-input-disabled::-webkit-input-placeholder{color:var(--semi-color-disabled-text)}.semi-input-disabled::placeholder{color:var(--semi-color-disabled-text)}.semi-input-group{align-content:center;align-items:center;display:inline-flex;flex-wrap:wrap}.semi-input-group .semi-cascader,.semi-input-group .semi-select,.semi-input-group .semi-tagInput,.semi-input-group .semi-tree-select,.semi-input-group>.semi-input-wrapper{border-radius:0}.semi-input-group .semi-cascader:first-child,.semi-input-group .semi-select:first-child,.semi-input-group .semi-tagInput:first-child,.semi-input-group .semi-tree-select:first-child,.semi-input-group>.semi-input-wrapper:first-child{border-radius:var(--semi-border-radius-small) 0 0 var(--semi-border-radius-small)}.semi-input-group .semi-cascader:last-child,.semi-input-group .semi-select:last-child,.semi-input-group .semi-tagInput:last-child,.semi-input-group .semi-tree-select:last-child,.semi-input-group>.semi-input-wrapper:last-child{border-radius:0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0}.semi-input-group .semi-cascader:not(:last-child),.semi-input-group .semi-select:not(:last-child),.semi-input-group .semi-tagInput:not(:last-child),.semi-input-group .semi-tree-select:not(:last-child),.semi-input-group>.semi-input-wrapper:not(:last-child){position:relative}.semi-input-group .semi-cascader:not(:last-child):after,.semi-input-group .semi-select:not(:last-child):after,.semi-input-group .semi-tagInput:not(:last-child):after,.semi-input-group .semi-tree-select:not(:last-child):after,.semi-input-group>.semi-input-wrapper:not(:last-child):after{background-color:var(--semi-color-border);bottom:1px;content:"";position:absolute;right:-1px;top:1px;width:1px}.semi-input-group .semi-select{overflow-y:visible}.semi-input-group .semi-autocomplete,.semi-input-group .semi-autocomplete .semi-datepicker-range-input,.semi-input-group .semi-datepicker,.semi-input-group .semi-datepicker .semi-datepicker-range-input,.semi-input-group .semi-input-number,.semi-input-group .semi-input-number .semi-datepicker-range-input,.semi-input-group .semi-timepicker,.semi-input-group .semi-timepicker .semi-datepicker-range-input{border-radius:0}.semi-input-group .semi-autocomplete:first-child .semi-datepicker-range-input,.semi-input-group .semi-autocomplete:first-child .semi-input-wrapper,.semi-input-group .semi-datepicker:first-child .semi-datepicker-range-input,.semi-input-group .semi-datepicker:first-child .semi-input-wrapper,.semi-input-group .semi-input-number:first-child .semi-datepicker-range-input,.semi-input-group .semi-input-number:first-child .semi-input-wrapper,.semi-input-group .semi-timepicker:first-child .semi-datepicker-range-input,.semi-input-group .semi-timepicker:first-child .semi-input-wrapper{border-radius:var(--semi-border-radius-small) 0 0 var(--semi-border-radius-small)}.semi-input-group .semi-autocomplete:last-child .semi-datepicker-range-input,.semi-input-group .semi-autocomplete:last-child .semi-input-wrapper,.semi-input-group .semi-datepicker:last-child .semi-datepicker-range-input,.semi-input-group .semi-datepicker:last-child .semi-input-wrapper,.semi-input-group .semi-input-number:last-child .semi-datepicker-range-input,.semi-input-group .semi-input-number:last-child .semi-input-wrapper,.semi-input-group .semi-timepicker:last-child .semi-datepicker-range-input,.semi-input-group .semi-timepicker:last-child .semi-input-wrapper{border-radius:0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0}.semi-input-group .semi-autocomplete:not(:last-child),.semi-input-group .semi-datepicker:not(:last-child),.semi-input-group .semi-input-number:not(:last-child),.semi-input-group .semi-timepicker:not(:last-child){position:relative}.semi-input-group .semi-autocomplete:not(:last-child):after,.semi-input-group .semi-datepicker:not(:last-child):after,.semi-input-group .semi-input-number:not(:last-child):after,.semi-input-group .semi-timepicker:not(:last-child):after{background-color:var(--semi-color-border);bottom:1px;content:"";position:absolute;right:-1px;top:1px;width:1px}.semi-input-group-wrapper-with-top-label{margin-bottom:16px;margin-top:16px}.semi-input-group-wrapper-with-top-label .semi-input-group{display:flex}.semi-input-group-wrapper-with-top-label .semi-input-group .semi-form-field{margin-bottom:0;margin-top:0}.semi-input-only_border,.semi-input-only_border:hover{background:transparent;border-color:var(--semi-color-border)}.semi-input-only_border:focus-within{background:transparent}.semi-input-borderless:not(:focus-within):not(:hover){background-color:initial;border-color:transparent}.semi-input-borderless:focus-within:not(:active){background-color:initial}.semi-input-borderless.semi-input-wrapper-error:not(:focus-within){border-color:var(--semi-color-danger)}.semi-input-borderless.semi-input-wrapper-warning:not(:focus-within){border-color:var(--semi-color-warning)}.semi-portal-rtl .semi-input-wrapper,.semi-rtl .semi-input-wrapper{direction:rtl}.semi-portal-rtl .semi-input-wrapper__with-prefix .semi-input,.semi-rtl .semi-input-wrapper__with-prefix .semi-input{padding-left:auto;padding-right:0}.semi-portal-rtl .semi-input-wrapper__with-suffix .semi-input,.semi-rtl .semi-input-wrapper__with-suffix .semi-input{padding-left:0;padding-right:auto}.semi-portal-rtl .semi-input,.semi-rtl .semi-input{padding-left:12px;padding-right:12px}.semi-portal-rtl .semi-input-inset-label,.semi-rtl .semi-input-inset-label{margin-left:12px;margin-right:auto}.semi-portal-rtl .semi-input-clearbtn+.semi-portal-rtl .semi-input-suffix+.semi-input-suffix-icon,.semi-portal-rtl .semi-input-clearbtn+.semi-portal-rtl .semi-input-suffix+.semi-input-suffix-text,.semi-portal-rtl .semi-input-clearbtn+.semi-rtl .semi-input-suffix+.semi-input-suffix-icon,.semi-portal-rtl .semi-input-clearbtn+.semi-rtl .semi-input-suffix+.semi-input-suffix-text,.semi-rtl .semi-input-clearbtn+.semi-portal-rtl .semi-input-suffix+.semi-input-suffix-icon,.semi-rtl .semi-input-clearbtn+.semi-portal-rtl .semi-input-suffix+.semi-input-suffix-text,.semi-rtl .semi-input-clearbtn+.semi-rtl .semi-input-suffix+.semi-input-suffix-icon,.semi-rtl .semi-input-clearbtn+.semi-rtl .semi-input-suffix+.semi-input-suffix-text{margin-left:auto;margin-right:0}.semi-portal-rtl .semi-input-append,.semi-rtl .semi-input-append{border-left:0;border-right:1px solid transparent}.semi-portal-rtl .semi-input-prepend,.semi-rtl .semi-input-prepend{border-left:1px solid transparent;border-right:0}.semi-portal-rtl .semi-input-group .semi-cascader:not(:last-child):after,.semi-portal-rtl .semi-input-group .semi-input-number:not(:last-child):after,.semi-portal-rtl .semi-input-group .semi-select:not(:last-child):after,.semi-portal-rtl .semi-input-group .semi-tree-select:not(:last-child):after,.semi-portal-rtl .semi-input-group>.semi-input-wrapper:not(:last-child):after,.semi-rtl .semi-input-group .semi-cascader:not(:last-child):after,.semi-rtl .semi-input-group .semi-input-number:not(:last-child):after,.semi-rtl .semi-input-group .semi-select:not(:last-child):after,.semi-rtl .semi-input-group .semi-tree-select:not(:last-child):after,.semi-rtl .semi-input-group>.semi-input-wrapper:not(:last-child):after{left:-1px;right:auto}.semi-portal-rtl .semi-input-textarea-wrapper,.semi-rtl .semi-input-textarea-wrapper{direction:rtl}.semi-portal-rtl .semi-input-textarea-counter,.semi-rtl .semi-input-textarea-counter{text-align:left}.semi-portal-rtl .semi-input-textarea-showClear,.semi-rtl .semi-input-textarea-showClear{padding-left:36px;padding-right:0}.semi-spin{display:inline-block;height:20px;position:relative;width:20px}@-webkit-keyframes semi-animation-rotate{0%{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes semi-animation-rotate{0%{-webkit-transform:rotate(0);transform:rotate(0)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.semi-spin-wrapper{color:var(--semi-color-primary);position:absolute;text-align:center;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);width:100%}.semi-spin-wrapper>svg{-webkit-animation:semi-animation-rotate .6s linear infinite;animation:semi-animation-rotate .6s linear infinite;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards;display:inline;height:20px;vertical-align:top;width:20px}.semi-spin-animate{-webkit-animation:semi-animation-rotate 1.6s linear infinite;animation:semi-animation-rotate 1.6s linear infinite;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards;display:inline-flex}.semi-spin-children{opacity:.5;-webkit-user-select:none;user-select:none}.semi-spin-block{display:block}.semi-spin-block:after{content:"";height:100%;left:0;position:absolute;top:0;width:100%;z-index:1}.semi-spin-block .semi-spin-wrapper{display:block}.semi-spin-block.semi-spin{height:auto;width:auto}.semi-spin-hidden:after{content:none}.semi-spin-hidden>.semi-spin-children{opacity:1;-webkit-user-select:auto;user-select:auto}.semi-spin-small,.semi-spin-small>.semi-spin-wrapper svg{height:14px;width:14px}.semi-spin-middle,.semi-spin-middle>.semi-spin-wrapper svg{height:20px;width:20px}.semi-spin-large,.semi-spin-large>.semi-spin-wrapper svg{height:32px;width:32px}.semi-spin-container{overflow:hidden}.semi-portal-rtl .semi-spin,.semi-portal-rtl .semi-spin-container,.semi-rtl .semi-spin,.semi-rtl .semi-spin-container{direction:rtl}.semi-overflow-list{display:flex;flex-wrap:nowrap;min-width:0}.semi-overflow-list-spacer{flex-shrink:1;width:1px}.semi-overflow-list-scroll-wrapper{display:flex;flex:1 1;flex-wrap:nowrap;overflow-x:scroll}.semi-portal-rtl .semi-overflow-list,.semi-rtl .semi-overflow-list{direction:rtl}.semi-input-number{align-items:center;box-sizing:border-box;display:inline-flex;-webkit-transform:scale(var(--semi-transform_scale-none));transform:scale(var(--semi-transform_scale-none));transition:background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none),border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none)}.semi-input-number-suffix-btns{background-color:var(--semi-color-bg-2);border:1px solid var(--semi-color-border);border-radius:var(--semi-border-radius-small);box-sizing:border-box;display:inline-flex;flex-direction:column;margin-left:4px}.semi-input-number-suffix-btns>.semi-input-number-button{align-items:center;border-radius:0;color:var(--semi-color-text-2);display:inline-flex;height:50%;justify-content:center;margin:0;padding:0;-webkit-user-select:none;user-select:none;width:14px}.semi-input-number-suffix-btns>.semi-input-number-button-down:not(.semi-input-number-button-down-not-allowed):hover,.semi-input-number-suffix-btns>.semi-input-number-button-up:not(.semi-input-number-button-up-not-allowed):hover{background-color:var(--semi-color-fill-0);cursor:pointer}.semi-input-number-suffix-btns>.semi-input-number-button-down:not(.semi-input-number-button-down-not-allowed):active,.semi-input-number-suffix-btns>.semi-input-number-button-up:not(.semi-input-number-button-up-not-allowed):active{background-color:var(--semi-color-fill-1);cursor:pointer}.semi-input-number-suffix-btns>.semi-input-number-button-down.semi-input-number-button-down-disabled,.semi-input-number-suffix-btns>.semi-input-number-button-up.semi-input-number-button-up-disabled{background-color:var(--semi-color-disabled-fill);color:var(--semi-color-disabled-text)}.semi-input-number-suffix-btns>.semi-input-number-button-down.semi-input-number-button-down-not-allowed,.semi-input-number-suffix-btns>.semi-input-number-button-up.semi-input-number-button-up-not-allowed{cursor:not-allowed}.semi-input-number-suffix-btns-inner-hover{border-color:var(--semi-color-fill-2)}.semi-input-number-suffix-btns-inner{margin-left:8px}.semi-input-number .semi-input-clearbtn+.semi-input-suffix{margin-left:-4px}.semi-input-number .semi-input-clearbtn+.semi-input-suffix .semi-input-number-suffix-btns-inner{margin-left:0}.semi-input-number-size-default .semi-input-number-suffix-btns{height:32px}.semi-input-number-size-default .semi-input-number-suffix-btns-inner{height:30px}.semi-input-number-size-large .semi-input-number-suffix-btns{height:40px}.semi-input-number-size-large .semi-input-number-suffix-btns-inner{height:38px}.semi-input-number-size-small .semi-input-number-suffix-btns{height:24px}.semi-input-number-size-small .semi-input-number-suffix-btns-inner{height:22px}.semi-input-number:not(:focus-within):not(:hover) .semi-input-borderless+.semi-input-number-suffix-btns{opacity:0}.semi-portal-rtl .semi-input-number,.semi-rtl .semi-input-number{direction:rtl}.semi-portal-rtl .semi-input-number-suffix-btns,.semi-rtl .semi-input-number-suffix-btns{margin-left:auto;margin-right:4px}.semi-portal-rtl .semi-input-number-suffix-btns-inner,.semi-rtl .semi-input-number-suffix-btns-inner{margin-left:auto;margin-right:8px}.semi-portal-rtl .semi-input-number .semi-input-clearbtn+.semi-input-suffix,.semi-rtl .semi-input-number .semi-input-clearbtn+.semi-input-suffix{margin-left:auto;margin-right:-4px}.semi-portal-rtl .semi-input-number .semi-input-clearbtn+.semi-input-suffix .semi-input-number-suffix-btns-inner,.semi-rtl .semi-input-number .semi-input-clearbtn+.semi-input-suffix .semi-input-number-suffix-btns-inner{margin-left:auto;margin-right:0}.semi-timeline{list-style:none;margin:0;padding:8px;width:100%}.semi-timeline-item{list-style:none;margin:0;padding:0 0 24px;position:relative}.semi-timeline-item-tail{border-left:1px solid var(--semi-color-text-3);height:calc(100% - 20px);left:4px;position:absolute;top:20px}.semi-timeline-item-head{border-radius:var(--semi-border-radius-circle);height:9px;position:absolute;top:5px;width:9px}.semi-timeline-item-head-ongoing{background-color:var(--semi-color-primary)}.semi-timeline-item-head-default{background-color:var(--semi-color-tertiary-light-active)}.semi-timeline-item-head-success{background-color:var(--semi-color-success)}.semi-timeline-item-head-warning{background-color:var(--semi-color-warning)}.semi-timeline-item-head-error{background-color:var(--semi-color-danger)}.semi-timeline-item-head-custom{align-self:center;border:0;border-radius:0;display:flex;height:auto;left:5px;position:absolute;top:10px;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);width:auto}.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-ongoing{background-color:initial;color:var(--semi-color-primary)}.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-success{background-color:initial;color:var(--semi-color-success)}.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-warning{background-color:initial;color:var(--semi-color-warning)}.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-error{background-color:initial;color:var(--semi-color-danger)}.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-default{background-color:initial;color:var(--semi-color-tertiary-light-active)}.semi-timeline-item-content{color:var(--semi-color-text-0);font-size:14px;line-height:20px;margin:0 0 0 25px;position:relative;word-break:break-word}.semi-timeline-item-content,.semi-timeline-item-content-extra,.semi-timeline-item-content-time{font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif}.semi-timeline-item-content-extra,.semi-timeline-item-content-time{color:var(--semi-color-text-2);font-size:12px;line-height:16px;margin-top:4px}.semi-timeline-item:last-child>.semi-timeline-item-tail{border-left:none}.semi-timeline-alternate .semi-timeline-item-head,.semi-timeline-alternate .semi-timeline-item-head-custom,.semi-timeline-alternate .semi-timeline-item-tail,.semi-timeline-center .semi-timeline-item-head,.semi-timeline-center .semi-timeline-item-head-custom,.semi-timeline-center .semi-timeline-item-tail,.semi-timeline-right .semi-timeline-item-head,.semi-timeline-right .semi-timeline-item-head-custom,.semi-timeline-right .semi-timeline-item-tail{left:50%}.semi-timeline-alternate .semi-timeline-item-head.semi-timeline-item-head-custom,.semi-timeline-center .semi-timeline-item-head.semi-timeline-item-head-custom,.semi-timeline-right .semi-timeline-item-head.semi-timeline-item-head-custom{margin-left:0}.semi-timeline-alternate .semi-timeline-item-head,.semi-timeline-center .semi-timeline-item-head,.semi-timeline-right .semi-timeline-item-head{margin-left:-4px}.semi-timeline-alternate .semi-timeline-item-left .semi-timeline-item-content,.semi-timeline-center .semi-timeline-item-left .semi-timeline-item-content,.semi-timeline-right .semi-timeline-item-left .semi-timeline-item-content{left:calc(50% - 4px);text-align:left;width:calc(50% - 14px)}.semi-timeline-alternate .semi-timeline-item-right .semi-timeline-item-content,.semi-timeline-center .semi-timeline-item-right .semi-timeline-item-content,.semi-timeline-right .semi-timeline-item-right .semi-timeline-item-content{margin:0;text-align:right;width:calc(50% - 20px)}.semi-timeline-center .semi-timeline-item-content-time{margin-left:calc(-40px - 100%);position:absolute;text-align:right;top:-2px;width:100%}.semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head,.semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head-custom,.semi-timeline-right .semi-timeline-item-right .semi-timeline-item-tail{left:calc(100% - 9px)}.semi-timeline-right .semi-timeline-item-right .semi-timeline-item-content{width:calc(100% - 28px)}.semi-portal-rtl .semi-timeline,.semi-rtl .semi-timeline{direction:rtl}.semi-portal-rtl .semi-timeline-item-tail,.semi-rtl .semi-timeline-item-tail{border-left:0;border-right:1px solid var(--semi-color-text-3);left:auto;right:4px}.semi-portal-rtl .semi-timeline-item-head-custom,.semi-rtl .semi-timeline-item-head-custom{left:auto;right:5px;-webkit-transform:translate(50%,-50%);transform:translate(50%,-50%)}.semi-portal-rtl .semi-timeline-item-content,.semi-rtl .semi-timeline-item-content{margin:0 25px 0 0}.semi-portal-rtl .semi-timeline-item:last-child .semi-timeline-item-tail,.semi-rtl .semi-timeline-item:last-child .semi-timeline-item-tail{border-right:none}.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-head,.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-head-custom,.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-tail,.semi-portal-rtl .semi-timeline-center .semi-timeline-item-head,.semi-portal-rtl .semi-timeline-center .semi-timeline-item-head-custom,.semi-portal-rtl .semi-timeline-center .semi-timeline-item-tail,.semi-portal-rtl .semi-timeline-right .semi-timeline-item-head,.semi-portal-rtl .semi-timeline-right .semi-timeline-item-head-custom,.semi-portal-rtl .semi-timeline-right .semi-timeline-item-tail,.semi-rtl .semi-timeline-alternate .semi-timeline-item-head,.semi-rtl .semi-timeline-alternate .semi-timeline-item-head-custom,.semi-rtl .semi-timeline-alternate .semi-timeline-item-tail,.semi-rtl .semi-timeline-center .semi-timeline-item-head,.semi-rtl .semi-timeline-center .semi-timeline-item-head-custom,.semi-rtl .semi-timeline-center .semi-timeline-item-tail,.semi-rtl .semi-timeline-right .semi-timeline-item-head,.semi-rtl .semi-timeline-right .semi-timeline-item-head-custom,.semi-rtl .semi-timeline-right .semi-timeline-item-tail{left:auto;right:50%}.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-head,.semi-portal-rtl .semi-timeline-center .semi-timeline-item-head,.semi-portal-rtl .semi-timeline-right .semi-timeline-item-head,.semi-rtl .semi-timeline-alternate .semi-timeline-item-head,.semi-rtl .semi-timeline-center .semi-timeline-item-head,.semi-rtl .semi-timeline-right .semi-timeline-item-head{margin-left:0;margin-right:-4px}.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-left .semi-timeline-item-content,.semi-portal-rtl .semi-timeline-center .semi-timeline-item-left .semi-timeline-item-content,.semi-portal-rtl .semi-timeline-right .semi-timeline-item-left .semi-timeline-item-content,.semi-rtl .semi-timeline-alternate .semi-timeline-item-left .semi-timeline-item-content,.semi-rtl .semi-timeline-center .semi-timeline-item-left .semi-timeline-item-content,.semi-rtl .semi-timeline-right .semi-timeline-item-left .semi-timeline-item-content{left:auto;right:calc(50% - 4px);text-align:right}.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-right .semi-timeline-item-content,.semi-portal-rtl .semi-timeline-center .semi-timeline-item-right .semi-timeline-item-content,.semi-portal-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-content,.semi-rtl .semi-timeline-alternate .semi-timeline-item-right .semi-timeline-item-content,.semi-rtl .semi-timeline-center .semi-timeline-item-right .semi-timeline-item-content,.semi-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-content{text-align:left}.semi-portal-rtl .semi-timeline-center .semi-timeline-item-content-time,.semi-rtl .semi-timeline-center .semi-timeline-item-content-time{margin-left:0;margin-right:calc(-40px - 100%);text-align:left}.semi-portal-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head,.semi-portal-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head-custom,.semi-portal-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-tail,.semi-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head,.semi-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head-custom,.semi-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-tail{left:0;right:calc(100% - 9px)}.semi-toast{pointer-events:none}.semi-toast-wrapper{display:flex;height:0;justify-content:center;position:fixed;top:0;width:100%;z-index:1010}.semi-toast-wrapper .semi-toast-innerWrapper{height:-webkit-fit-content;height:-moz-fit-content;height:fit-content;text-align:center;width:-webkit-fit-content;width:-moz-fit-content;width:fit-content}.semi-toast-wrapper .semi-toast-innerWrapper-hover .semi-toast-zero-height-wrapper{-webkit-perspective:none;-webkit-perspective:initial;perspective:none;-webkit-perspective-origin:50%;perspective-origin:50%}.semi-toast-zero-height-wrapper{height:0;overflow:visible;-webkit-perspective:280px;perspective:280px;-webkit-perspective-origin:center 280px;perspective-origin:center 280px;transition:all .3s cubic-bezier(.22,.57,.02,1.2)}.semi-toast-content{align-items:flex-start;background-color:var(--semi-color-bg-3);border-radius:var(--semi-border-radius-medium);box-shadow:var(--semi-shadow-elevated);color:var(--semi-color-text-0);display:inline-flex;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif;font-size:14px;font-weight:600;justify-content:center;line-height:20px;margin:12px;padding:12px 8px;pointer-events:all}.semi-toast-content .semi-toast-close-button{height:20px;margin-top:-2px}.semi-toast-content .semi-toast-content-text{word-wrap:break-word;margin-left:12px;margin-right:12px;overflow-wrap:break-word;text-align:left}.semi-toast-light.semi-toast-warning .semi-toast-content{background-color:var(--semi-color-warning-light-default);border:1px solid var(--semi-color-warning)}.semi-toast-light.semi-toast-warning .semi-toast-icon-warning{color:var(--semi-color-warning)}.semi-toast-light.semi-toast-success .semi-toast-content{background-color:var(--semi-color-success-light-default);border:1px solid var(--semi-color-success)}.semi-toast-light.semi-toast-success .semi-toast-icon-success{color:var(--semi-color-success)}.semi-toast-light.semi-toast-info .semi-toast-content{background-color:var(--semi-color-info-light-default);border:1px solid var(--semi-color-info)}.semi-toast-light.semi-toast-info .semi-toast-icon-info{color:var(--semi-color-info)}.semi-toast-light.semi-toast-error .semi-toast-content{background-color:var(--semi-color-danger-light-default);border:1px solid var(--semi-color-danger)}.semi-toast-light.semi-toast-error .semi-toast-icon-error{color:var(--semi-color-danger)}.semi-toast .semi-toast-icon-warning{color:var(--semi-color-warning)}.semi-toast .semi-toast-icon-success{color:var(--semi-color-success)}.semi-toast .semi-toast-icon-info{color:var(--semi-color-info)}.semi-toast .semi-toast-icon-error{color:var(--semi-color-danger)}.semi-toast-animation-show{-webkit-animation:semi-toast-keyframe-toast-show .3s cubic-bezier(.22,.57,.02,1.2) 0s;animation:semi-toast-keyframe-toast-show .3s cubic-bezier(.22,.57,.02,1.2) 0s;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}.semi-toast-animation-hide{-webkit-animation:semi-toast-keyframe-toast-hide .3s cubic-bezier(.22,.57,.02,1.2) 0s;animation:semi-toast-keyframe-toast-hide .3s cubic-bezier(.22,.57,.02,1.2) 0s;-webkit-animation-fill-mode:forwards;animation-fill-mode:forwards}@-webkit-keyframes semi-toast-keyframe-toast-show{0%{opacity:0;-webkit-transform:translateY(-100%);transform:translateY(-100%)}to{opacity:1}}@keyframes semi-toast-keyframe-toast-show{0%{opacity:0;-webkit-transform:translateY(-100%);transform:translateY(-100%)}to{opacity:1}}@-webkit-keyframes semi-toast-keyframe-toast-hide{0%{opacity:1}to{opacity:0;-webkit-transform:translateY(-100%);transform:translateY(-100%)}}@keyframes semi-toast-keyframe-toast-hide{0%{opacity:1}to{opacity:0;-webkit-transform:translateY(-100%);transform:translateY(-100%)}}.semi-toast-rtl{direction:rtl}.semi-toast-rtl .semi-toast-content .semi-toast-content-text{margin-left:12px;margin-right:12px;text-align:right}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;margin:0}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}::-webkit-scrollbar{height:10px;width:10px}::-webkit-scrollbar-track{box-shadow:inset 0 0 6px rgba(0,0,0,.3);-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,.3)}::-webkit-scrollbar-thumb{background-color:#c8c8c8;border-radius:7px;box-shadow:inset 0 0 6px rgba(0,0,0,.1);-webkit-box-shadow:inset 0 0 6px rgba(0,0,0,.1)}.exception-box textarea{white-space:nowrap!important} +/*# sourceMappingURL=main.8eb42378.css.map*/ \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/static/css/main.8eb42378.css.map b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/static/css/main.8eb42378.css.map new file mode 100644 index 000000000..dd7e09ad0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/static/css/main.8eb42378.css.map @@ -0,0 +1 @@ +{"version":3,"file":"static/css/main.8eb42378.css","mappings":"AAGA,WACE,sCAAsC,CACtC,qCAAqC,CACrC,mCAAmC,CACnC,qCAAqC,CACrC,mCAAmC,CACnC,qCAAqC,CACrC,sCAAsC,CACtC,mCAAmC,CACnC,wCAAwC,CACxC,oCAAoC,CACpC,yCAAyC,CACzC,2CAA2C,CAC3C,iDAAiD,CACjD,gCAAiC,CACjC,mCAAmC,CACnC,kCAAkC,CAClC,gCAAgC,CAChC,kCAAkC,CAClC,gCAAgC,CAChC,kCAAkC,CAClC,mCAAmC,CACnC,sCAAsC,CACtC,uCAAuC,CACvC,wCAAwC,CACxC,uCAAuC,CACvC,yCAAyC,CACzC,oDAAoD,CACpD,sDAAsD,CACtD,sDAAsD,CACtD,sDAAsD,CACtD,yDAAyD,CACzD,2DAA2D,CAC3D,2DAA2D,CAC3D,2DACF,CAEA,4DACE,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,uBAAwB,CACxB,sBAAuB,CACvB,kBAAmB,CACnB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAAyB,CACzB,uBAAwB,CACxB,sBAAuB,CACvB,sBAAuB,CACvB,sBAAuB,CACvB,sBAAuB,CACvB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAAyB,CACzB,wBAAyB,CACzB,uBAAwB,CACxB,uBAAwB,CACxB,uBAAwB,CACxB,qBAAsB,CACtB,qBAAsB,CACtB,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,uBAAwB,CACxB,uBAAwB,CACxB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,sBAAuB,CACvB,sBAAuB,CACvB,sBAAuB,CACvB,sBAAuB,CACvB,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,0BAA2B,CAC3B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAAyB,CACzB,+BAAgC,CAChC,+BAAgC,CAChC,+BAAgC,CAChC,8BAA+B,CAC/B,8BAA+B,CAC/B,6BAA8B,CAC9B,6BAA8B,CAC9B,4BAA6B,CAC7B,4BAA6B,CAC7B,2BAA4B,CAC5B,gCAAiC,CACjC,gCAAiC,CACjC,gCAAiC,CACjC,gCAAiC,CACjC,+BAAgC,CAChC,+BAAgC,CAChC,+BAAgC,CAChC,8BAA+B,CAC/B,6BAA8B,CAC9B,6BAA8B,CAC9B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAAyB,CACzB,wBAAyB,CACzB,uBAAwB,CACxB,uBAAwB,CACxB,sBAAuB,CACvB,sBAAuB,CACvB,qBAAsB,CACtB,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,0BAA2B,CAC3B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAAyB,CACzB,wBAAyB,CACzB,uBAAwB,CACxB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAAyB,CACzB,uBAAwB,CACxB,uBAAwB,CACxB,uBAAwB,CACxB,sBAAuB,CACvB,qBAAsB,CACtB,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,yBAA0B,CAC1B,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,uBAAwB,CACxB,sBAAuB,CACvB,sBAAuB,CACvB,sBAAuB,CACvB,oBAAqB,CACrB,oBAAqB,CACrB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAAyB,CACzB,wBAAyB,CACzB,uBAAwB,CACxB,uBAAwB,CACxB,uBAAwB,CACxB,qBAAsB,CACtB,qBAAsB,CACtB,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,0BAA2B,CAC3B,0BAA2B,CAC3B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAAyB,CACzB,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,0BAA2B,CAC3B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,uBACF,CAEA,8FACE,qBAAsB,CACtB,sBAAuB,CACvB,sBAAuB,CACvB,sBAAuB,CACvB,sBAAuB,CACvB,uBAAwB,CACxB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,qBAAsB,CACtB,uBAAwB,CACxB,uBAAwB,CACxB,uBAAwB,CACxB,wBAAyB,CACzB,wBAAyB,CACzB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAAyB,CACzB,yBAA0B,CAC1B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,0BAA2B,CAC3B,0BAA2B,CAC3B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,0BAA2B,CAC3B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,sBAAuB,CACvB,uBAAwB,CACxB,uBAAwB,CACxB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,2BAA4B,CAC5B,4BAA6B,CAC7B,6BAA8B,CAC9B,8BAA+B,CAC/B,8BAA+B,CAC/B,8BAA+B,CAC/B,+BAAgC,CAChC,+BAAgC,CAChC,+BAAgC,CAChC,+BAAgC,CAChC,qBAAsB,CACtB,qBAAsB,CACtB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,qBAAsB,CACtB,qBAAsB,CACtB,uBAAwB,CACxB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,uBAAwB,CACxB,uBAAwB,CACxB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,yBAA0B,CAC1B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,6BAA8B,CAC9B,6BAA8B,CAC9B,8BAA+B,CAC/B,+BAAgC,CAChC,+BAAgC,CAChC,+BAAgC,CAChC,gCAAiC,CACjC,gCAAiC,CACjC,gCAAiC,CACjC,gCAAiC,CACjC,qBAAsB,CACtB,sBAAuB,CACvB,uBAAwB,CACxB,wBAAyB,CACzB,wBAAyB,CACzB,wBAAyB,CACzB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,uBAAwB,CACxB,yBAA0B,CAC1B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,sBAAuB,CACvB,wBAAyB,CACzB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,uBAAwB,CACxB,wBAAyB,CACzB,yBAA0B,CAC1B,0BAA2B,CAC3B,0BAA2B,CAC3B,0BAA2B,CAC3B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,2BAA4B,CAC5B,sBAAuB,CACvB,sBAAuB,CACvB,sBAAuB,CACvB,sBAAuB,CACvB,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,yBAA0B,CAC1B,wBAA2B,CAC3B,kBACF,CAEA,6EAEE,kCAAmC,CACnC,4CAA8C,CAC9C,4CAA8C,CAC9C,+CAAiD,CACjD,qDAAuD,CACvD,sDAAwD,CACxD,wDAA0D,CAC1D,6DAA+D,CAC/D,2DAA6D,CAC7D,4DAA8D,CAC9D,uDAAyD,CACzD,6DAA+D,CAC/D,8DAAgE,CAChE,gEAAkE,CAClE,qEAAuE,CACvE,mEAAqE,CACrE,oEAAsE,CACtE,gDAAkD,CAClD,sDAAwD,CACxD,uDAAyD,CACzD,8DAAgE,CAChE,4DAA8D,CAC9D,6DAA+D,CAC/D,+CAAiD,CACjD,qDAAuD,CACvD,sDAAwD,CACxD,4CAA8C,CAC9C,kDAAoD,CACpD,mDAAqD,CACrD,qDAAuD,CACvD,0DAA4D,CAC5D,wDAA0D,CAC1D,yDAA2D,CAC3D,gDAAkD,CAClD,sDAAwD,CACxD,uDAAyD,CACzD,yDAA2D,CAC3D,8DAAgE,CAChE,4DAA8D,CAC9D,6DAA+D,CAC/D,6CAA+C,CAC/C,mDAAqD,CACrD,oDAAsD,CACtD,2DAA6D,CAC7D,yDAA2D,CAC3D,0DAA4D,CAC5D,iDAAmD,CACnD,uDAAyD,CACzD,wDAA0D,CAC1D,+DAAiE,CACjE,6DAA+D,CAC/D,8DAAgE,CAChE,oDAAsD,CACtD,uDAAyD,CACzD,uDAAyD,CACzD,mDAAqD,CACrD,uDAAyD,CACzD,+CAAiD,CACjD,4CAA8C,CAC9C,kDAAoD,CACpD,mDAAqD,CACrD,oDAAsD,CACtD,gDAAkD,CAClD,6CAA+C,CAC/C,yCAA6C,CAC7C,gDAAkD,CAClD,gDAAkD,CAClD,gDAAkD,CAClD,2CAA6C,CAC7C,2CAA6C,CAC7C,2CAA6C,CAC7C,2CAA6C,CAC7C,2CAA6C,CAC7C,8CAAgD,CAChD,+CAAiD,CACjD,gDAAkD,CAClD,gDAAkD,CAClD,uEAA+E,CAC/E,oCAAqC,CACrC,8BAA+B,CAC/B,+BAAgC,CAChC,+BAAgC,CAChC,+BAAgC,CAChC,gCAAiC,CACjC,sDAAwD,CACxD,gDAAkD,CAClD,2BAA0C,CAC1C,2BAA2C,CAC3C,2BAAyC,CACzC,2BAA2C,CAC3C,2BAA0C,CAC1C,2BAA0C,CAC1C,2BAA2C,CAC3C,2BAA2C,CAC3C,2BAA0C,CAC1C,2BAA2C,CAC3C,4BAA2C,CAC3C,4BAA4C,CAC5C,4BAA4C,CAC5C,4BAA4C,CAC5C,4BAA0C,CAC1C,4BAA4C,CAC5C,4BAA2C,CAC3C,4BAA4C,CAC5C,4BAA2C,CAC3C,4BAA4C,CA1G5C,kJA2GF,CAEA,8FAEE,kCAAmC,CACnC,0BAA0C,CAC1C,4CAA8C,CAC9C,+CAAiD,CACjD,qDAAuD,CACvD,sDAAwD,CACxD,wDAA0D,CAC1D,8DAAgE,CAChE,4DAA8D,CAC9D,6DAA+D,CAC/D,uDAAyD,CACzD,6DAA+D,CAC/D,8DAAgE,CAChE,gEAAkE,CAClE,sEAAwE,CACxE,oEAAsE,CACtE,qEAAuE,CACvE,gDAAkD,CAClD,sDAAwD,CACxD,uDAAyD,CACzD,+DAAiE,CACjE,6DAA+D,CAC/D,8DAAgE,CAChE,+CAAiD,CACjD,qDAAuD,CACvD,sDAAwD,CACxD,4CAA8C,CAC9C,kDAAoD,CACpD,mDAAqD,CACrD,qDAAuD,CACvD,2DAA6D,CAC7D,yDAA2D,CAC3D,0DAA4D,CAC5D,gDAAkD,CAClD,sDAAwD,CACxD,uDAAyD,CACzD,yDAA2D,CAC3D,+DAAiE,CACjE,6DAA+D,CAC/D,8DAAgE,CAChE,6CAA+C,CAC/C,mDAAqD,CACrD,oDAAsD,CACtD,4DAA8D,CAC9D,0DAA4D,CAC5D,2DAA6D,CAC7D,iDAAmD,CACnD,uDAAyD,CACzD,wDAA0D,CAC1D,gEAAkE,CAClE,8DAAgE,CAChE,+DAAiE,CACjE,oDAAsD,CACtD,uDAAyD,CACzD,uDAAyD,CACzD,mDAAqD,CACrD,uDAAyD,CACzD,4CAA8C,CAC9C,kDAAoD,CACpD,mDAAqD,CACrD,oDAAsD,CACtD,2BAAwC,CACxC,oFAA8F,CAC9F,yCAA6C,CAC7C,+CAAiD,CACjD,+CAAiD,CACjD,+CAAiD,CACjD,+CAAiD,CACjD,+CAAiD,CACjD,yBAAsC,CACtC,yBAAsC,CACtC,yBAAsC,CACtC,yBAAsC,CACtC,yBAAsC,CACtC,8CAAgD,CAChD,+CAAiD,CACjD,+CAAiD,CACjD,gDAAkD,CAClD,oCAAqC,CACrC,8BAA+B,CAC/B,+BAAgC,CAChC,+BAAgC,CAChC,+BAAgC,CAChC,gCAAiC,CACjC,sDAAwD,CACxD,gDAAkD,CAClD,2BAA0C,CAC1C,2BAAyC,CACzC,2BAA0C,CAC1C,2BAAyC,CACzC,2BAA0C,CAC1C,2BAA0C,CAC1C,2BAAyC,CACzC,2BAAyC,CACzC,2BAA0C,CAC1C,2BAA0C,CAC1C,4BAA2C,CAC3C,4BAA2C,CAC3C,4BAA2C,CAC3C,4BAA0C,CAC1C,4BAA4C,CAC5C,4BAA0C,CAC1C,4BAA2C,CAC3C,4BAAyC,CACzC,4BAA2C,CAC3C,4BAA0C,CA1G1C,kJA2GF,CAEA,mFAEE,UAAW,CADX,SAEF,CACA,+FACE,sBACF,CACA,iGACE,4BACF,CACA,+FAEE,sBAAuB,CADvB,iBAAkB,CAElB,yBAAkB,CAAlB,iBACF,CACA,2GACE,mCACF,CACA,2GACE,mCACF,CC/lBA,cAIE,WAAY,CAFZ,qBAAsB,CAItB,cAAe,CAEf,eAAgB,CAPhB,cAAe,CAEf,WAAY,CAIZ,iBAAkB,CAFlB,UAIF,CAEA,uDAEE,aAAc,CAEd,UAAW,CADX,UAEF,CChBA,mCAEE,kBAAmB,CADnB,mBAEF,CACA,wDAEE,kBAAmB,CADnB,YAAa,CAEb,sBACF,CACA,iCAEE,kBAAmB,CADnB,mBAEF,CACA,0DAGE,2DAAqD,CAArD,mDAAqD,CACrD,oCAA6B,CAA7B,4BAA6B,CAF7B,WAAY,CADZ,UAIF,CACA,wCAME,kBAAmB,CADnB,sBAAuB,CADvB,WAGF,CACA,+DAIE,WACF,CACA,+DAIE,YACF,CACA,0BACE,gBACF,CACA,2BACE,eACF,CC5CA,mBACE,oBACF,CACA,gCACE,eAAgB,CAChB,gBACF,CACA,sCAEE,yDAA0D,CAD1D,sDAEF,CACA,qCAEE,0DAA2D,CAD3D,uDAAwD,CAExD,cACF,CACA,wDACE,yCACF,CAEA,aAOE,kBAAmB,CAInB,0BAA2B,CAC3B,6CAA8C,CAX9C,eAAgB,CAQhB,cAAe,CAHf,mBAAoB,CAFpB,kJAAyK,CAFzK,cAAe,CAef,eAAgB,CAZhB,WAAY,CAGZ,sBAAuB,CALvB,gBAAiB,CAejB,YAAa,CAFb,gBAAmB,CANnB,wBAAiB,CAAjB,gBAAiB,CASjB,qBAAsB,CACtB,kBACF,CACA,6OACE,wDACF,CACA,oBACE,yCAA0C,CAC1C,+BAAiC,CAEjC,yDAAkD,CAAlD,iDAAkD,CADlD,8PAEF,CACA,6BACE,8CACF,CACA,iDACE,wBAA6B,CAC7B,yCACF,CACA,+CACE,yCACF,CACA,0BACE,+CACF,CACA,2BACE,gDACF,CACA,wCACE,wBAA6B,CAC7B,yCACF,CACA,yHACE,8BACF,CACA,uFACE,uDACF,CACA,qBACE,0CAA2C,CAC3C,+BAAiC,CAEjC,yDAAkD,CAAlD,iDAAkD,CADlD,8PAEF,CACA,8BACE,8CACF,CACA,kDACE,wBAA6B,CAC7B,yCACF,CACA,gDACE,yCACF,CACA,2BACE,gDACF,CACA,4BACE,iDACF,CACA,yCACE,wBAA6B,CAC7B,0CACF,CACA,4HACE,+BACF,CACA,wFACE,wDACF,CACA,sBACE,2CAA4C,CAC5C,+BAAiC,CAEjC,yDAAkD,CAAlD,iDAAkD,CADlD,8PAEF,CACA,+BACE,8CACF,CACA,mDACE,wBAA6B,CAC7B,yCACF,CACA,iDACE,yCACF,CACA,4BACE,iDACF,CACA,6BACE,kDACF,CACA,0CACE,wBAA6B,CAC7B,yCACF,CACA,+HACE,8BACF,CACA,qBACE,0CAA2C,CAC3C,+BAAiC,CAEjC,yDAAkD,CAAlD,iDAAkD,CADlD,8PAEF,CACA,8BACE,8CACF,CACA,gDACE,mCACF,CACA,kDACE,wBAA6B,CAC7B,yCACF,CACA,0GACE,gDACF,CACA,yCACE,wBAA6B,CAC7B,yCACF,CACA,2GACE,iDACF,CACA,4HACE,+BACF,CACA,uBACE,4CAA6C,CAE7C,+BAAiC,CADjC,yCAA0C,CAG1C,yDAAkD,CAAlD,iDAAkD,CADlD,8PAEF,CACA,gCACE,8CACF,CACA,oDACE,wBAA6B,CAC7B,yCACF,CACA,kDACE,yCACF,CACA,2CACE,wBAA6B,CAC7B,yCACF,CACA,6BACE,kDACF,CACA,8BACE,mDACF,CACA,kIACE,iCACF,CACA,sBAEE,kBACF,CAIA,4LACE,qCACF,CACA,wBACE,wBAA6B,CAC7B,0BAA2B,CAE3B,yDAAkD,CAAlD,iDAAkD,CADlD,yIAEF,CACA,yDACE,yCAA0C,CAC1C,0BACF,CACA,0DACE,yCAA0C,CAC1C,0BACF,CACA,qBACE,wBACF,CACA,sDACE,yCACF,CACA,uDACE,yCACF,CACA,mBACE,yCAA0C,CAC1C,0BAA2B,CAE3B,yDAAkD,CAAlD,iDAAkD,CADlD,8PAEF,CACA,oDACE,yCAA0C,CAC1C,0BACF,CACA,qDACE,yCAA0C,CAC1C,0BACF,CACA,wBACE,WAAY,CAIZ,gBACF,CACA,wBACE,WAAY,CAIZ,iBACF,CACA,mBACE,UACF,CACA,mBACE,YAAa,CACb,cACF,CACA,gCAIE,eAAgB,CAHhB,QAAS,CACT,cAAe,CACf,eAEF,CACA,qDACE,iBAAkB,CAClB,kBACF,CACA,gEACE,iBAAkB,CAClB,kBACF,CACA,gEACE,iBAAkB,CAClB,kBACF,CACA,2DACE,cAAe,CACf,eACF,CACA,gFACE,gBAAiB,CACjB,iBACF,CACA,uGACE,gBAAiB,CACjB,iBACF,CACA,uGACE,iBAAkB,CAClB,kBACF,CACA,4CAEE,yDAA0D,CAD1D,sDAEF,CACA,2CAEE,0DAA2D,CAD3D,uDAEF,CACA,yDACE,8BAA+B,CAC/B,iBACF,CACA,wBAEE,kBAAmB,CACnB,yCAA0C,CAF1C,mBAGF,CACA,gCACE,0CACF,CACA,kCACE,4CACF,CACA,iCACE,2CACF,CACA,gCACE,0CACF,CACA,+BACE,yCACF,CACA,iCACE,8CACF,CACA,8BACE,yCACF,CACA,mCACE,wBACF,CACA,+BAKE,yCAA0C,CAH1C,UAAW,CADX,aAAc,CAGd,WAAY,CADZ,SAGF,CAEA,qDAEE,aAAc,CACd,iBAAkB,CAClB,kBACF,CACA,2EAEE,iBAAkB,CAClB,kBACF,CACA,2EAEE,iBAAkB,CAClB,kBACF,CACA,iEAEE,aACF,CACA,2FAEE,cAAe,CACf,eACF,CACA,qIAEE,iBAAkB,CAClB,kBACF,CACA,2JAEE,iBAAkB,CAClB,kBACF,CACA,2JAEE,iBAAkB,CAClB,kBACF,CACA,iJAEE,cAAe,CACf,eACF,CACA,2LAEE,gBAAiB,CACjB,iBACF,CACA,yOAEE,gBAAiB,CACjB,iBACF,CACA,yOAEE,iBAAkB,CAClB,kBACF,CACA,mHAGE,2BAA4B,CAE5B,0DAA2D,CAH3D,wBAAyB,CAEzB,uDAEF,CACA,uKAEE,8CAA+C,CAC/C,cACF,CACA,iHAKE,yDAA0D,CAF1D,4BAA6B,CAC7B,sDAAuD,CAFvD,yBAIF,CACA,2GAEE,gBAAiB,CACjB,iBACF,CACA,yJAEE,gBAAiB,CACjB,iBACF,CACA,yJAEE,iBAAkB,CAClB,kBACF,CACA,+EAEE,eAAgB,CAChB,cACF,CACA,iFAGE,aAAc,CADd,gBAEF,CCncA,WAOE,iBAAkB,CANlB,oBAAqB,CACrB,iBAAkB,CAClB,aAAc,CACd,iBAAkB,CAElB,iCAAkC,CADlC,mBAGF,CAEA,uBACE,aACF,CAEA,iBACE,cACF,CAEA,mBACE,cACF,CAEA,iBACE,cACF,CAEA,uBACE,cACF,CAEA,oBACE,gEAA0D,CAA1D,wDAA0D,CAC1D,oCAA6B,CAA7B,4BACF,CAEA,8CACE,GACE,2BAAoB,CAApB,mBACF,CACA,GACE,+BAAyB,CAAzB,uBACF,CACF,CAPA,sCACE,GACE,2BAAoB,CAApB,mBACF,CACA,GACE,+BAAyB,CAAzB,uBACF,CACF,CC1CA,mBACE,gBACF,CACA,2FAME,QAAS,CAFT,QAAS,CACT,SAEF,CACA,sBACE,kBACF,CACA,2CACE,QAAS,CACT,mBAAoB,CACpB,eAAgB,CAChB,kBACF,CACA,uBAOE,8BAA+B,CAF/B,eAAgB,CAChB,kBAEF,CACA,gDALE,kJAAyK,CAFzK,cAAe,CADf,eAAmB,CAEnB,gBAYF,CANA,yBAKE,8BACF,CACA,qDACE,gBACF,CAQA,iNACE,eACF,CACA,sDACE,gBACF,CACA,kGAEE,oBACF,CACA,kDACE,gBACF,CACA,4DACE,qBACF,CACA,gCACE,YAAa,CACb,cACF,CACA,6BACE,mBAAoB,CACpB,qBACF,CACA,kDAEE,QAAO,CADP,SAEF,CACA,mDACE,eACF,CACA,wDACE,kBACF,CACA,uDAKE,cAAe,CAHf,gBAAiB,CAEjB,gBAEF,CACA,gHAJE,kJASF,CALA,yDAIE,cAAe,CAFf,gBAGF,CACA,yDACE,kBACF,CACA,wDAEE,cAAe,CADf,kBAEF,CACA,0DAGE,kJAAyK,CACzK,cAAe,CAFf,gBAGF,CACA,wDACE,kBACF,CACA,uDAEE,cAAe,CADf,kBAEF,CACA,yDAGE,kJAAyK,CACzK,cAAe,CAFf,gBAGF,CACA,oCACE,kBACF,CACA,wEACE,UACF,CACA,sDACE,QACF,CAEA,iEAEE,aACF,CACA,uEAEE,aAAc,CAEd,iBAAkB,CADlB,eAEF,CACA,iHAEE,gBACF,CACA,qIAEE,eACF,CACA,qIAEE,gBACF,CACA,kQAIE,eACF,CACA,uIAEE,gBACF,CACA,uIAEE,eACF,CACA,0PAIE,oBACF,CACA,+HAEE,cAAe,CACf,iBACF,CACA,mJAEE,qBACF,CACA,+EAEE,aACF,CACA,+HAEE,gBACF,CACA,2IAGE,iBAAkB,CADlB,eAEF,CACA,6IAGE,iBAAkB,CADlB,eAEF,CACA,2IAGE,iBAAkB,CADlB,eAEF,CC1MA,cAEE,gDAAiD,CAEjD,qBAAsB,CADtB,8BAA+B,CAF/B,YAIF,CACA,qBACE,0BACF,CACA,yBAEE,YAAa,CADb,UAEF,CACA,uBACE,eAAgB,CAGhB,8CAA+C,CAF/C,oBAAqB,CAGrB,WAAY,CAFZ,YAAuB,CAGvB,qBACF,CACA,wBAIE,kBAAmB,CAFnB,eAAgB,CADhB,YAAa,CAEb,kBAEF,CACA,iDAGE,oBAAqB,CAFrB,eAAgB,CAChB,aAEF,CACA,6DAGE,gDAAiD,CAFjD,UAAW,CACX,SAEF,CACA,oCACE,UACF,CAIA,wEACE,QACF,CACA,oCACE,UACF,CAEA,uDACE,iDACF,CAEA,2CACE,+CACF,CCxDA,YAME,8BAA+B,CAH/B,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CAGjB,gBAAiB,CADjB,iBAGF,CACA,iBAME,6CAA8C,CAD9C,QAAS,CAET,WAAY,CAHZ,MAAO,CAHP,cAAe,CAEf,OAAQ,CADR,KAAM,CAMN,YACF,CACA,wBACE,YACF,CACA,yBACE,mBAAoB,CACpB,iBAAkB,CAClB,UACF,CACA,iBAQE,gCAAiC,CAHjC,QAAS,CACT,MAAO,CAGP,SAAU,CAPV,aAAc,CADd,cAAe,CAGf,OAAQ,CADR,KAAM,CAIN,YAGF,CACA,wBAEE,kBAAmB,CADnB,YAEF,CACA,kBAEE,sBAAuB,CADvB,mBAAoB,CAEpB,0BAA2B,CAE3B,QAAS,CADT,UAEF,CACA,oBAWE,2BAA4B,CAJ5B,uCAAwC,CACxC,yCAA0C,CAC1C,6CAA8C,CAI9C,sCAAuC,CARvC,qBAAsB,CAHtB,YAAa,CAIb,qBAAsB,CAHtB,WAAY,CASZ,eAAgB,CAFhB,cAAe,CATf,iBAAkB,CAGlB,UAUF,CACA,uBACE,YACF,CACA,+BAEE,WAAY,CADZ,eAAgB,CAEhB,KACF,CACA,mBAOE,wBAA6B,CAE7B,iCAAkC,CADlC,8BAA+B,CAH/B,cAAe,CACf,eAAgB,CAFhB,SAMF,CACA,4CATE,sBAAuB,CADvB,YAAa,CAEb,aAYF,CACA,iBACE,aAAc,CACd,QAAS,CACT,SACF,CACA,qBACE,gBACF,CACA,mBAME,wBAA6B,CAF7B,yBAA0B,CAC1B,8BAA+B,CAJ/B,aAAc,CACd,SAAY,CACZ,gBAIF,CACA,gCACE,gBAAiB,CACjB,cACF,CACA,uCACE,iBACF,CACA,iCACE,mBAAoB,CACpB,iBAAkB,CAClB,UACF,CACA,yBAEE,+BAAgC,CADhC,mBAEF,CACA,sBACE,4BACF,CACA,yBACE,+BACF,CACA,uBACE,8BACF,CACA,yBACE,+BACF,CACA,kBACE,WACF,CACA,mBACE,WACF,CACA,kBACE,WACF,CACA,uBACE,wBACF,CAEA,qBACE,aACF,CAEA,sEAGE,eAAgB,CADhB,iBAEF,CAEA,sEAGE,eAAgB,CADhB,cAEF,CAEA,wBACE,YACF,CAEA,iCACE,kGAAkG,CAAlG,0FAAkG,CAClG,oCAA6B,CAA7B,4BACF,CAEA,iCACE,kGAAkG,CAAlG,0FAAkG,CAClG,oCAA6B,CAA7B,4BACF,CAEA,8BACE,+FAA8F,CAA9F,uFAA8F,CAC9F,oCAA6B,CAA7B,4BACF,CAEA,8BACE,+FAA8F,CAA9F,uFAA8F,CAC9F,oCAA6B,CAA7B,4BACF,CAEA,oDACE,GACE,SAAU,CACV,2BAAqB,CAArB,mBACF,CACA,GACE,SAAU,CACV,0BAAmB,CAAnB,kBACF,CACF,CATA,4CACE,GACE,SAAU,CACV,2BAAqB,CAArB,mBACF,CACA,GACE,SAAU,CACV,0BAAmB,CAAnB,kBACF,CACF,CACA,oDACE,GACE,SAAU,CACV,0BAAmB,CAAnB,kBACF,CACA,GACE,SAAU,CACV,2BAAqB,CAArB,mBACF,CACF,CATA,4CACE,GACE,SAAU,CACV,0BAAmB,CAAnB,kBACF,CACA,GACE,SAAU,CACV,2BAAqB,CAArB,mBACF,CACF,CACA,iDACE,GACE,SACF,CACA,GACE,SACF,CACF,CAPA,yCACE,GACE,SACF,CACA,GACE,SACF,CACF,CACA,iDACE,GACE,SACF,CACA,GACE,SACF,CACF,CAPA,yCACE,GACE,SACF,CACA,GACE,SACF,CACF,CACA,gBACE,aACF,CACA,0FAEE,gBAAiB,CADjB,cAEF,CACA,kFACE,aAAc,CACd,iBACF,CACA,8EACE,eACF,CACA,wGACE,aAAc,CACd,iBACF,CACA,wBACE,aACF,CACA,yDACE,aAAc,CACd,iBACF,CCpPA,aAGE,MAAO,CAFP,iBAAkB,CAClB,KAAM,CAEN,UAAW,CACX,SACF,CACA,mBAEE,wBAA6B,CAC7B,6BAAsB,CAAtB,qBAAsB,CAFtB,iBAGF,CCXA,iBACE,8BAA+B,CAG/B,kJAAyK,CAFzK,cAAe,CACf,gBAEF,CACA,2CACE,8BACF,CACA,0CACE,8BACF,CACA,4CACE,8BACF,CACA,yCACE,+BACF,CACA,yCACE,+BACF,CACA,wCACE,8BACF,CACA,sCACE,4BAA6B,CAC7B,eACF,CACA,0CACE,qCAAsC,CACtC,kBAAmB,CACnB,wBAAiB,CAAjB,gBACF,CACA,+DACE,4BACF,CACA,sBAGE,aAAc,CAFd,gBAAiB,CACjB,qBAEF,CACA,uBAGE,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAGF,CACA,iDACE,eACF,CACA,sBAIE,yCAA0C,CAH1C,yCAA0C,CAC1C,iBAAkB,CAClB,8BAA+B,CAE/B,eACF,CACA,sBACE,wDACF,CACA,mBAEE,gCAA8B,CAD9B,yBAA0B,CAC1B,6BACF,CACA,qBACE,4BACF,CACA,wBACE,eACF,CACA,mBAEE,4BAA6B,CAC7B,cAAe,CAFf,cAAe,CAGf,oBACF,CACA,2BACE,oCACF,CACA,yBACE,kCACF,CACA,0BACE,mCACF,CACA,yDACE,oDAAqD,CACrD,kBACF,CACA,0DACE,qDAAsD,CACtD,kBACF,CACA,sCACE,eACF,CACA,wCAEE,2BAA4B,CAD5B,mBAAoB,CAEpB,eACF,CACA,oFACE,0BACF,CACA,4CACE,aAAc,CAEd,sBAAuB,CADvB,kBAEF,CACA,4FACE,oBAAqB,CACrB,cAAe,CACf,kBACF,CACA,iCACE,cAAe,CACf,eACF,CACA,6BACE,mBAAoB,CAGpB,eAAgB,CADhB,SAAU,CADV,qBAGF,CACA,oDACE,mBACF,CACA,+BAIE,8BAA+B,CAH/B,mBAAoB,CAEpB,eAAgB,CADhB,SAGF,CACA,0CAEE,+BAAgC,CADhC,qBAEF,CACA,2BACE,QACF,CAEA,uDAIE,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAAiB,CAGjB,QACF,CACA,uHAEE,eACF,CACA,2HAEE,eACF,CACA,yHAEE,eACF,CACA,6HAEE,eACF,CACA,qHAEE,eACF,CAEA,uDAIE,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAAiB,CAGjB,QACF,CACA,uHAEE,eACF,CACA,2HAEE,eACF,CACA,yHAEE,eACF,CACA,6HAEE,eACF,CACA,qHAEE,eACF,CAEA,uDAIE,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAAiB,CAGjB,QACF,CACA,uHAEE,eACF,CACA,2HAEE,eACF,CACA,yHAEE,eACF,CACA,6HAEE,eACF,CACA,qHAEE,eACF,CAEA,uDAIE,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAAiB,CAGjB,QACF,CACA,uHAEE,eACF,CACA,2HAEE,eACF,CACA,yHAEE,eACF,CACA,6HAEE,eACF,CACA,qHAEE,eACF,CAEA,uDAIE,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAAiB,CAGjB,QACF,CACA,uHAEE,eACF,CACA,2HAEE,eACF,CACA,yHAEE,eACF,CACA,6HAEE,eACF,CACA,qHAEE,eACF,CAEA,uDAIE,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAAiB,CAGjB,QACF,CACA,uHAEE,eACF,CACA,2HAEE,eACF,CACA,yHAEE,eACF,CACA,6HAEE,eACF,CACA,qHAEE,eACF,CAEA,+EAGE,eAAgB,CADhB,gBAEF,CAEA,6DAEE,aACF,CACA,4IAIE,oBACF,CACA,uEAGE,eAAgB,CADhB,iBAEF,CACA,6FAEE,gBACF,CAMA,8KAHE,gBAAiB,CACjB,gBAMF,CC7VA,uCACE,GACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,IACE,SACF,CACF,CARA,+BACE,GACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,IACE,SACF,CACF,CACA,yCACE,GACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,IACE,SAAU,CACV,6BAA4B,CAA5B,qBACF,CACA,GAGE,+DAA8D,CAA9D,uDAA8D,CAF9D,SAAU,CACV,0BAAsB,CAAtB,kBAEF,CACF,CAdA,iCACE,GACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,IACE,SAAU,CACV,6BAA4B,CAA5B,qBACF,CACA,GAGE,+DAA8D,CAA9D,uDAA8D,CAF9D,SAAU,CACV,0BAAsB,CAAtB,kBAEF,CACF,CACA,wCACE,GACE,SACF,CACA,IACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,GACE,SACF,CACF,CAXA,gCACE,GACE,SACF,CACA,IACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,GACE,SACF,CACF,CACA,sBAYE,oBAAqB,CAVrB,2CAA6C,CAE7C,8CAA+C,CAD/C,4BAA6B,CAa7B,kJAAyK,CAFzK,cAAe,CAJf,MAAO,CAKP,gBAAiB,CAGjB,eAAgB,CADhB,SAAU,CAJV,wBAAyB,CALzB,gBAAkB,CAPlB,iBAAkB,CAUlB,KAQF,CACA,2BACE,SACF,CACA,sBACE,WACF,CACA,sBACE,oBAAqB,CAErB,WAAY,CADZ,UAEF,CACA,yBAEE,kBAAmB,CAEnB,qBAAsB,CAHtB,YAAa,CAEb,sBAEF,CACA,6BACE,uEAAwE,CAAxE,+DAAwE,CACxE,oCAA6B,CAA7B,4BACF,CACA,6BACE,wEAAyE,CAAzE,gEAAyE,CACzE,oCAA6B,CAA7B,4BACF,CAEA,+CAIE,gCAAkC,CAHlC,UAAW,CAEX,iBAAkB,CADlB,UAGF,CACA,gEAGE,WAAY,CAFZ,QAAS,CACT,kCAA2B,CAA3B,0BAEF,CACA,+HAEE,cACF,CACA,oEACE,WAAY,CACZ,QACF,CACA,uIAEE,cACF,CACA,qEACE,WAAY,CACZ,SACF,CACA,yIAEE,cACF,CACA,oEAEE,WAAY,CACZ,UAAW,CACX,OAAQ,CAHR,SAIF,CACA,uIAEE,eACF,CACA,iEAEE,WAAY,CACZ,UAAW,CACX,OAAQ,CACR,kCAA2B,CAA3B,0BAA2B,CAJ3B,SAKF,CACA,iIAEE,eACF,CACA,uEAIE,UAAW,CAFX,WAAY,CACZ,UAAW,CAFX,SAIF,CACA,6IAEE,eACF,CACA,qEAEE,WAAY,CACZ,SAAU,CACV,OAAQ,CACR,gCAAyB,CAAzB,wBAAyB,CAJzB,SAKF,CACA,yIAEE,eACF,CACA,kEAEE,WAAY,CACZ,SAAU,CACV,OAAQ,CACR,iDAA0C,CAA1C,yCAA0C,CAJ1C,SAKF,CACA,mIAEE,eACF,CACA,wEAIE,UAAW,CAFX,WAAY,CACZ,SAAU,CAEV,gCAAyB,CAAzB,wBAAyB,CAJzB,SAKF,CACA,+IAEE,eACF,CACA,uEAEE,QAAS,CADT,QAAS,CAET,gCAAyB,CAAzB,wBACF,CACA,6IAEE,cACF,CACA,mEAEE,QAAS,CADT,QAAS,CAET,iDAA0C,CAA1C,yCACF,CACA,qIAEE,cACF,CACA,wEACE,SAAU,CACV,QAAS,CACT,gCAAyB,CAAzB,wBACF,CACA,+IAEE,cACF,CAEA,uEAEE,aAAc,CAGd,SAAU,CADV,iBAAkB,CADlB,kBAAmB,CAGnB,OACF,CCpNA,uCACE,GACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,IACE,SACF,CACF,CARA,+BACE,GACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,IACE,SACF,CACF,CACA,wCACE,GACE,SACF,CACA,IACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,GACE,SACF,CACF,CAXA,gCACE,GACE,SACF,CACA,IACE,SAAU,CACV,2BAA0B,CAA1B,mBACF,CACA,GACE,SACF,CACF,CACA,sBAEE,uCAAwC,CAGxC,8CAA+C,CAF/C,sCAAuC,CAKvC,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CAEjB,SAAU,CARV,iBAAkB,CAGlB,YAMF,CACA,2BACE,SACF,CACA,sBACE,oBAAqB,CAErB,WAAY,CADZ,UAEF,CACA,oBAEE,gDAAiD,CADjD,WAEF,CACA,sBACE,iBACF,CACA,yBAEE,qBAAsB,CADtB,YAEF,CACA,6BACE,uEAAwE,CAAxE,+DAAwE,CACxE,oCAA6B,CAA7B,4BACF,CACA,6BACE,wEAAyE,CAAzE,gEAAyE,CACzE,oCAA6B,CAA7B,4BACF,CAEA,+CAIE,aAAY,CAHZ,UAAW,CAEX,iBAAkB,CADlB,UAGF,CACA,gEAGE,WAAY,CAFZ,QAAS,CACT,kCAA2B,CAA3B,0BAEF,CACA,+HAEE,cACF,CACA,oEACE,WAAY,CACZ,QACF,CACA,uIAEE,cACF,CACA,qEACE,WAAY,CACZ,SACF,CACA,yIAEE,cACF,CACA,oEAEE,WAAY,CACZ,UAAW,CACX,OAAQ,CAHR,SAIF,CACA,uIAEE,eACF,CACA,iEAEE,WAAY,CACZ,UAAW,CACX,OAAQ,CACR,kCAA2B,CAA3B,0BAA2B,CAJ3B,SAKF,CACA,iIAEE,eACF,CACA,uEAIE,UAAW,CAFX,WAAY,CACZ,UAAW,CAFX,SAIF,CACA,6IAEE,eACF,CACA,qEAEE,WAAY,CACZ,SAAU,CACV,OAAQ,CACR,gCAAyB,CAAzB,wBAAyB,CAJzB,SAKF,CACA,yIAEE,eACF,CACA,kEAEE,WAAY,CACZ,SAAU,CACV,OAAQ,CACR,iDAA0C,CAA1C,yCAA0C,CAJ1C,SAKF,CACA,mIAEE,eACF,CACA,wEAIE,UAAW,CAFX,WAAY,CACZ,SAAU,CAEV,gCAAyB,CAAzB,wBAAyB,CAJzB,SAKF,CACA,+IAEE,eACF,CACA,uEAEE,QAAS,CADT,QAAS,CAET,gCAAyB,CAAzB,wBACF,CACA,6IAEE,cACF,CACA,mEAEE,QAAS,CADT,QAAS,CAET,iDAA0C,CAA1C,yCACF,CACA,qIAEE,cACF,CACA,wEACE,SAAU,CACV,QAAS,CACT,gCAAyB,CAAzB,wBACF,CACA,+IAEE,cACF,CAEA,+BACE,aACF,CCzLA,eAGE,kJAAyK,CAFzK,cAAe,CACf,gBAEF,CACA,uBAME,iCAAkC,CADlC,8CAA+C,CAH/C,sCAAuC,CAKvC,SAAU,CANV,eAAgB,CAEhB,iBAAkB,CAClB,YAIF,CACA,4BACE,SACF,CACA,uBACE,oBACF,CACA,oBACE,eAAgB,CAEhB,QAAS,CADT,aAEF,CACA,qBACE,8BAA+B,CAQ/B,cAAe,CADf,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CAFjB,oBAKF,CACA,8BACE,iBACF,CACA,oBAKE,kBAAmB,CAHnB,8BAA+B,CAE/B,YAAa,CADb,eAAgB,CAFhB,gBAAiB,CAKjB,4GACF,CACA,0BACE,yCACF,CACA,0DACE,yCAA0C,CAC1C,cACF,CACA,2DACE,yCACF,CACA,kCACE,yCAA0C,CAC1C,SACF,CACA,yBAEE,kBAAmB,CADnB,mBAAoB,CAEpB,gBACF,CACA,2BACE,8BACF,CACA,8BACE,iCACF,CACA,4BACE,+BACF,CACA,6BACE,gCACF,CACA,4BACE,+BACF,CACA,6BACE,iBACF,CACA,+BACE,aAAc,CAEd,cAAe,CADf,gBAEF,CACA,2BACE,eACF,CACA,gDACE,qCAAsC,CACtC,kBACF,CACA,6GAEE,wBAA6B,CAD7B,kBAEF,CACA,uBAME,mCAAoC,CADpC,UAAW,CAJX,aAAc,CACd,UAAW,CAKX,YAAa,CAHb,cAAe,CADf,UAKF,CAEA,yEAEE,aACF,CACA,uFAEE,cAAe,CACf,kBACF,CACA,qFAEE,iBAAkB,CAClB,kBACF,CACA,yFAGE,eAAgB,CADhB,cAEF,CChIA,aACE,YAAa,CACb,SAAU,CACV,qBAAsB,CACtB,eACF,CACA,yHACE,qBACF,CACA,wCACE,aACF,CACA,qBACE,SAAU,CACV,eACF,CACA,mBAEE,cAAe,CADf,iBAEF,CACA,4BACE,WAAY,CACZ,gBAAkB,CAClB,gBACF,CAEA,uBACE,kBACF,CACA,gFACE,iBACF,CAEA,qDAEE,aACF,CCpCA,6BAQE,yCAA0C,CAH1C,4BAA6B,CAC7B,6CAA8C,CAL9C,qBAAsB,CACtB,oBAAqB,CACrB,iBAAkB,CAMlB,8PAAgQ,CAFhQ,qBAAsB,CAHtB,UAMF,CACA,mCACE,yCACF,CACA,mCAEE,+CACF,CACA,sHAHE,yCAKF,CACA,oCACE,yCACF,CACA,kDAIE,8BAA+B,CAE/B,WAAY,CAHZ,cAAe,CAFf,iBAAkB,CAIlB,SAAU,CAHV,KAKF,CACA,sDACE,mBACF,CACA,wDACE,cACF,CACA,mEACE,qCACF,CACA,yDACE,iBACF,CACA,4EAGE,gDAAiD,CADjD,qCAAsC,CADtC,kBAGF,CACA,wFACE,gDACF,CACA,kIACE,qCACF,CAFA,sGACE,qCACF,CACA,sCACE,WACF,CACA,mCACE,uDAAwD,CACxD,mDACF,CACA,yCACE,qDAAsD,CACtD,iDACF,CACA,qEACE,uDAAwD,CACxD,qCACF,CACA,0CACE,sDAAuD,CACvD,qCACF,CACA,qCACE,wDAAyD,CACzD,oDACF,CACA,2CACE,sDAAuD,CACvD,kDACF,CACA,uEACE,wDAAyD,CACzD,sCACF,CACA,4CACE,uDAAwD,CACxD,sCACF,CAEA,qBAQE,wBAA6B,CAC7B,0BAA2B,CAL3B,eAAgB,CAUhB,qBAAsB,CACtB,8BAA+B,CAF/B,WAAY,CANZ,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CAMjB,YAAa,CATb,gBAAiB,CAFjB,iBAAkB,CAClB,WAAY,CAQZ,qBAAsB,CACtB,UAKF,CACA,2BACE,wBACF,CACA,gDACE,8BACF,CAFA,kCACE,8BACF,CACA,+BACE,kBACF,CACA,4DAGE,wBAA6B,CAD7B,qCAAsC,CADtC,kBAGF,CACA,wEACE,wBACF,CACA,kHACE,qCACF,CAFA,sFACE,qCACF,CACA,8BACE,WACF,CACA,8BACE,eACF,CACA,6BAUE,8BAA+B,CAN/B,YAAa,CACb,qBAAsB,CAFtB,kJAAyK,CAFzK,cAAe,CAKf,sBAAuB,CAJvB,gBAAiB,CAMjB,eAAgB,CADhB,oBAA0B,CAE1B,gBAEF,CACA,oCACE,8BACF,CAEA,+DACE,wBAA6B,CAC7B,wBACF,CACA,0DACE,wBACF,CACA,qFACE,qCACF,CACA,uFACE,sCACF,CACA,+FACE,8BACF,CACA,iGACE,+BACF,CCxKA,iBAYE,yCAA0C,CAD1C,+CAAgD,CAVhD,qBAAsB,CACtB,mBAAoB,CAIpB,QAAS,CAFT,YAAa,CACb,eAAgB,CAEhB,gBAAiB,CACjB,iBAAkB,CAElB,4EAAyF,CADzF,wBAAiB,CAAjB,gBAAiB,CANjB,WAUF,CACA,uBAGE,YAAa,CADb,WAAY,CAEZ,6BAA8B,CAH9B,UAIF,CACA,sBAGE,eAAgB,CAFhB,QAAS,CACT,SAEF,CACA,mDACE,WACF,CAIA,mHACE,eACF,CACA,2BAEE,gBAAiB,CACjB,iBAAkB,CAClB,4EAAyF,CAHzF,UAIF,CAKA,uHAEE,SAAU,CADV,gDAEF,CACA,2EAGE,6CAA8C,CAE9C,qBAAsB,CAOtB,8BAA+B,CAX/B,cAAe,CACf,YAAa,CAQb,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAAiB,CAFjB,iBAAkB,CADlB,YAAa,CAFb,gBAAiB,CAUjB,yIAA0I,CAD1I,UAEF,CACA,qFAKE,SAAU,CAJV,eAAgB,CAEhB,sBAAuB,CACvB,gDAAwD,CAFxD,kBAIF,CACA,yFACE,UACF,CACA,uGACE,wDAAyD,CACzD,mBACF,CACA,wDAKE,kBAAmB,CAFnB,aAAc,CAFd,YAAa,CAKb,0BAA2B,CAF3B,oBAAqB,CAFrB,UAKF,CACA,+BACE,SACF,CACA,0DACE,gBACF,CACA,0BACE,SACF,CACA,sDACE,UACF,CACA,iFACE,YACF,CACA,4BAEE,kBAAmB,CADnB,YAAa,CAGb,aAAc,CADd,UAEF,CACA,4BACE,SAAU,CACV,oCACF,CACA,4CACE,eACF,CACA,gCAKE,aACF,CACA,uEALE,8BAA+B,CAD/B,mBAAoB,CAEpB,iBAAkB,CAClB,cAQF,CACA,wCAEE,8BAA+B,CAD/B,mBAAoB,CAEpB,gBAAiB,CAEjB,SAAU,CADV,gDAEF,CACA,+BACE,wDAAyD,CACzD,8BACF,CACA,sEACE,+BACF,CACA,6DACE,wBAA6B,CAC7B,wCAAyC,CACzC,kBACF,CACA,oGACE,wCACF,CACA,+BACE,wBAA6B,CAE7B,kBACF,CACA,+JAHE,qCAMF,CACA,6CACE,8BACF,CACA,uEACE,yCAA0C,CAC1C,8BACF,CACA,8GACE,8BACF,CACA,iEAEE,yCAA0C,CAD1C,8BAEF,CACA,wGACE,+BACF,CACA,iEACE,wBAA6B,CAC7B,qCAAsC,CACtC,kBACF,CACA,oMAEE,qCACF,CACA,+FACE,wBAA6B,CAC7B,wCAAyC,CACzC,kBACF,CACA,sIACE,wCACF,CACA,uKACE,yCAA0C,CAC1C,8BACF,CACA,qPACE,8BACF,CACA,2JAEE,yCAA0C,CAD1C,8BAEF,CACA,yOACE,+BACF,CACA,2JACE,wBAA6B,CAC7B,qCAAsC,CACtC,kBACF,CACA,0bAGE,qCACF,CACA,sDACE,aACF,CACA,0BACE,aAAc,CAGd,cAAe,CADf,YAAa,CADb,SAGF,CACA,qDAIE,kBAAmB,CAHnB,YAAa,CAEb,WAAY,CADZ,0BAGF,CACA,qBAEE,cAAe,CADf,eAAgB,CAEhB,eAAgB,CAChB,YAAa,CAIb,eAAgB,CAHhB,SAAU,CACV,sBAAuB,CACvB,kBAEF,CACA,2CAEE,wBAA6B,CAD7B,8BAA+B,CAG/B,eAAgB,CADhB,WAAY,CAEZ,UACF,CACA,uDACE,cACF,CACA,uGACE,gBACF,CACA,wJACE,yCAA0C,CAC1C,8BACF,CACA,+LACE,8BACF,CACA,+EACE,wBAA6B,CAC7B,qCAAsC,CACtC,kBACF,CACA,gOAEE,qCACF,CACA,+EACE,wDAAyD,CACzD,8BACF,CACA,sHACE,+BACF,CACA,yJACE,yCAA0C,CAC1C,8BACF,CACA,gMACE,8BACF,CACA,gFACE,wBAA6B,CAC7B,qCAAsC,CACtC,kBACF,CACA,kOAEE,qCACF,CACA,gFACE,wDAAyD,CACzD,8BACF,CACA,uHACE,+BACF,CACA,oDACE,wDAAyD,CACzD,8BACF,CACA,2FACE,+BACF,CACA,kFAEE,wDAAyD,CACzD,qCAAsC,CAFtC,kBAGF,CACA,oDACE,wBAA6B,CAC7B,qCAAsC,CACtC,kBACF,CACA,0KAEE,qCACF,CACA,+CACE,cACF,CACA,+BAEE,2BAAoB,CAApB,mBACF,CACA,gEAHE,4CAAuC,CAAvC,oCAAuC,CAAvC,sEAMF,CAHA,iCAEE,iCAA0B,CAA1B,yBACF,CAGA,wBAEE,kBAAmB,CACnB,qBAAsB,CAFtB,mBAGF,CACA,6BAGE,mBAAoB,CAFpB,aAAc,CACd,gBAEF,CACA,yEAEE,WAAY,CACZ,qBAAsB,CAFtB,UAGF,CACA,6BAME,8BAA+B,CAD/B,mBAAoB,CAFpB,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAAiB,CAQjB,SAAU,CAFV,sBAAuB,CACvB,gDAAwD,CAFxD,kBAIF,CACA,wBAIE,kBAAmB,CAHnB,qBAAsB,CAEtB,mBAAoB,CADpB,iBAGF,CACA,sDACE,sBAAuB,CACvB,kBACF,CAEA,mDACE,sBACF,CACA,gFACE,cAAe,CACf,UACF,CACA,+KAGE,eAAgB,CADhB,cAAe,CADf,UAGF,CACA,gFAEE,SAAU,CADV,gDAEF,CAEA,4LACE,+BACF,CACA,qGAEE,wBAA6B,CAD7B,8BAEF,CACA,8GAEE,wDAAyD,CAEzD,wBAA6B,CAD7B,8BAA+B,CAF/B,eAIF,CACA,qJACE,+BACF,CACA,iJACE,wBAA6B,CAC7B,wCAAyC,CACzC,kBACF,CACA,wLACE,wCACF,CACA,8GAEE,wBAA6B,CAC7B,qCAAsC,CACtC,kBAAmB,CAHnB,eAIF,CACA,8RAEE,qCACF,CACA,oJACE,yCAA0C,CAC1C,8BACF,CACA,2LACE,8BACF,CACA,8IAEE,yCAA0C,CAD1C,8BAEF,CACA,qLACE,+BACF,CACA,qJACE,yCAA0C,CAC1C,8BACF,CACA,4LACE,8BACF,CACA,+IAEE,yCAA0C,CAD1C,8BAEF,CACA,sLACE,+BACF,CACA,+WACE,wBAA6B,CAC7B,qCAAsC,CACtC,kBACF,CACA,k2BAGE,qCACF,CACA,mWACE,wBAA6B,CAC7B,wCAAyC,CACzC,kBACF,CACA,ibACE,wCACF,CACA,6DACE,eACF,CACA,iDACE,qBACF,CACA,6DACE,WACF,CACA,wDAGE,iBAAkB,CADlB,eAAgB,CADhB,gBAGF,CACA,kDAIE,2BAAkB,CAClB,UACF,CACA,4DACE,kBAAmB,CACnB,eAAgB,CAChB,4EACF,CACA,kDACE,8BAA+B,CAC/B,gBAAiB,CACjB,iBACF,CACA,2GACE,gBAAiB,CACjB,SAAU,CACV,gDACF,CACA,6FACE,gBAAiB,CACjB,iBACF,CACA,4DACE,sBACF,CACA,0FACE,UACF,CACA,qHACE,SAAU,CACV,gDACF,CAEA,4BAIE,gDAAiD,CADjD,iBAAkB,CADlB,WAAY,CAGZ,iBAAkB,CAClB,kBAAmB,CALnB,UAMF,CACA,mDACE,kBACF,CACA,+DAEE,kBAAmB,CADnB,mBAEF,CACA,yEACE,oBACF,CACA,oDAEE,iBAAkB,CADlB,aAEF,CACA,kDAEE,kBAAmB,CADnB,mBAEF,CACA,wEAGE,wBAA6B,CAD7B,8BAA+B,CAD/B,eAGF,CACA,+GACE,8BACF,CACA,iFAEE,wBAA6B,CAD7B,8BAEF,CACA,wHACE,8BACF,CACA,iFAEE,wBAA6B,CAD7B,qCAAsC,CAEtC,kBACF,CACA,oOAEE,qCACF,CACA,yHAEE,wBAA6B,CAD7B,8BAEF,CACA,gKACE,8BACF,CACA,oJAEE,wBAA6B,CAD7B,8BAEF,CACA,0HAEE,wBAA6B,CAD7B,8BAEF,CACA,iKACE,8BACF,CACA,uOAEE,wBAA6B,CAD7B,qCAAsC,CAEtC,kBACF,CACA,klBAGE,qCACF,CACA,6RAEE,wBAA6B,CAD7B,qCAEF,CACA,2FACE,gBACF,CACA,wGAEE,wBAA6B,CAD7B,8BAEF,CACA,8OAGE,wBAA6B,CAD7B,8BAEF,CACA,sFACE,kBACF,CACA,8OAGE,wBAA6B,CAD7B,qCAEF,CACA,wDACE,UACF,CACA,kEACE,eACF,CACA,mEACE,gBACF,CACA,kDACE,UACF,CACA,4DACE,cAAe,CACf,sBACF,CACA,oDACE,eAAgB,CAChB,eACF,CACA,8DAGE,kBAAmB,CADnB,kBAAmB,CADnB,sBAGF,CAEA,oDACE,UACF,CACA,wDACE,eACF,CAEA,+CACE,qBAAsB,CACtB,gBAAiB,CACjB,UACF,CACA,yCAEE,eAAgB,CADhB,YAAa,CAEb,eACF,CAEA,8CACE,SACF,CAEA,6DAIE,8CAA+C,CAD/C,cAAe,CADf,aAIF,CACA,8IAFE,6EAKF,CACA,yGAGE,gBAAiB,CADjB,cAEF,CACA,uGAEE,aAAc,CACd,iBACF,CACA,wgBAIE,gBAAiB,CACjB,iBACF,CACA,+LAEE,iBACF,CACA,2EAGE,kBAAmB,CACnB,qBAAsB,CAFtB,mBAGF,CACA,qFAIE,mBAAoB,CAFpB,eAAgB,CAChB,cAEF,CACA,iFAEE,aACF,CACA,2LAGE,aAAc,CADd,iBAEF,CACA,+EAEE,aACF,CACA,+HAGE,gBAAiB,CADjB,mBAEF,CACA,mJAGE,cAAe,CADf,mBAAoB,CAEpB,6EACF,CACA,iPAEE,gBAAiB,CACjB,iBAAkB,CAClB,gDACF,CACA,mFAIE,gBAAiB,CADjB,iBAAkB,CADlB,aAAc,CAGd,iBAAkB,CAClB,kBACF,CACA,mIAGE,gBAAiB,CADjB,iBAEF,CACA,iNAGE,eAAgB,CADhB,iBAEF,CACA,+JAEE,gBAAiB,CACjB,gBACF,CACA,iKAGE,eAAgB,CADhB,iBAEF,CACA,mIAGE,cAAe,CADf,kBAEF,CCxwBA,6BACE,mJACF,CCFA,iBACE,qBAAsB,CACtB,eACF,CACA,uBACE,YAAa,CACb,qBAAsB,CACtB,2BAA4B,CAC5B,iBACF,CACA,wBACE,YAAa,CACb,0BACF,CACA,8BAME,8BAA+B,CAH/B,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAAiB,CAGjB,iBAEF,CACA,6BAEE,WAAY,CACZ,iBAAkB,CAFlB,UAGF,CACA,kDACE,+BACF,CACA,6BACE,mBAAoB,CAEpB,qBAAsB,CADtB,WAEF,CACA,sBACE,8BACF,CACA,+BACE,gBACF,CACA,wBACE,QAAS,CACT,SACF,CACA,wBAEE,YAAa,CACb,wBAAyB,CAFzB,eAGF,CACA,kEACE,gBACF,CACA,yBACE,8CACF,CAEA,gDACE,0BACF,CAEA,qBACE,aACF,CACA,4CACE,2BACF,CACA,6CACE,cACF,CACA,kDAEE,gBAAiB,CADjB,cAEF,CACA,6CACE,wBACF,CACA,uFAEE,eAAgB,CADhB,cAEF,CAEA,6CACE,aACF,CACA,oEACE,0BACF,CCvFA,YACE,mBACF,CACA,qBACE,qBACF,CACA,uBACE,kBACF,CACA,yBACE,kBACF,CACA,sBACE,oBACF,CACA,wBACE,sBACF,CACA,2BACE,oBACF,CACA,iBACE,cACF,CACA,6BACE,sBAAe,CAAf,cACF,CACA,2BACE,WACF,CACA,8BACE,uBAAgB,CAAhB,eACF,CACA,4BACE,YACF,CACA,6BACE,uBAAgB,CAAhB,eACF,CACA,2BACE,YACF,CAEA,mDAEE,aACF,CC9CA,4BACE,0CAA2C,CAO3C,8BAA+B,CAH/B,gBAIF,CACA,+FAJE,YAAa,CACb,6BAMF,CACA,qCACE,4CACF,CAEA,4BAGE,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAGF,CACA,6BAGE,kBAAmB,CAFnB,YAAa,CACb,6BAEF,CAEA,YAGE,uBAAyB,CACzB,gBAAiB,CAEjB,aAAc,CADd,iBAAkB,CAHlB,eAAgB,CADhB,UAMF,CACA,oBACE,MAAO,CAGP,qBAAsB,CADtB,UAAW,CAOX,8BAA+B,CAD/B,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CAHjB,QAAS,CACT,SAAU,CAJV,iBAAkB,CASlB,UACF,CACA,4CACE,SACF,CAIA,2DACE,aACF,CACA,0EAEE,mBAAoB,CADpB,gBAEF,CACA,yEAEE,kBAAmB,CADnB,eAEF,CACA,kBAKE,cAAgB,CAJhB,iBAKF,CACA,sBACE,iBACF,CACA,mBAEE,gCAAiC,CADjC,eAEF,CACA,sCACE,wBAA6B,CAC7B,gDACF,CACA,0BACE,uBAAgB,CAAhB,eAAgB,CAChB,WACF,CACA,iFACE,uCACF,CACA,0BACE,QACF,CACA,qDACE,sBACF,CACA,oDACE,wBACF,CACA,4BACE,YAAa,CACb,0BACF,CACA,iBAGE,qBAAsB,CAFtB,aAAc,CACd,UAEF,CACA,qBACE,0BACF,CACA,qCACE,oBACF,CACA,iGACE,UACF,CACA,uDACE,uCAAwC,CAIxC,gDAAiD,CAHjD,8BAA+B,CAC/B,eAAgB,CAQhB,wBAAyB,CAFzB,gBAAmB,CAGnB,iBAAkB,CARlB,eAAgB,CAMhB,qBAGF,CACA,qKAGE,uCAAwC,CADxC,uBAAgB,CAAhB,eAAgB,CADhB,WAGF,CACA,mLACE,uCAAwC,CAMxC,QAAS,CALT,UAAW,CAMX,aAAc,CAJd,MAAO,CADP,iBAAkB,CAGlB,OAAQ,CADR,KAAM,CAIN,UACF,CACA,uFACE,+CAAgD,CAChD,6CACF,CACA,gGACE,gDACF,CACA,8HACE,wBACF,CACA,yFACE,8CAA+C,CAC/C,8CACF,CACA,kGACE,gDACF,CACA,gIACE,wBACF,CACA,kHAEE,uBAAwB,CADxB,eAEF,CAIA,sKACE,iBACF,CACA,iFAEE,kBAAmB,CADnB,mBAEF,CAMA,2JACE,eAAgB,CAChB,sBAAuB,CACvB,kBACF,CACA,mDAEE,2BAA4B,CAD5B,iBAEF,CACA,4CACE,gDACF,CACA,0EACE,wBACF,CACA,0DAIE,yCAA0C,CAC1C,UAAW,CAEX,iBAAkB,CAJlB,uBAA4B,CAF5B,iBAAkB,CAKlB,UAAW,CAJX,SAAU,CAMV,SACF,CACA,gEACE,0CACF,CACA,kBACE,uBACF,CACA,kCAEE,uCAAwC,CADxC,iBAEF,CACA,6DAEE,uCAAwC,CADxC,wFAEF,CACA,iLACE,oFACF,CACA,+LACE,yCAA0C,CAM1C,QAAS,CALT,UAAW,CAMX,aAAc,CAJd,MAAO,CADP,iBAAkB,CAGlB,OAAQ,CADR,KAAM,CAIN,UACF,CACA,uDAKE,gDAAiD,CAFjD,gBAAiB,CACjB,iBAAkB,CAGlB,qBAAsB,CANtB,kBAAmB,CACnB,wBAAyB,CAIzB,YAAa,CAEb,iBAAkB,CAClB,qBACF,CACA,gEACE,gDACF,CACA,gEACE,eAAgB,CAChB,sBAAuB,CACvB,kBACF,CACA,6EACE,yCACF,CACA,wDACE,YACF,CACA,6HAGE,uCAAwC,CADxC,uBAAgB,CAAhB,eAAgB,CADhB,WAGF,CACA,mEACE,+CAAgD,CAChD,6CACF,CACA,qEACE,8CAA+C,CAC/C,8CACF,CAIA,+FACE,4GACF,CACA,0CACE,iBACF,CACA,+DACE,2CAA6C,CAC7C,gDACF,CACA,iGACE,iBACF,CACA,oEAEE,kBAAmB,CADnB,mBAEF,CACA,0CACE,aACF,CACA,0DACE,YACF,CACA,+EACE,iBAAgB,CAIhB,kBAAmB,CADnB,mBAAoB,CAEpB,eAAgB,CAHhB,kBAAmB,CADnB,kBAKF,CACA,uFAGE,YAAa,CADb,mBAAoB,CADpB,gBAGF,CACA,sFAEE,gBAAe,CAAf,gBAAe,CADf,SAEF,CACA,mBACE,yCAA0C,CAE1C,QAAS,CADT,YAAa,CAEb,iBACF,CACA,uCACE,mBAAoB,CACpB,qBACF,CACA,2CACE,kBACF,CACA,0DACE,mBACF,CACA,sCACE,YACF,CACA,yCACE,iBACF,CACA,iFACE,MAAO,CACP,KACF,CACA,8DACE,iCAA0B,CAA1B,yBACF,CACA,yEACE,cACF,CACA,sCACE,oBAAqB,CAErB,WAAY,CAEZ,iBAAkB,CADlB,qBAAsB,CAFtB,UAIF,CACA,8CAGE,kBAAmB,CACnB,cAAe,CAHf,YAAa,CACb,OAAQ,CAGR,eACF,CACA,oFAGE,8BAA+B,CAD/B,aAAc,CADd,QAGF,CACA,kHACE,8BACF,CACA,4FAEE,WAAY,CADZ,UAEF,CACA,oQAGE,+BACF,CACA,sCAKE,kBAAmB,CADnB,8BAA+B,CAD/B,cAAe,CADf,mBAAoB,CADpB,eAKF,CACA,0CAEE,WAAY,CADZ,UAEF,CACA,yCACE,+BACF,CACA,uCAKE,8CAA+C,CAD/C,+CAAgD,CADhD,6CAA8C,CAF9C,iBAAkB,CAClB,kBAIF,CACA,2CAEE,yCAAe,CACf,eAAgB,CADhB,cAEF,CACA,2DACE,+CACF,CACA,wCAGE,gDAAiD,CAFjD,8CAA+C,CAC/C,+CAEF,CACA,oGACE,wBACF,CAKA,qMACE,+CACF,CACA,wBAQE,sBAAuB,CACvB,gDAAiD,CAJjD,8BAA+B,CAC/B,cAAe,CAJf,MAAS,CAET,iBAAkB,CAHlB,uBAAgB,CAAhB,eAAgB,CAMhB,iBAAkB,CAJlB,SAOF,CACA,kBAEE,cAAe,CADf,kBAEF,CACA,oNAUE,kBAAmB,CADnB,YAAa,CADb,WAAY,CALZ,MAAO,CACP,iBAAkB,CAClB,kBAAmB,CAHnB,aAAc,CAId,iBAAkB,CAClB,kBAAmB,CANnB,uBAAgB,CAAhB,eAUF,CACA,+BACE,kBACF,CAKA,sZAEE,eACF,CACA,6BACE,8BAA+B,CAC/B,eACF,CAEA,wBAIE,kBAAmB,CAEnB,sBAAuB,CALvB,8BAA+B,CAC/B,cAAe,CACf,mBAAoB,CAKpB,gBAAiB,CADjB,iBAAkB,CAFlB,wBAAiB,CAAjB,gBAIF,CACA,6BAEE,kBAAmB,CADnB,mBAAoB,CAEpB,sBACF,CACA,sDAEE,+BAAwB,CAAxB,uBACF,CACA,4GAHE,+DAAgE,CAAhE,uDAAgE,CAAhE,4GAMF,CAHA,sDAEE,8BAAuB,CAAvB,sBACF,CAEA,uDACE,gBAAiB,CACjB,eACF,CAEA,oCACE,aAAc,CACd,gBACF,CACA,2EACE,wBACF,CACA,4EACE,0BACF,CACA,+EACE,gBACF,CACA,+GAEE,8CAA+C,CAD/C,cAEF,CACA,wHACE,+CACF,CACA,iHACE,aAAc,CACd,+CACF,CACA,0HACE,+CACF,CACA,0IAEE,wBAAyB,CADzB,eAEF,CACA,oEACE,+CACF,CACA,kFAEE,SAAU,CADV,UAEF,CACA,0CACE,uBACF,CACA,wFAEE,+CAAgD,CADhD,cAEF,CACA,2FAEE,8CAA+C,CAD/C,cAEF,CACA,6FACE,aAAc,CACd,+CACF,CACA,yGACE,SAAU,CACV,OACF,CACA,sFACE,4CAAqC,CAArC,oCACF,CACA,iGAEE,aAAc,CADd,iBAEF,CAKA,4HAHE,aAAc,CACd,gBAKF,CACA,mEACE,aAAc,CACd,+CACF,CAMA,6QAFE,8CAA+C,CAD/C,cAMF,CACA,mFAEE,8CAA+C,CAD/C,cAEF,CACA,oQACE,SAAU,CAGV,iBAAkB,CADlB,kBAAmB,CAGnB,iBAAkB,CADlB,kBAAmB,CAHnB,OAKF,CACA,wPAEE,6CACF,CAKA,sfAEE,eACF,CACA,8PAEE,8CACF,CACA,gDAEE,eAAgB,CADhB,iBAAkB,CAElB,4CAAqC,CAArC,oCACF,CACA,mCACE,aACF,CC9mBA,eAIE,sBAAuB,CAHvB,qBAAsB,CAUtB,sBAAe,CAAf,cAAe,CAHf,cAAe,CALf,YAAa,CAIb,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CAJjB,iBAAkB,CAQlB,yDAAkD,CAAlD,iDAAkD,CADlD,8PAGF,CACA,oCAKE,WAAY,CAHZ,MAAO,CAIP,QAAS,CACT,SAAU,CANV,iBAAkB,CAElB,KAAM,CACN,UAIF,CACA,uBAEE,YAAa,CADb,QAAO,CAEP,qBAAsB,CACtB,WACF,CACA,qBAGE,kBAAmB,CACnB,8BAA+B,CAH/B,YAAa,CACb,QAAO,CAGP,gBAAiB,CACjB,wBAAiB,CAAjB,gBACF,CACA,kDAEE,yDACF,CAQA,+JAEE,eACF,CACA,mJACE,0CAA2C,CAC3C,mDACF,CACA,6MACE,6CAA8C,CAC9C,eACF,CACA,qBAGE,kBAAmB,CAInB,cAAe,CALf,YAAa,CAGb,WAAY,CAJZ,iBAAkB,CAKlB,wBAAiB,CAAjB,gBAAiB,CAFjB,UAIF,CACA,6BAME,sBAAuB,CAGvB,mDAAoD,CADpD,mDAAoD,CAPpD,qBAAsB,CAGtB,WAAY,CACZ,QAAS,CAHT,iBAAkB,CAKlB,8PAAgQ,CAJhQ,UAOF,CACA,wCACE,cACF,CACA,0DACE,oCAAqC,CAGrC,mDAAoD,CADpD,oDAAqD,CADrD,6BAGF,CACA,kDACE,8BACF,CACA,kDACE,mCACF,CACA,8EACE,0CAA2C,CAC3C,eAAgB,CAChB,6BACF,CACA,+EACE,0CAA2C,CAC3C,4CAA6C,CAC7C,6BACF,CACA,mIACE,kCACF,CACA,mDACE,mCACF,CACA,+EAIE,eACF,CACA,+JALE,2CAA4C,CAC5C,6CAA8C,CAC9C,6BAOF,CACA,oIACE,kCACF,CACA,wBAEE,sBAAuB,CAGvB,sBAAuB,CACvB,4BAA6B,CAH7B,iBAAkB,CAFlB,gBAAiB,CAGjB,iBAGF,CACA,6CAEE,aAAc,CADd,iBAEF,CACA,qDACE,kCACF,CACA,0DACE,SAAU,CACV,OACF,CACA,6CAIE,8BAA+B,CAF/B,cAAe,CADf,eAAgB,CAEhB,gBAEF,CACA,6CAIE,8BAA+B,CAF/B,cAAe,CADf,eAAmB,CAEnB,gBAEF,CACA,qFACE,YACF,CACA,8BACE,mCACF,CACA,+BACE,mCACF,CACA,gCACE,kDAAmD,CACnD,0CACF,CACA,sCACE,kDAAmD,CACnD,4CACF,CACA,gGACE,eACF,CACA,uCACE,kDAAmD,CACnD,6CACF,CAIA,+EACE,sBACF,CACA,gEACE,kDAAmD,CACnD,mDACF,CACA,gIACE,eACF,CACA,8GACE,oCAAqC,CAGrC,mDAAoD,CADpD,oDAAqD,CADrD,6BAGF,CACA,0HACE,0CAA2C,CAC3C,4CAA6C,CAC7C,6BACF,CACA,4HACE,2CAA4C,CAC5C,6CAA8C,CAC9C,6BACF,CACA,0GACE,8BACF,CAIA,qEACE,kBACF,CACA,qDAEE,0CAA2C,CAC3C,mDAAoD,CAFpD,6BAGF,CACA,2DAEE,sBAAuB,CADvB,6BAEF,CACA,qDACE,6BACF,CACA,kFAEE,6CAA8C,CAC9C,6DAA8D,CAF9D,WAGF,CACA,wFAEE,6CAA8C,CAD9C,6BAEF,CAIA,0FACE,qCACF,CACA,+FAEE,6CAA8C,CAC9C,6DAA8D,CAC9D,6BAA8B,CAH9B,WAIF,CACA,qBAIE,qBAAsB,CACtB,8BAA+B,CAF/B,eAAgB,CADhB,WAAY,CADZ,aAKF,CACA,qBACE,wDACF,CACA,4BACE,yDACF,CAEA,oBAGE,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CAEjB,gBACF,CACA,0DACE,kBACF,CACA,+BACE,YAAa,CACb,cAAe,CACf,QACF,CACA,8CACE,mBACF,CACA,6BACE,YAAa,CACb,qBAAsB,CACtB,YACF,CACA,sCACE,YACF,CACA,yDACE,oBAAa,CAAb,YACF,CAEA,yDAEE,aACF,CACA,mGAEE,SAAU,CACV,OACF,CACA,mEAEE,aACF,CCjTA,YACE,qBAAsB,CAMtB,sBAAe,CAAf,cAAe,CAIf,cAAe,CALf,mBAAoB,CAFpB,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CAMjB,eAAgB,CAChB,cAAe,CALf,iBAAkB,CAQlB,eAAgB,CALhB,8PAAgQ,CAIhQ,qBAEF,CACA,gCACE,aACF,CACA,+DASE,cAAe,CAFf,WAAY,CAJZ,MAAO,CAKP,QAAS,CAHT,SAAU,CAHV,iBAAkB,CAElB,KAAM,CAEN,UAIF,CACA,4CAEE,+CACF,CACA,sEACE,kCACF,CACA,sEACE,0CAA2C,CAC3C,4CACF,CAIA,uEACE,kCACF,CACA,uEACE,2CAA4C,CAC5C,6CACF,CACA,iCAEE,mCAAoC,CACpC,6CAA8C,CAF9C,WAGF,CACA,6BAGE,6CAA8C,CAC9C,gBAAiB,CAFjB,WAAY,CADZ,iBAIF,CACA,8CACE,eACF,CACA,mCAEE,gBAAiB,CADjB,eAEF,CACA,mCAEE,gBAAiB,CADjB,WAEF,CACA,2BAIE,sBAAuB,CACvB,4BAA6B,CAH7B,6CAA8C,CAD9C,gBAAiB,CAEjB,iBAAkB,CAGlB,8PACF,CACA,6CACE,aACF,CACA,qDACE,kCACF,CACA,6CAIE,8BAA+B,CAF/B,cAAe,CADf,eAAgB,CAEhB,gBAEF,CACA,6CAIE,8BAA+B,CAF/B,cAAe,CADf,eAAmB,CAEnB,gBAAiB,CAEjB,cACF,CACA,kCACE,mCACF,CACA,mCACE,kDAAmD,CACnD,0CACF,CACA,yCACE,gDACF,CACA,6FACE,4CACF,CACA,0CACE,kDAAmD,CACnD,iDACF,CACA,8FACE,6CACF,CACA,oGACE,2CACF,CACA,iCACE,mCACF,CACA,2CACE,sBACF,CACA,sEACE,kDAAmD,CACnD,mDACF,CAIA,0PACE,+CACF,CACA,iIACE,0CAA2C,CAC3C,yCACF,CACA,qLACE,6CAA8C,CAC9C,+CACF,CACA,kBACE,mBAAoB,CAIpB,WAAY,CAHZ,cAAe,CACf,iBAAkB,CAIlB,wBAAiB,CAAjB,gBAAiB,CADjB,kBAAmB,CAFnB,UAIF,CACA,0BAEE,kBAAmB,CAOnB,sBAAuB,CAFvB,yCAA0C,CAC1C,kBAAmB,CAJnB,qBAAsB,CAHtB,mBAAoB,CAKpB,WAAY,CAHZ,sBAAuB,CAOvB,8PAAgQ,CALhQ,UAMF,CACA,qCAGE,cAAe,CADf,WAAY,CADZ,UAGF,CACA,oBACE,YAAa,CACb,qBAAsB,CACtB,WACF,CACA,4CACE,mCACF,CACA,6CACE,mCACF,CACA,kBAIE,kBAAmB,CAFnB,8BAA+B,CAC/B,mBAAoB,CAFpB,wBAAiB,CAAjB,gBAIF,CACA,8BAEE,6CAA8C,CAE9C,8BAA+B,CAC/B,cAAe,CAFf,eAAgB,CAGhB,gBAAiB,CALjB,iBAAkB,CAMlB,8PACF,CACA,oCAEE,mCAAoC,CADpC,eAEF,CACA,sCAEE,iCAAkC,CAClC,+BAAgC,CAFhC,eAGF,CACA,uCAEE,qCAAsC,CADtC,kBAEF,CACA,oCACE,cAAe,CACf,gBACF,CACA,oCACE,cAAe,CACf,gBACF,CACA,sEACE,0CACF,CACA,uEACE,2CACF,CACA,gEAEE,oCAAqC,CADrC,0CAA2C,CAG3C,kBAAmB,CADnB,+BAEF,CACA,wDACE,8BACF,CACA,sFAME,WAAY,CAFZ,MAAO,CAGP,YAAa,CAEb,SAAU,CAPV,iBAAkB,CAClB,KAAM,CAEN,UAAW,CAGX,UAEF,CAIA,uFACE,kBACF,CACA,+CAEE,0CAA2C,CAC3C,qCAAsC,CAFtC,WAGF,CACA,qDACE,sBACF,CAKA,wJACE,6CAA8C,CAC9C,+CACF,CAIA,8EACE,qCACF,CACA,kBAEE,qBAAsB,CADtB,8BAEF,CACA,kBACE,wDACF,CACA,yBACE,+CACF,CAEA,iBAGE,kJAAyK,CAFzK,cAAe,CACf,gBAEF,CACA,0BACE,YAAa,CACb,qBAAsB,CACtB,YACF,CAIA,yFACE,YACF,CACA,4BACE,mBAAoB,CACpB,cAAe,CAEf,QAAS,CADT,qBAEF,CACA,6BAEE,mCAAoC,CACpC,6CAA8C,CAF9C,oBAAqB,CAGrB,qBACF,CAEA,mDAEE,aACF,CACA,oLAIE,SAAU,CACV,OACF,CACA,uHAEE,cACF,CACA,6DAEE,aACF,CC3UA,WAME,uBAAqB,CACrB,sBAAmB,CAHnB,kBAAmB,CAHnB,YAAa,CACb,eAAgB,CAKhB,kBAAmB,CADnB,oBAEF,CACA,4BAJE,kJAAyK,CAFzK,SAaF,CAPA,iBAKE,8BAA+B,CAJ/B,cAAe,CAGf,eAAgB,CAFhB,gBAKF,CACA,oBACE,kBACF,CACA,qCACE,qCACF,CACA,gBAiBE,kBAAmB,CAZnB,0BAA6B,CAQ7B,6CAA8C,CAD9C,8BAA+B,CAN/B,cAAe,CAUf,YAAa,CAbb,kJAAyK,CAFzK,cAAe,CAUf,eAAgB,CAHhB,WAAY,CAUZ,sBAAuB,CAhBvB,gBAAiB,CAajB,gBAAiB,CANjB,eAAgB,CAChB,gBAAiB,CANjB,cAAe,CAUf,iBAAkB,CAMlB,yDAAkD,CAAlD,iDAAkD,CADlD,6PAA+P,CAZ/P,wBAAiB,CAAjB,gBAcF,CACA,sBACE,wBAGF,CACA,mDAHE,yCAA0C,CAC1C,8BAKF,CACA,uBAEE,yCAA0C,CAD1C,wBAAyB,CAEzB,8BACF,CACA,uBAGE,eAEF,CACA,oDAFE,wDAAyD,CAHzD,wBAAyB,CACzB,+BAQF,CACA,yBAGE,wBAA6B,CAF7B,wBAAyB,CACzB,qCAAsC,CAEtC,kBACF,CACA,+BACE,wBACF,CACA,sBAEE,QAAS,CADT,cAEF,CACA,6BACE,wBAAyB,CAGzB,kBACF,CACA,gEAHE,wBAA6B,CAD7B,qCAOF,CACA,oCAEE,eACF,CACA,8EAHE,gDAKF,CACA,iBAIE,8BAA+B,CAD/B,kJAAyK,CAFzK,cAAe,CACf,gBAGF,CACA,gCACE,gCAAiC,CACjC,cACF,CACA,gFACE,qCAAsC,CACtC,kBACF,CACA,qBAOE,kBAAmB,CAEnB,8BAA+B,CAJ/B,YAAa,CAGb,aAAc,CAJd,kJAAyK,CAFzK,cAAe,CAIf,sBAAuB,CAHvB,gBAAiB,CAFjB,gBASF,CACA,kCAEE,eAAgB,CAChB,gBAAiB,CAFjB,cAGF,CACA,8BACE,qCACF,CAKA,8CACE,wBAAiB,CAAjB,gBACF,CAEA,qBAEE,kBAAmB,CADnB,eAEF,CACA,yBACE,iBACF,CACA,qBAKE,qBAAsB,CACtB,cAAe,CAHf,YAAa,CAFb,WAAY,CAGZ,sBAAuB,CAFvB,gBAKF,CACA,2BACE,yCACF,CACA,4BACE,yCACF,CAEA,iDAEE,aACF,CACA,2DAGE,eAAgB,CADhB,gBAEF,CACA,sHAGE,4BAAqB,CAArB,oBACF,CChLA,oBAcE,kBAAmB,CAJnB,eAAkB,CAMlB,qBAAsB,CAPtB,8BAA+B,CAM/B,cAAe,CAHf,YAAa,CACb,gBAAiB,CAVjB,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CAMjB,gBAAmB,CAGnB,iBAAkB,CAMlB,yIAA0I,CAb1I,oBAcF,CACA,yBAME,oBAAqB,CAJrB,iBAAkB,CAElB,YAAa,CACb,sBAAuB,CAFvB,gBAAiB,CAFjB,UAMF,CACA,yBACE,YAAa,CACb,cAAe,CACf,eACF,CACA,4BAEE,wBAAyB,CADzB,+BAAgC,CAEhC,eACF,CACA,2BACE,yCACF,CACA,0BAEE,qCAAsC,CADtC,kBAAmB,CAEnB,sBACF,CAIA,iEACE,wBACF,CACA,6BACE,qCAAsC,CACtC,kBACF,CACA,mCACE,yCACF,CACA,6BAEE,sBAAuB,CADvB,eAEF,CACA,sDACE,8BACF,CAKA,yCAHE,yCAmBF,CAhBA,aAGE,4BAA6B,CAD7B,6CAA8C,CAD9C,qBAAsB,CAUtB,cAAe,CAJf,mBAAoB,CAFpB,eAAgB,CADhB,WAAY,CAUZ,gBAAiB,CAJjB,YAAa,CAKb,eAAgB,CANhB,iBAAkB,CAIlB,yDAAkD,CAAlD,iDAAkD,CADlD,8PAAgQ,CAJhQ,qBAQF,CACA,mBACE,yCAA0C,CAC1C,4BACF,CACA,mBAEE,yCAA0C,CAD1C,+CAAgD,CAEhD,SACF,CACA,oBACE,yCACF,CACA,mBACE,WAAY,CACZ,gBACF,CACA,mBAEE,gBAAiB,CADjB,eAEF,CACA,0CAGE,kJAAyK,CAFzK,cAAe,CACf,gBAEF,CACA,qCACE,+CAAgD,CAChD,SACF,CACA,iDACE,yCAA0C,CAC1C,+CACF,CACA,mDACE,yCAA0C,CAC1C,+CACF,CACA,qBACE,wDAAyD,CACzD,oDACF,CACA,2BACE,sDAAuD,CACvD,kDACF,CACA,2BACE,wDAAyD,CACzD,sCACF,CACA,4BACE,uDAAwD,CACxD,mDACF,CACA,mBACE,uDAAwD,CACxD,mDACF,CACA,yBACE,qDAAsD,CACtD,iDACF,CACA,yBACE,uDAAwD,CACxD,qCACF,CACA,0BACE,sDAAuD,CACvD,kDACF,CACA,sBACE,kBAEF,CACA,kDAFE,gDAIF,CACA,4BACE,4BACF,CACA,sGAEE,qCAAsC,CACtC,kBACF,CAMA,6JAFE,qCAKF,CAHA,gCAEE,wBACF,CACA,uBAME,kBAAmB,CAKnB,8BAA+B,CAD/B,cAAe,CALf,YAAa,CAEb,WAAY,CAJZ,kJAAyK,CAFzK,cAAe,CAGf,WAAY,CAFZ,gBAAiB,CAOjB,gBAAiB,CADjB,eAIF,CACA,4BAEE,eAAgB,CAChB,sBAAuB,CAFvB,UAGF,CACA,qCACE,YAAa,CACb,UACF,CACA,iCACE,YACF,CACA,mCACE,8BACF,CACA,iCAGE,iBAAkB,CADlB,gBAAiB,CADjB,cAGF,CACA,+CACE,aACF,CACA,uCACE,cACF,CACA,iDAGE,iBAAkB,CADlB,gBAAiB,CADjB,cAGF,CACA,6BAIE,kBAAmB,CADnB,YAAa,CAEb,WAAY,CAHZ,eAAgB,CADhB,kBAKF,CACA,sCACE,mBAAoB,CACpB,aAAc,CACd,UACF,CACA,mEACE,cAAe,CACf,cACF,CACA,gFACE,wBACF,CACA,8EACE,wBAA6B,CAE7B,8BAA+B,CAC/B,cAAe,CAFf,WAGF,CACA,sBACE,WACF,CACA,6CACE,eACF,CACA,mDAGE,cAAe,CADf,eAAgB,CADhB,UAGF,CACA,yDACE,eACF,CACA,mEAEE,kBAAmB,CADnB,YAEF,CACA,4DACE,gBACF,CACA,4EAIE,aAAc,CAHd,gBAAiB,CACjB,0BAA2B,CAC3B,eAEF,CACA,wDACE,aACF,CACA,qEACE,eACF,CACA,qEACE,eACF,CACA,mBAEE,kBAAmB,CAGnB,8BAA+B,CAJ/B,YAAa,CAKb,aAAc,CAHd,sBAAuB,CAIvB,2DAAoD,CAApD,mDAAoD,CAHpD,UAIF,CACA,yBACE,YAAa,CACb,UACF,CACA,wCAGE,kBAAmB,CAFnB,YAAa,CACb,sBAEF,CACA,kDACE,aACF,CACA,kDACE,8BAA+B,CAC/B,YACF,CAMA,uCAFE,kBAAmB,CAFnB,YAAa,CACb,sBAUF,CAPA,mBAKE,8BAA+B,CAC/B,aAAc,CAFd,UAGF,CACA,yBACE,+BACF,CACA,iCACE,cACF,CACA,yBAME,8BAA+B,CAC/B,aAAc,CAFd,kJAAyK,CAFzK,cAAe,CADf,eAAgB,CAEhB,gBAAiB,CAHjB,iBAAkB,CAOlB,kBACF,CACA,yBACE,8BAA+B,CAC/B,gBACF,CAEA,gDACE,aACF,CAEA,wEACE,WAAY,CACZ,WAAY,CACZ,eAAgB,CAChB,iBACF,CACA,+DAOE,wBAA6B,CAD7B,WAAY,CAFZ,WAAY,CADZ,MAAO,CAFP,iBAAkB,CAClB,KAAM,CAGN,UAGF,CACA,qEACE,WACF,CACA,uDAGE,WAAY,CAFZ,cAAe,CACf,eAEF,CAEA,0EACE,WAAY,CACZ,WAAY,CACZ,eAAgB,CAChB,iBACF,CACA,sIACE,WAAY,CACZ,gBACF,CACA,0JACE,WACF,CACA,oGAIE,WAAY,CADZ,MAAO,CAFP,iBAAkB,CAClB,KAGF,CACA,wHACE,WACF,CACA,iEAIE,wBAA6B,CAD7B,WAAY,CAFZ,WAAY,CACZ,UAGF,CACA,uEACE,WACF,CACA,yDACE,cAAe,CACf,eACF,CAEA,8JACE,WAAY,CACZ,gBACF,CACA,gLACE,WACF,CAEA,wJACE,WAAY,CACZ,gBACF,CACA,0KACE,WACF,CAEA,iCAIE,aACF,CAEA,yBACE,iBAAkB,CAClB,eACF,CACA,yDACE,YACF,CAEA,mBACE,8BAA+B,CAS/B,cAAe,CADf,kJAAyK,CAFzK,cAAe,CACf,gBAAiB,CALjB,cAAe,CAGf,0BAKF,CACA,uCACE,6CACF,CAEA,6BAOE,kBAAuB,CAFvB,kBAAmB,CACnB,WAAY,CAFZ,gBAIF,CAEA,uDACE,wBAA6B,CAC7B,wBACF,CACA,0EACE,SACF,CACA,kDACE,wBACF,CACA,6DACE,qCACF,CACA,+DACE,sCACF,CACA,uDACE,qCACF,CACA,yDACE,sCACF,CAEA,qCAME,mCAAoC,CADpC,gBAEF,CAEA,qDAEE,aACF,CACA,yEAEE,aAAc,CACd,iBACF,CACA,yHAEE,cACF,CACA,6HAEE,eAAgB,CAChB,cACF,CACA,qHAEE,aAAc,CACd,gBACF,CACA,6IAEE,aAAc,CACd,gBACF,CACA,6EAEE,gBACF,CACA,6EAGE,eAAgB,CADhB,cAEF,CACA,2HAEE,gBAAiB,CACjB,cACF,CAMA,4XAEE,SAAU,CACV,OACF,CACA,iEAEE,iBAAkB,CAClB,kBACF,CACA,6EAGE,eAAgB,CADhB,cAEF,CC7iBA,UAWE,kBAAmB,CARnB,wBAA6B,CAD7B,6CAA8C,CAD9C,qBAAsB,CAQtB,YAAa,CAGb,mBAAoB,CAFpB,sBAAuB,CAJvB,eAAgB,CAFhB,iBAAkB,CAClB,wBAAiB,CAAjB,gBAAiB,CAGjB,qBAAsB,CADtB,kBAMF,CACA,kCAGE,kJAAyK,CAFzK,cAAe,CAGf,WAAY,CAFZ,gBAAiB,CAGjB,eACF,CACA,8DACE,wDACF,CACA,iBACE,6CACF,CACA,iBACE,4CACF,CACA,gBAGE,kJAAyK,CAFzK,cAAe,CAIf,WAAY,CAHZ,gBAAiB,CAEjB,eAEF,CACA,8BACE,wDACF,CACA,oBACE,YACF,CACA,sBACE,YAAa,CACb,iBACF,CACA,sBACE,YAAa,CACb,gBACF,CACA,kBACE,QACF,CACA,2BACE,eAAgB,CAEhB,sBAAuB,CADvB,kBAEF,CACA,yBAKE,WAAY,CACZ,WACF,CACA,yCAJE,kBAAmB,CAFnB,YAAa,CACb,sBAYF,CAPA,gBAIE,8BAA+B,CAE/B,cAAe,CADf,gBAEF,CACA,sBACE,8BACF,CACA,uBACE,8BACF,CACA,mBACE,uBACF,CACA,0EACE,gBACF,CACA,wBACE,iBACF,CACA,yCACE,0CACF,CACA,wBACE,uBACF,CACA,0GAEE,WAAY,CADZ,UAEF,CACA,oDAEE,WAAY,CADZ,UAEF,CACA,gFACE,kBACF,CACA,0GAEE,WAAY,CADZ,UAEF,CACA,uCACE,kBACF,CACA,oDAEE,WAAY,CADZ,UAEF,CAEA,gBACE,aAAc,CACd,WACF,CACA,0BACE,eAAgB,CAChB,gBACF,CACA,yCACE,WACF,CACA,yCACE,WACF,CAEA,uCAEE,eAAgB,CADhB,gBAEF,CACA,oDACE,cACF,CAEA,sBACE,wBAA6B,CAC7B,4CAA8C,CAC9C,iCACF,CAEA,sBACE,4CAA8C,CAC9C,+BACF,CAEA,sBACE,8CAAiD,CACjD,iCACF,CAEA,qBACE,wBAA6B,CAC7B,2CAA6C,CAC7C,gCACF,CAEA,qBACE,2CAA6C,CAC7C,+BACF,CAEA,qBACE,6CAAgD,CAChD,gCACF,CAEA,qBACE,wBAA6B,CAC7B,2CAA6C,CAC7C,gCACF,CAEA,qBACE,2CAA6C,CAC7C,+BACF,CAEA,qBACE,6CAAgD,CAChD,gCACF,CAEA,sBACE,wBAA6B,CAC7B,4CAA8C,CAC9C,iCACF,CAEA,sBACE,4CAA8C,CAC9C,+BACF,CAEA,sBACE,8CAAiD,CACjD,iCACF,CAEA,qBACE,wBAA6B,CAC7B,2CAA6C,CAC7C,gCACF,CAEA,qBACE,2CAA6C,CAC7C,+BACF,CAEA,qBACE,6CAAgD,CAChD,gCACF,CAEA,uBACE,wBAA6B,CAC7B,6CAA+C,CAC/C,kCACF,CAEA,uBACE,6CAA+C,CAC/C,+BACF,CAEA,uBACE,+CAAkD,CAClD,kCACF,CAEA,2BACE,wBAA6B,CAC7B,iDAAmD,CACnD,sCACF,CAEA,2BACE,iDAAmD,CACnD,+BACF,CAEA,2BACE,mDAAsD,CACtD,sCACF,CAEA,4BACE,wBAA6B,CAC7B,kDAAoD,CACpD,uCACF,CAEA,4BACE,kDAAoD,CACpD,+BACF,CAEA,4BACE,oDAAuD,CACvD,uCACF,CAEA,qBACE,wBAA6B,CAC7B,2CAA6C,CAC7C,gCACF,CAEA,qBACE,2CAA6C,CAC7C,+BACF,CAEA,qBACE,6CAAgD,CAChD,gCACF,CAEA,uBACE,wBAA6B,CAC7B,6CAA+C,CAC/C,kCACF,CAEA,uBACE,6CAA+C,CAC/C,+BACF,CAEA,uBACE,+CAAkD,CAClD,kCACF,CAEA,qBACE,wBAA6B,CAC7B,2CAA6C,CAC7C,gCACF,CAEA,qBACE,2CAA6C,CAC7C,+BACF,CAEA,qBACE,6CAAgD,CAChD,gCACF,CAEA,uBACE,wBAA6B,CAC7B,6CAA+C,CAC/C,kCACF,CAEA,uBACE,6CAA+C,CAC/C,+BACF,CAEA,uBACE,+CAAkD,CAClD,kCACF,CAEA,oBACE,wBAA6B,CAC7B,0CAA4C,CAC5C,+BACF,CAEA,oBACE,0CAA4C,CAC5C,+BACF,CAEA,oBACE,4CAA+C,CAC/C,+BACF,CAEA,qBACE,wBAA6B,CAC7B,2CAA6C,CAC7C,gCACF,CAEA,qBACE,2CAA6C,CAC7C,+BACF,CAEA,qBACE,6CAAgD,CAChD,gCACF,CAEA,uBACE,wBAA6B,CAC7B,6CAA+C,CAC/C,kCACF,CAEA,uBACE,6CAA+C,CAC/C,+BACF,CAEA,uBACE,+CAAkD,CAClD,kCACF,CAEA,uBACE,wBAA6B,CAC7B,6CAA+C,CAC/C,kCACF,CAEA,uBACE,6CAA+C,CAC/C,+BACF,CAEA,uBACE,+CAAkD,CAClD,kCACF,CAcA,kEACE,uCAAwC,CACxC,4CAA+C,CAC/C,8BACF,CAEA,kHAGE,8BACF,CAEA,gDAEE,uCAAwC,CACxC,yCAA0C,CAC1C,8BACF,CAEA,gCACE,6BAA8B,CAC9B,UACF,CACA,sCACE,SACF,CACA,uCACE,UACF,CAEA,+CAEE,aACF,CACA,2DAEE,iBAAkB,CAClB,iBACF,CACA,iEAEE,uBACF,CACA,0MAIE,eAAgB,CADhB,iBAEF,CACA,2EAGE,gBAAiB,CADjB,kBAEF,CACA,2EAEE,uBACF,CACA,2DAEE,aACF,CACA,+EAGE,eAAgB,CADhB,iBAEF,CACA,qFAEE,aACF,CACA,yGAGE,eAAgB,CADhB,cAEF,CACA,mIAGE,aAAc,CADd,iBAEF,CC1eA,aAIE,kBAAmB,CAFnB,mBAAoB,CAGpB,sBAAuB,CAFvB,eAAgB,CAFhB,iBAAkB,CAMlB,iBAAkB,CAClB,qBAAsB,CAFtB,kBAGF,CACA,2BACE,wDACF,CACA,mBACE,wDACF,CACA,4CACE,YACF,CACA,gCAEE,kBAAmB,CADnB,YAAa,CAIb,kJAAyK,CAFzK,cAAe,CAGf,eAAgB,CAFhB,gBAGF,CACA,qBACE,wBAAiB,CAAjB,gBACF,CACA,+BAGE,iBAAkB,CADlB,WAAY,CADZ,UAGF,CACA,oDAEE,2BAAqB,CAArB,mBAAqB,CADrB,+BAAwB,CAAxB,uBAEF,CACA,kDACE,cAAe,CACf,gBACF,CACA,yBAGE,iBAAkB,CADlB,WAAY,CADZ,UAGF,CACA,8CAEE,2BAAqB,CAArB,mBAAqB,CADrB,+BAAwB,CAAxB,uBAEF,CACA,4CACE,cAAe,CACf,gBACF,CACA,mBAGE,iBAAkB,CADlB,WAAY,CADZ,UAGF,CACA,sCAGE,kJAAyK,CAFzK,cAAe,CACf,gBAEF,CACA,qBAGE,iBAAkB,CADlB,WAAY,CADZ,UAGF,CACA,wCAGE,kJAAyK,CAFzK,cAAe,CACf,gBAEF,CACA,oBAGE,iBAAkB,CADlB,WAAY,CADZ,UAGF,CACA,uCAGE,kJAAyK,CAFzK,cAAe,CACf,gBAEF,CACA,mBAGE,iBAAkB,CADlB,WAAY,CADZ,UAGF,CACA,sCAGE,kJAAyK,CAFzK,cAAe,CACf,gBAEF,CACA,yBAGE,kBAAmB,CADnB,YAAa,CADb,WAGF,CACA,4CACE,cAAe,CACf,gBACF,CACA,oBACE,8CACF,CACA,mBACE,wBACF,CACA,iBACE,aAAc,CAEd,WAAY,CACZ,gBAAiB,CAFjB,UAGF,CACA,mBAKE,WAAY,CAHZ,MAAO,CADP,iBAAkB,CAElB,KAAM,CACN,UAEF,CACA,mBACE,cACF,CAEA,qBAIE,kBAAmB,CAFnB,mBAAoB,CACpB,qBAAsB,CAFtB,iBAAkB,CAIlB,yBAAkB,CAAlB,sBAAkB,CAAlB,iBACF,CACA,8CAIE,iBAAkB,CAFlB,YAAa,CACb,sBAAuB,CAEvB,eAAgB,CAJhB,iBAKF,CACA,oDAEE,WAAY,CADZ,UAEF,CACA,sDAEE,WAAY,CADZ,UAEF,CACA,qDAEE,WAAY,CADZ,UAEF,CACA,oDAEE,WAAY,CADZ,UAEF,CACA,0DAEE,YAAa,CADb,WAEF,CACA,kDACE,iBACF,CACA,wDAEE,QAAU,CADV,SAEF,CACA,0DAEE,QAAU,CADV,SAEF,CACA,yDAEE,QAAU,CADV,SAEF,CACA,wDAEE,SAAU,CADV,SAEF,CACA,8DAEE,SAAU,CADV,SAEF,CACA,mDAEE,YAAa,CACb,sBAAuB,CAFvB,iBAGF,CACA,yEACE,4BAA6B,CAC7B,eACF,CACA,iFAGE,kBAAmB,CADnB,iBAAkB,CADlB,wBAAiB,CAAjB,gBAGF,CACA,uFACE,aAAc,CACd,YACF,CACA,yFACE,aAAc,CACd,eACF,CACA,wFACE,aAAc,CACd,YACF,CACA,uFACE,cAAe,CACf,YACF,CACA,6FACE,cAAe,CACf,YACF,CACA,8CAIE,YAAa,CAHb,4BAA6B,CAE7B,cAAe,CADf,iBAAkB,CAGlB,iCAA0B,CAA1B,yBAA0B,CAC1B,wBAAiB,CAAjB,gBACF,CACA,2DAGE,kBAAmB,CACnB,oCAAqC,CACrC,8CAA+C,CAJ/C,YAAa,CACb,sBAAuB,CAIvB,kBACF,CAMA,wIAGE,aAAc,CADd,WAAY,CADZ,UAGF,CACA,mEAGE,cAAe,CADf,WAAY,CADZ,UAGF,CACA,kEAGE,cAAe,CADf,WAAY,CADZ,UAGF,CACA,iEAGE,cAAe,CADf,WAAY,CADZ,UAGF,CACA,uEAGE,cAAe,CADf,WAAY,CADZ,UAGF,CACA,2DAGE,kBAAmB,CACnB,oCAAqC,CAKrC,mCAAoC,CAJpC,iBAAkB,CAGlB,kBAAmB,CAPnB,YAAa,CAMb,eAAgB,CALhB,sBAAuB,CAIvB,eAIF,CAKA,wIAEE,gBAAiB,CADjB,aAEF,CASA,sMAEE,gBAAiB,CADjB,cAEF,CACA,uEAEE,gBAAiB,CADjB,cAEF,CAEA,mBACE,oBACF,CACA,gCACE,qBACF,CACA,4CACE,aACF,CACA,4CACE,uCAAwC,CACxC,iBACF,CACA,sCACE,uCAAwC,CACxC,iBACF,CASA,qHACE,uCAAwC,CACxC,iBACF,CACA,4CACE,uCAAwC,CACxC,iBACF,CACA,kDACE,uCAAwC,CACxC,gBACF,CACA,6CACE,WACF,CACA,2CACE,UACF,CACA,6CACE,UACF,CACA,2CACE,UACF,CACA,6CACE,UACF,CACA,2CACE,UACF,CACA,6CACE,UACF,CACA,2CACE,UACF,CACA,6CACE,UACF,CACA,2CACE,UACF,CACA,6CACE,UACF,CACA,2CACE,UACF,CACA,6CACE,UACF,CACA,2CACE,UACF,CACA,6CACE,UACF,CACA,2CACE,UACF,CACA,6CACE,UACF,CACA,2CACE,UACF,CACA,6CACE,UACF,CACA,2CACE,UACF,CAIA,0FACE,UACF,CACA,8CACE,UACF,CACA,4CACE,UACF,CACA,8CACE,UACF,CACA,4CACE,UACF,CACA,8CACE,UACF,CACA,4CACE,UACF,CACA,8CACE,UACF,CACA,4CACE,UACF,CACA,8CACE,UACF,CACA,4CACE,UACF,CACA,8CACE,UACF,CACA,4CACE,UACF,CACA,8CACE,UACF,CACA,4CACE,UACF,CACA,8CACE,UACF,CACA,4CACE,UACF,CACA,8CACE,UACF,CACA,4CACE,UACF,CACA,8CACE,UACF,CACA,4CACE,WACF,CACA,0CACE,2CACF,CAEA,mBACE,4CAA8C,CAC9C,+BACF,CAEA,kBACE,2CAA6C,CAC7C,+BACF,CAEA,kBACE,2CAA6C,CAC7C,+BACF,CAEA,mBACE,4CAA8C,CAC9C,+BACF,CAEA,kBACE,2CAA6C,CAC7C,+BACF,CAEA,oBACE,6CAA+C,CAC/C,+BACF,CAEA,wBACE,iDAAmD,CACnD,+BACF,CAEA,yBACE,kDAAoD,CACpD,+BACF,CAEA,kBACE,2CAA6C,CAC7C,+BACF,CAEA,oBACE,6CAA+C,CAC/C,+BACF,CAEA,kBACE,2CAA6C,CAC7C,+BACF,CAEA,oBACE,6CAA+C,CAC/C,+BACF,CAEA,iBACE,0CAA4C,CAC5C,+BACF,CAEA,kBACE,2CAA6C,CAC7C,+BACF,CAEA,oBACE,6CAA+C,CAC/C,+BACF,CAEA,oBACE,6CAA+C,CAC/C,+BACF,CAEA,8BAEE,sCAAuC,CADvC,kBAAmB,CAKnB,kBAAmB,CAFnB,qBAAsB,CADtB,oBAAqB,CAKrB,WAAY,CAHZ,iBAAkB,CAElB,UAEF,CACA,gDAEE,WAAY,CADZ,UAEF,CACA,0CAEE,WAAY,CADZ,UAEF,CACA,oCAEE,WAAY,CADZ,UAEF,CACA,sCAEE,WAAY,CADZ,UAEF,CACA,qCAEE,WAAY,CADZ,UAEF,CACA,oCAEE,WAAY,CADZ,UAEF,CACA,0CAEE,YAAa,CADb,WAEF,CAkBA,wSACE,iBACF,CAEA,uDACE,iBACF,CAEA,qCACE,8CACF,CAEA,uCACE,kEAA6D,CAA7D,0DACF,CAEA,sBACE,wDAAqD,CAArD,gDACF,CAEA,gDACE,GACE,SAAU,CACV,0BAAmB,CAAnB,kBACF,CACA,GACE,cAAe,CACf,SAAU,CACV,6BAAsB,CAAtB,qBACF,CACF,CAVA,wCACE,GACE,SAAU,CACV,0BAAmB,CAAnB,kBACF,CACA,GACE,cAAe,CACf,SAAU,CACV,6BAAsB,CAAtB,qBACF,CACF,CACA,uCACE,GACE,0BAAmB,CAAnB,kBACF,CACA,IACE,2BAAqB,CAArB,mBACF,CACA,GACE,0BAAmB,CAAnB,kBACF,CACF,CAVA,+BACE,GACE,0BAAmB,CAAnB,kBACF,CACA,IACE,2BAAqB,CAArB,mBACF,CACA,GACE,0BAAmB,CAAnB,kBACF,CACF,CACA,qDAEE,aACF,CAKA,0PAEE,2BAAqB,CAArB,mBACF,CACA,iEAEE,SAAU,CACV,OACF,CACA,iEAEE,aACF,CACA,mHAEE,gBAAiB,CACjB,cACF,CACA,mHAEE,gBAAiB,CACjB,kBACF,CACA,uGAEE,gBAAiB,CACjB,kBACF,CACA,gNAIE,gBAAiB,CACjB,kBACF,CACA,mHAEE,gBAAiB,CACjB,kBACF,CACA,+HAEE,gBAAiB,CACjB,iBACF,CCzrBA,qCACE,GACE,0BAAmB,CAAnB,kBACF,CACA,GACE,4BAAsB,CAAtB,oBACF,CACF,CAPA,6BACE,GACE,0BAAmB,CAAnB,kBACF,CACA,GACE,4BAAsB,CAAtB,oBACF,CACF,CACA,uCACE,GACE,4BAAsB,CAAtB,oBACF,CACA,GACE,0BAAmB,CAAnB,kBACF,CACF,CAPA,+BACE,GACE,4BAAsB,CAAtB,oBACF,CACA,GACE,0BAAmB,CAAnB,kBACF,CACF,CAMA,gCAHE,yDAAkD,CAAlD,iDAAkD,CADlD,8PAsBF,CAlBA,oBAQE,yCAA0C,CAC1C,4BAA6B,CAC7B,6CAA8C,CAN9C,eAAgB,CAUhB,qBAAsB,CACtB,8BAA+B,CAF/B,WAAY,CAZZ,oBAAqB,CAWrB,YAAa,CAVb,iBAAkB,CAClB,qBAAsB,CAQtB,UAOF,CACA,gDAZE,kJAAyK,CAFzK,cAAe,CACf,gBAmBF,CANA,4BACE,WAAY,CAIZ,gBACF,CACA,0BAEE,cAAe,CADf,WAAY,CAEZ,gBAGF,CACA,oDAHE,kJAAyK,CACzK,gBAQF,CANA,0BAEE,cAAe,CADf,WAAY,CAIZ,gBACF,CACA,0BACE,yCAA0C,CAC1C,wBACF,CACA,0BACE,yCAA0C,CAC1C,+CACF,CACA,gCACE,yCAA0C,CAC1C,2CACF,CACA,iCACE,yCAA0C,CAC1C,2CACF,CACA,wCACE,cACF,CACA,0BACE,uDAAwD,CACxD,mDACF,CACA,gCACE,qDAAsD,CACtD,iDACF,CACA,mDACE,uDAAwD,CACxD,qCACF,CACA,iCACE,sDAAuD,CACvD,qCACF,CACA,4BACE,wDAAyD,CACzD,oDACF,CACA,kCACE,sDAAuD,CACvD,kDACF,CACA,qDACE,wDAAyD,CACzD,sCACF,CACA,mCACE,uDAAwD,CACxD,sCACF,CACA,iCAEE,kBAAmB,CADnB,mBAEF,CACA,6CACE,cACF,CACA,iCAEE,kBAAmB,CADnB,mBAEF,CACA,6CACE,eACF,CACA,0DAEE,kBAAmB,CADnB,mBAEF,CACA,2BACE,WACF,CACA,+BACE,8BACF,CACA,iFAEE,qCACF,CACA,yFAEE,mBACF,CACA,6FAEE,cACF,CACA,mHAEE,qCACF,CACA,6GAEE,6CAA8C,CAC9C,wDAAyD,CACzD,mBACF,CACA,qIAEE,wBAAyB,CADzB,cAEF,CACA,8EAEE,sBAAuB,CADvB,cAEF,CACA,qEACE,iFACF,CACA,sFAEE,eAAgB,CADhB,uBAEF,CACA,sEACE,iFACF,CACA,uFACE,uBACF,CACA,yGAEE,kBAAmB,CACnB,wBAA6B,CAF7B,mBAGF,CACA,qHACE,wBACF,CACA,2JAEE,wBAA6B,CAD7B,4BAEF,CACA,iIACE,yCAA0C,CAC1C,4BACF,CAIA,yfACE,yCACF,CACA,6IAEE,yCAA0C,CAD1C,+CAEF,CAIA,qnBACE,uBACF,CACA,4WAEE,yCAA0C,CAD1C,qBAEF,CACA,uLACE,uBAAgD,CAEhD,+CAAuB,CAAvB,8CAAuB,CADvB,iFAEF,CACA,yNACE,eACF,CACA,qLACE,uBAAgD,CAEhD,+CAAuB,CAAvB,8CAAuB,CADvB,iFAEF,CACA,uNACE,eACF,CAIA,+fACE,yCACF,CACA,gmBACE,yCACF,CACA,wrBACE,iFACF,CACA,2JACE,wBACF,CACA,mLACE,uDAAwD,CACxD,mDACF,CACA,+LACE,qDAAsD,CACtD,iDACF,CACA,gdACE,qDACF,CAKA,+oBACE,uDAAwD,CACxD,qCACF,CACA,iMACE,sDACF,CACA,odACE,sDAAuD,CACvD,qCACF,CACA,wyBACE,uDACF,CACA,g4BACE,iFACF,CACA,+JACE,wBACF,CACA,uLACE,wDAAyD,CACzD,oDACF,CACA,mMACE,sDAAuD,CACvD,kDACF,CACA,wdACE,sDACF,CAKA,2pBACE,wDAAyD,CACzD,sCACF,CACA,qMACE,uDACF,CACA,4dACE,uDAAwD,CACxD,sCACF,CACA,wzBACE,wDACF,CACA,g5BACE,iFACF,CACA,6BAIE,uDAAwD,CAFxD,qCAAsC,CADtC,kBAIF,CACA,gEAHE,gDAKF,CACA,yOAKE,qCACF,CAEA,YAOE,wBAA6B,CAN7B,WAAY,CAOZ,qBAAsB,CAJtB,aAAc,CAFd,YAAa,CAGb,iBAAkB,CAClB,kBAAmB,CAHnB,UAMF,CACA,6EACE,YACF,CACA,uDACE,YACF,CACA,uCACE,8BACF,CAFA,yBACE,8BACF,CACA,kBAIE,kJAAyK,CAFzK,cAAe,CADf,WAAY,CAEZ,gBAAiB,CAEjB,gBACF,CACA,kBACE,WAAY,CAEZ,gBAAiB,CAEjB,gBACF,CACA,sCAHE,kJAAyK,CAFzK,cAWF,CANA,oBACE,WAAY,CAEZ,gBAAiB,CAEjB,gBACF,CACA,qBAEE,aAAc,CADd,kBAEF,CACA,wBAME,8BAA+B,CAC/B,aAAc,CAFd,kJAAyK,CAFzK,cAAe,CADf,eAAgB,CAEhB,gBAAiB,CAHjB,iBAAkB,CAOlB,kBACF,CACA,sCAGE,kBAAmB,CAFnB,YAAa,CACb,sBAEF,CACA,gDAEE,8BAA+B,CAC/B,eAAgB,CAFhB,aAAc,CAGd,kBACF,CACA,gDACE,8BAA+B,CAC/B,YACF,CAMA,4DAFE,kBAAmB,CAFnB,YAAa,CACb,sBASF,CANA,yCAGE,WAAY,CAEZ,cACF,CAIA,gIACE,aACF,CACA,0BACE,YACF,CACA,uCAGE,kBAAmB,CACnB,yCAA0C,CAC1C,8BAA+B,CAH/B,YAAa,CAOb,aAAc,CADd,kJAAyK,CAFzK,cAAe,CALf,WAAY,CAMZ,gBAGF,CACA,kGACE,cACF,CACA,mBAEE,iCAAkC,CADlC,iFAEF,CACA,oBACE,iFAAkF,CAClF,kCACF,CACA,gDACE,qCACF,CAFA,kCACE,qCACF,CACA,kBAGE,oBAAqB,CADrB,kBAAmB,CADnB,mBAAoB,CAGpB,cACF,CACA,2KAIE,eACF,CACA,uOAIE,iFACF,CACA,kOAIE,iFACF,CACA,gQAIE,iBACF,CACA,8RAKE,yCAA0C,CAK1C,UAAW,CANX,UAAW,CAGX,iBAAkB,CAClB,UAAW,CACX,OAAQ,CAHR,SAKF,CACA,+BACE,kBACF,CAOA,oZAIE,eACF,CACA,okBAQE,iFACF,CACA,4jBAQE,iFACF,CACA,oNAIE,iBACF,CACA,4OAKE,yCAA0C,CAK1C,UAAW,CANX,UAAW,CAGX,iBAAkB,CAClB,UAAW,CACX,OAAQ,CAHR,SAKF,CACA,yCAEE,kBAAmB,CADnB,eAEF,CACA,2DACE,YACF,CACA,4EAEE,eAAgB,CADhB,YAEF,CAMA,sDACE,sBAAuB,CACvB,qCACF,CACA,qCACE,sBACF,CAEA,sDACE,wBAA6B,CAC7B,wBACF,CACA,iDACE,wBACF,CACA,mEACE,qCACF,CACA,qEACE,sCACF,CAEA,mEAEE,aACF,CACA,qHAEE,iBAAkB,CAClB,eACF,CACA,qHAGE,cAAe,CADf,kBAEF,CACA,mDAEE,iBAAkB,CAClB,kBACF,CACA,2EAGE,gBAAiB,CADjB,iBAEF,CAQA,wtBAIE,gBAAiB,CACjB,cACF,CACA,iEAEE,aAAc,CACd,kCACF,CACA,mEAGE,iCAAkC,CADlC,cAEF,CAWA,2sBAGE,SAAU,CADV,UAEF,CACA,qFAEE,aACF,CACA,qFAEE,eACF,CACA,yFAGE,iBAAkB,CADlB,eAEF,CC7oBA,WAEE,oBAAqB,CAErB,WAAY,CAHZ,iBAAkB,CAElB,UAEF,CACA,yCACE,GACE,2BAAoB,CAApB,mBACF,CACA,GACE,+BAAyB,CAAzB,uBACF,CACF,CAPA,iCACE,GACE,2BAAoB,CAApB,mBACF,CACA,GACE,+BAAyB,CAAzB,uBACF,CACF,CACA,mBAME,+BAAgC,CAJhC,iBAAkB,CADlB,iBAAkB,CAIlB,OAAQ,CADR,kCAA2B,CAA3B,0BAA2B,CAD3B,UAIF,CACA,uBAEE,2DAAsD,CAAtD,mDAAsD,CACtD,oCAA6B,CAA7B,4BAA6B,CAF7B,cAAe,CAKf,WAAY,CAFZ,kBAAmB,CACnB,UAEF,CACA,mBAEE,4DAAuD,CAAvD,oDAAuD,CACvD,oCAA6B,CAA7B,4BAA6B,CAF7B,mBAGF,CACA,oBACE,UAAY,CACZ,wBAAiB,CAAjB,gBACF,CACA,iBACE,aACF,CACA,uBACE,UAAW,CAKX,WAAY,CAFZ,MAAO,CAFP,iBAAkB,CAClB,KAAM,CAEN,UAAW,CAEX,SACF,CACA,oCACE,aACF,CACA,2BACE,WAAY,CACZ,UACF,CACA,wBACE,YACF,CACA,sCACE,SAAU,CACV,wBAAiB,CAAjB,gBACF,CAMA,yDAEE,WAAY,CADZ,UAEF,CAMA,2DAEE,WAAY,CADZ,UAEF,CAMA,yDAEE,WAAY,CADZ,UAEF,CAEA,qBACE,eACF,CAMA,sHAEE,aACF,CCxGA,oBACE,YAAa,CACb,gBAAiB,CACjB,WACF,CACA,2BACE,aAAc,CACd,SACF,CACA,mCACE,YAAa,CACb,QAAO,CACP,gBAAiB,CACjB,iBACF,CAEA,mEAEE,aACF,CCnBA,mBAEE,kBAAmB,CACnB,qBAAsB,CAFtB,mBAAoB,CAIpB,yDAAkD,CAAlD,iDAAkD,CADlD,8PAEF,CACA,+BAME,uCAAwC,CAFxC,yCAA0C,CAC1C,6CAA8C,CAE9C,qBAAsB,CANtB,mBAAoB,CACpB,qBAAsB,CACtB,eAKF,CACA,yDAME,kBAAmB,CAGnB,eAAgB,CAChB,8BAA+B,CAL/B,mBAAoB,CAJpB,UAAW,CAMX,sBAAuB,CAHvB,QAAS,CADT,SAAU,CAKV,wBAAiB,CAAjB,gBAAiB,CANjB,UASF,CACA,oOAEE,yCAA0C,CAD1C,cAEF,CACA,sOAEE,yCAA0C,CAD1C,cAEF,CACA,sMACE,gDAAiD,CACjD,qCACF,CACA,4MACE,kBACF,CACA,2CACE,qCACF,CACA,qCACE,eACF,CACA,2DACE,gBACF,CACA,gGACE,aACF,CACA,+DACE,WACF,CACA,qEACE,WACF,CACA,6DACE,WACF,CACA,mEACE,WACF,CACA,6DACE,WACF,CACA,mEACE,WACF,CAEA,wGACE,SACF,CAEA,iEAEE,aACF,CACA,yFAEE,gBAAiB,CACjB,gBACF,CACA,qGAEE,gBAAiB,CACjB,gBACF,CACA,iJAEE,gBAAiB,CACjB,iBACF,CACA,2NAEE,gBAAiB,CACjB,cACF,CCrGA,eAIE,eAAgB,CAHhB,QAAS,CACT,WAAY,CACZ,UAEF,CACA,oBAIE,eAAgB,CAFhB,QAAS,CACT,gBAAmB,CAFnB,iBAIF,CACA,yBAKE,8CAA+C,CAD/C,wBAAyB,CADzB,QAAS,CAFT,iBAAkB,CAClB,QAIF,CACA,yBAKE,8CAA+C,CAD/C,UAAW,CAHX,iBAAkB,CAClB,OAAQ,CACR,SAGF,CACA,iCACE,0CACF,CACA,iCACE,wDACF,CACA,iCACE,0CACF,CACA,iCACE,0CACF,CACA,+BACE,yCACF,CACA,gCAGE,iBAAkB,CAKlB,QAAS,CACT,eAAgB,CAPhB,YAAa,CAKb,WAAY,CAFZ,QAAS,CAJT,iBAAkB,CAGlB,QAAS,CAMT,sCAAgC,CAAhC,8BAAgC,CAJhC,UAKF,CACA,oFACE,wBAA6B,CAC7B,+BACF,CACA,oFACE,wBAA6B,CAC7B,+BACF,CACA,oFACE,wBAA6B,CAC7B,+BACF,CACA,kFACE,wBAA6B,CAC7B,8BACF,CACA,oFACE,wBAA6B,CAC7B,6CACF,CACA,4BAOE,8BAA+B,CAN/B,cAAe,CACf,gBAAiB,CAGjB,iBAAkB,CADlB,iBAAkB,CAElB,qBAEF,CACA,+FANE,kJAYF,CANA,mEAIE,8BAA+B,CAH/B,cAAe,CACf,gBAAiB,CAGjB,cACF,CACA,wDACE,gBACF,CACA,kcACE,QACF,CACA,4OACE,aACF,CACA,+IACE,gBACF,CACA,mOACE,oBAAqB,CAErB,eAAgB,CADhB,sBAEF,CACA,sOAEE,QAAS,CACT,gBAAiB,CAFjB,sBAGF,CACA,uDAGE,8BAA+B,CAF/B,iBAAkB,CAIlB,gBAAiB,CAHjB,QAAS,CAET,UAEF,CACA,+NACE,qBACF,CACA,2EACE,uBACF,CAEA,yDAEE,aACF,CACA,6EAIE,aAAc,CACd,+CAAgD,CAHhD,SAAU,CACV,SAGF,CACA,2FAEE,SAAU,CACV,SAAU,CACV,qCAA+B,CAA/B,6BACF,CACA,mFAEE,iBACF,CACA,2IAEE,iBACF,CACA,unCAUE,SAAU,CACV,SACF,CACA,+WAIE,aAAc,CACd,iBACF,CACA,uhBAIE,SAAU,CACV,qBAAsB,CACtB,gBACF,CACA,6hBAIE,eACF,CACA,yIAEE,aAAc,CACd,+BAAgC,CAChC,eACF,CACA,+gBAIE,MAAO,CACP,sBACF,CCpMA,YACE,mBACF,CACA,oBAKE,YAAa,CAHb,QAAS,CAIT,sBAAuB,CALvB,cAAe,CAEf,KAAM,CACN,UAAW,CAGX,YACF,CACA,6CAEE,0BAAmB,CAAnB,uBAAmB,CAAnB,kBAAmB,CACnB,iBAAkB,CAFlB,yBAAkB,CAAlB,sBAAkB,CAAlB,iBAGF,CACA,mFACE,wBAAkB,CAAlB,2BAAkB,CAAlB,gBAAkB,CAClB,8BAAiC,CAAjC,sBACF,CACA,gCAIE,QAAS,CACT,gBAAiB,CAFjB,yBAAkB,CAAlB,iBAAkB,CADlB,uCAAgC,CAAhC,+BAAgC,CADhC,gDAKF,CACA,oBAUE,sBAAuB,CAJvB,uCAAwC,CACxC,8CAA+C,CAL/C,sCAAuC,CAYvC,8BAA+B,CAL/B,mBAAoB,CAJpB,kJAAyK,CAFzK,cAAe,CAUf,eAAgB,CAFhB,sBAAuB,CAPvB,gBAAiB,CAQjB,WAAY,CAJZ,gBAA0B,CAP1B,kBAcF,CACA,6CAEE,WAAY,CADZ,eAEF,CACA,6CAIE,oBAAqB,CAHrB,gBAAiB,CACjB,iBAAkB,CAGlB,wBAAyB,CAFzB,eAGF,CACA,yDACE,wDAAyD,CACzD,0CACF,CACA,8DACE,+BACF,CACA,yDACE,wDAAyD,CACzD,0CACF,CACA,8DACE,+BACF,CACA,sDACE,qDAAsD,CACtD,uCACF,CACA,wDACE,4BACF,CACA,uDACE,uDAAwD,CACxD,yCACF,CACA,0DACE,8BACF,CACA,qCACE,+BACF,CACA,qCACE,+BACF,CACA,kCACE,4BACF,CACA,mCACE,8BACF,CACA,2BACE,qFAAsF,CAAtF,6EAAsF,CACtF,oCAA6B,CAA7B,4BACF,CACA,2BACE,qFAAsF,CAAtF,6EAAsF,CACtF,oCAA6B,CAA7B,4BACF,CACA,kDACE,GACE,SAAU,CACV,mCAA4B,CAA5B,2BACF,CACA,GACE,SACF,CACF,CARA,0CACE,GACE,SAAU,CACV,mCAA4B,CAA5B,2BACF,CACA,GACE,SACF,CACF,CACA,kDACE,GACE,SACF,CACA,GACE,SAAU,CACV,mCAA4B,CAA5B,2BACF,CACF,CARA,0CACE,GACE,SACF,CACA,GACE,SAAU,CACV,mCAA4B,CAA5B,2BACF,CACF,CAEA,gBACE,aACF,CACA,6DAEE,gBAAiB,CACjB,iBAAkB,CAFlB,gBAGF,CCpIA,KAKE,kCAAmC,CACnC,iCAAkC,CAJlC,mIAEY,CAHZ,QAMF,CAEA,KACE,uEAEF,CAEA,oBAEE,WAAY,CADZ,UAEF,CAEA,0BACE,uCAA4C,CAC5C,+CAEF,CAEA,0BAIE,wBAAyB,CAHzB,iBAAkB,CAClB,uCAA4C,CAC5C,+CAEF,CAEA,wBACE,4BACF","sources":["../node_modules/@douyinfe/semi-ui/lib/es/_base/base.css","../node_modules/@douyinfe/semi-foundation/lib/es/backtop/backtop.css","../node_modules/@douyinfe/semi-foundation/lib/es/button/iconButton.css","../node_modules/@douyinfe/semi-foundation/lib/es/button/button.css","../node_modules/@douyinfe/semi-icons/lib/es/styles/icons.css","../node_modules/@douyinfe/semi-foundation/lib/es/descriptions/descriptions.css","../node_modules/@douyinfe/semi-foundation/lib/es/divider/divider.css","../node_modules/@douyinfe/semi-foundation/lib/es/modal/modal.css","../node_modules/@douyinfe/semi-foundation/lib/es/_portal/portal.css","../node_modules/@douyinfe/semi-foundation/lib/es/typography/typography.css","../node_modules/@douyinfe/semi-foundation/lib/es/tooltip/tooltip.css","../node_modules/@douyinfe/semi-foundation/lib/es/popover/popover.css","../node_modules/@douyinfe/semi-foundation/lib/es/dropdown/dropdown.css","../node_modules/@douyinfe/semi-foundation/lib/es/layout/layout.css","../node_modules/@douyinfe/semi-foundation/lib/es/input/textarea.css","../node_modules/@douyinfe/semi-foundation/lib/es/navigation/navigation.css","../node_modules/@douyinfe/semi-foundation/lib/es/collapsible/collapsible.css","../node_modules/@douyinfe/semi-foundation/lib/es/popconfirm/popconfirm.css","../node_modules/@douyinfe/semi-foundation/lib/es/space/space.css","../node_modules/@douyinfe/semi-foundation/lib/es/table/table.css","../node_modules/@douyinfe/semi-foundation/lib/es/checkbox/checkbox.css","../node_modules/@douyinfe/semi-foundation/lib/es/radio/radio.css","../node_modules/@douyinfe/semi-foundation/lib/es/pagination/pagination.css","../node_modules/@douyinfe/semi-foundation/lib/es/select/select.css","../node_modules/@douyinfe/semi-foundation/lib/es/tag/tag.css","../node_modules/@douyinfe/semi-foundation/lib/es/avatar/avatar.css","../node_modules/@douyinfe/semi-foundation/lib/es/input/input.css","../node_modules/@douyinfe/semi-foundation/lib/es/spin/spin.css","../node_modules/@douyinfe/semi-foundation/lib/es/overflowList/overflowList.css","../node_modules/@douyinfe/semi-foundation/lib/es/inputNumber/inputNumber.css","../node_modules/@douyinfe/semi-foundation/lib/es/timeline/timeline.css","../node_modules/@douyinfe/semi-foundation/lib/es/toast/toast.css","index.css"],"sourcesContent":["/* shadow */\n/* sizing */\n/* spacing */\nbody, :host {\n --semi-transition_duration-slowest:0ms;\n --semi-transition_duration-slower:0ms;\n --semi-transition_duration-slow:0ms;\n --semi-transition_duration-normal:0ms;\n --semi-transition_duration-fast:0ms;\n --semi-transition_duration-faster:0ms;\n --semi-transition_duration-fastest:0ms;\n --semi-transition_duration-none:0ms;\n --semi-transition_function-linear:linear;\n --semi-transition_function-ease:ease;\n --semi-transition_function-easeIn:ease-in;\n --semi-transition_function-easeOut:ease-out;\n --semi-transition_function-easeInIOut:ease-in-out;\n --semi-transition_delay-none: 0ms;\n --semi-transition_delay-slowest:0ms;\n --semi-transition_delay-slower:0ms;\n --semi-transition_delay-slow:0ms;\n --semi-transition_delay-normal:0ms;\n --semi-transition_delay-fast:0ms;\n --semi-transition_delay-faster:0ms;\n --semi-transition_delay-fastest:0ms;\n --semi-transform_scale-none:scale(1,1);\n --semi-transform_scale-small:scale(1,1);\n --semi-transform_scale-medium:scale(1,1);\n --semi-transform_scale-large:scale(1,1);\n --semi-transform-rotate-none:rotate(0deg);\n --semi-transform_rotate-clockwise90deg:rotate(90deg);\n --semi-transform_rotate-clockwise180deg:rotate(180deg);\n --semi-transform_rotate-clockwise270deg:rotate(270deg);\n --semi-transform_rotate-clockwise360deg:rotate(360deg);\n --semi-transform_rotate-anticlockwise90deg:rotate(-90deg);\n --semi-transform_rotate-anticlockwise180deg:rotate(-180deg);\n --semi-transform_rotate-anticlockwise270deg:rotate(-270deg);\n --semi-transform_rotate-anticlockwise360deg:rotate(-360deg);\n}\n\nbody, body .semi-always-light, :host, :host .semi-always-light {\n --semi-amber-0: 254,251,235;\n --semi-amber-1: 252,245,206;\n --semi-amber-2: 249,232,158;\n --semi-amber-3: 246,216,111;\n --semi-amber-4: 243,198,65;\n --semi-amber-5: 240,177,20;\n --semi-amber-6: 200,138,15;\n --semi-amber-7: 160,102,10;\n --semi-amber-8: 120,70,6;\n --semi-amber-9: 80,43,3;\n --semi-black: 0,0,0;\n --semi-blue-0: 234,245,255;\n --semi-blue-1: 203,231,254;\n --semi-blue-2: 152,205,253;\n --semi-blue-3: 101,178,252;\n --semi-blue-4: 50,149,251;\n --semi-blue-5: 0,100,250;\n --semi-blue-6: 0,98,214;\n --semi-blue-7: 0,79,179;\n --semi-blue-8: 0,61,143;\n --semi-blue-9: 0,44,107;\n --semi-cyan-0: 229,247,248;\n --semi-cyan-1: 194,239,240;\n --semi-cyan-2: 138,221,226;\n --semi-cyan-3: 88,203,211;\n --semi-cyan-4: 44,184,197;\n --semi-cyan-5: 5,164,182;\n --semi-cyan-6: 3,134,152;\n --semi-cyan-7: 1,105,121;\n --semi-cyan-8: 0,77,91;\n --semi-cyan-9: 0,50,61;\n --semi-green-0: 236,247,236;\n --semi-green-1: 208,240,209;\n --semi-green-2: 164,224,167;\n --semi-green-3: 125,209,130;\n --semi-green-4: 90,194,98;\n --semi-green-5: 59,179,70;\n --semi-green-6: 48,149,59;\n --semi-green-7: 37,119,47;\n --semi-green-8: 27,89,36;\n --semi-green-9: 17,60,24;\n --semi-grey-0: 249,249,249;\n --semi-grey-1: 230,232,234;\n --semi-grey-2: 198,202,205;\n --semi-grey-3: 167,171,176;\n --semi-grey-4: 136,141,146;\n --semi-grey-5: 107,112,117;\n --semi-grey-6: 85,91,97;\n --semi-grey-7: 65,70,76;\n --semi-grey-8: 46,50,56;\n --semi-grey-9: 28,31,35;\n --semi-indigo-0: 236,239,248;\n --semi-indigo-1: 209,216,240;\n --semi-indigo-2: 167,179,225;\n --semi-indigo-3: 128,144,211;\n --semi-indigo-4: 94,111,196;\n --semi-indigo-5: 63,81,181;\n --semi-indigo-6: 51,66,161;\n --semi-indigo-7: 40,52,140;\n --semi-indigo-8: 31,40,120;\n --semi-indigo-9: 23,29,99;\n --semi-light-blue-0: 233,247,253;\n --semi-light-blue-1: 201,236,252;\n --semi-light-blue-2: 149,216,248;\n --semi-light-blue-3: 98,195,245;\n --semi-light-blue-4: 48,172,241;\n --semi-light-blue-5: 0,149,238;\n --semi-light-blue-6: 0,123,202;\n --semi-light-blue-7: 0,99,167;\n --semi-light-blue-8: 0,75,131;\n --semi-light-blue-9: 0,53,95;\n --semi-light-green-0: 243,248,236;\n --semi-light-green-1: 227,240,208;\n --semi-light-green-2: 200,226,165;\n --semi-light-green-3: 173,211,126;\n --semi-light-green-4: 147,197,91;\n --semi-light-green-5: 123,182,60;\n --semi-light-green-6: 100,152,48;\n --semi-light-green-7: 78,121,38;\n --semi-light-green-8: 57,91,27;\n --semi-light-green-9: 37,61,18;\n --semi-lime-0: 242,250,230;\n --semi-lime-1: 227,246,197;\n --semi-lime-2: 203,237,142;\n --semi-lime-3: 183,227,91;\n --semi-lime-4: 167,218,44;\n --semi-lime-5: 155,209,0;\n --semi-lime-6: 126,174,0;\n --semi-lime-7: 99,139,0;\n --semi-lime-8: 72,104,0;\n --semi-lime-9: 47,70,0;\n --semi-orange-0: 255,248,234;\n --semi-orange-1: 254,238,204;\n --semi-orange-2: 254,217,152;\n --semi-orange-3: 253,193,101;\n --semi-orange-4: 253,166,51;\n --semi-orange-5: 252,136,0;\n --semi-orange-6: 210,103,0;\n --semi-orange-7: 168,74,0;\n --semi-orange-8: 126,49,0;\n --semi-orange-9: 84,29,0;\n --semi-pink-0: 253,236,239;\n --semi-pink-1: 251,207,216;\n --semi-pink-2: 246,160,181;\n --semi-pink-3: 242,115,150;\n --semi-pink-4: 237,72,123;\n --semi-pink-5: 233,30,99;\n --semi-pink-6: 197,19,86;\n --semi-pink-7: 162,11,72;\n --semi-pink-8: 126,5,58;\n --semi-pink-9: 90,1,43;\n --semi-purple-0: 247,233,247;\n --semi-purple-1: 239,202,240;\n --semi-purple-2: 221,155,224;\n --semi-purple-3: 201,111,209;\n --semi-purple-4: 180,73,194;\n --semi-purple-5: 158,40,179;\n --semi-purple-6: 135,30,158;\n --semi-purple-7: 113,22,138;\n --semi-purple-8: 92,15,117;\n --semi-purple-9: 73,10,97;\n --semi-red-0: 254,242,237;\n --semi-red-1: 254,221,210;\n --semi-red-2: 253,183,165;\n --semi-red-3: 251,144,120;\n --semi-red-4: 250,102,76;\n --semi-red-5: 249,57,32;\n --semi-red-6: 213,37,21;\n --semi-red-7: 178,20,12;\n --semi-red-8: 142,8,5;\n --semi-red-9: 106,1,3;\n --semi-teal-0: 228,247,244;\n --semi-teal-1: 192,240,232;\n --semi-teal-2: 135,224,211;\n --semi-teal-3: 84,209,193;\n --semi-teal-4: 39,194,176;\n --semi-teal-5: 0,179,161;\n --semi-teal-6: 0,149,137;\n --semi-teal-7: 0,119,111;\n --semi-teal-8: 0,89,85;\n --semi-teal-9: 0,60,58;\n --semi-violet-0: 243,237,249;\n --semi-violet-1: 226,209,244;\n --semi-violet-2: 196,167,233;\n --semi-violet-3: 166,127,221;\n --semi-violet-4: 136,91,210;\n --semi-violet-5: 106,58,199;\n --semi-violet-6: 87,47,179;\n --semi-violet-7: 70,37,158;\n --semi-violet-8: 54,28,138;\n --semi-violet-9: 40,20,117;\n --semi-white: 255,255,255;\n --semi-yellow-0: 255,253,234;\n --semi-yellow-1: 254,251,203;\n --semi-yellow-2: 253,243,152;\n --semi-yellow-3: 252,232,101;\n --semi-yellow-4: 251,218,50;\n --semi-yellow-5: 250,200,0;\n --semi-yellow-6: 208,170,0;\n --semi-yellow-7: 167,139,0;\n --semi-yellow-8: 125,106,0;\n --semi-yellow-9: 83,72,0;\n}\n\nbody[theme-mode=dark], body .semi-always-dark, :host([theme-mode=dark]), :host .semi-always-dark {\n --semi-red-0: 108,9,11;\n --semi-red-1: 144,17,16;\n --semi-red-2: 180,32,25;\n --semi-red-3: 215,51,36;\n --semi-red-4: 251,73,50;\n --semi-red-5: 252,114,90;\n --semi-red-6: 253,153,131;\n --semi-red-7: 253,190,172;\n --semi-red-8: 254,224,213;\n --semi-red-9: 255,243,239;\n --semi-pink-0: 92,7,48;\n --semi-pink-1: 128,14,65;\n --semi-pink-2: 164,23,81;\n --semi-pink-3: 199,34,97;\n --semi-pink-4: 235,47,113;\n --semi-pink-5: 239,86,134;\n --semi-pink-6: 243,126,159;\n --semi-pink-7: 247,168,188;\n --semi-pink-8: 251,211,220;\n --semi-pink-9: 253,238,241;\n --semi-purple-0: 74,16,97;\n --semi-purple-1: 94,23,118;\n --semi-purple-2: 115,31,138;\n --semi-purple-3: 137,40,159;\n --semi-purple-4: 160,51,179;\n --semi-purple-5: 181,83,194;\n --semi-purple-6: 202,120,209;\n --semi-purple-7: 221,160,225;\n --semi-purple-8: 239,206,240;\n --semi-purple-9: 247,235,247;\n --semi-violet-0: 64,27,119;\n --semi-violet-1: 76,36,140;\n --semi-violet-2: 88,46,160;\n --semi-violet-3: 100,57,181;\n --semi-violet-4: 114,70,201;\n --semi-violet-5: 136,101,212;\n --semi-violet-6: 162,136,223;\n --semi-violet-7: 190,173,233;\n --semi-violet-8: 221,212,244;\n --semi-violet-9: 241,238,250;\n --semi-indigo-0: 23,30,101;\n --semi-indigo-1: 32,41,122;\n --semi-indigo-2: 41,54,142;\n --semi-indigo-3: 52,68,163;\n --semi-indigo-4: 64,83,183;\n --semi-indigo-5: 95,113,197;\n --semi-indigo-6: 129,145,212;\n --semi-indigo-7: 167,180,226;\n --semi-indigo-8: 209,216,241;\n --semi-indigo-9: 237,239,248;\n --semi-blue-0: 5,49,112;\n --semi-blue-1: 10,70,148;\n --semi-blue-2: 19,92,184;\n --semi-blue-3: 29,117,219;\n --semi-blue-4: 41,144,255;\n --semi-blue-5: 84,169,255;\n --semi-blue-6: 127,193,255;\n --semi-blue-7: 169,215,255;\n --semi-blue-8: 212,236,255;\n --semi-blue-9: 239,248,255;\n --semi-light-blue-0: 0,55,97;\n --semi-light-blue-1: 0,77,133;\n --semi-light-blue-2: 3,102,169;\n --semi-light-blue-3: 10,129,204;\n --semi-light-blue-4: 19,159,240;\n --semi-light-blue-5: 64,180,243;\n --semi-light-blue-6: 110,200,246;\n --semi-light-blue-7: 157,220,249;\n --semi-light-blue-8: 206,238,252;\n --semi-light-blue-9: 235,248,254;\n --semi-cyan-0: 4,52,61;\n --semi-cyan-1: 7,79,92;\n --semi-cyan-2: 10,108,123;\n --semi-cyan-3: 14,137,153;\n --semi-cyan-4: 19,168,184;\n --semi-cyan-5: 56,187,198;\n --semi-cyan-6: 98,205,212;\n --semi-cyan-7: 145,223,227;\n --semi-cyan-8: 198,239,241;\n --semi-cyan-9: 231,247,248;\n --semi-teal-0: 2,60,57;\n --semi-teal-1: 4,90,85;\n --semi-teal-2: 7,119,111;\n --semi-teal-3: 10,149,136;\n --semi-teal-4: 14,179,161;\n --semi-teal-5: 51,194,176;\n --semi-teal-6: 94,209,193;\n --semi-teal-7: 142,225,211;\n --semi-teal-8: 196,240,232;\n --semi-teal-9: 230,247,244;\n --semi-green-0: 18,60,25;\n --semi-green-1: 28,90,37;\n --semi-green-2: 39,119,49;\n --semi-green-3: 50,149,61;\n --semi-green-4: 62,179,73;\n --semi-green-5: 93,194,100;\n --semi-green-6: 127,209,132;\n --semi-green-7: 166,225,168;\n --semi-green-8: 208,240,209;\n --semi-green-9: 236,247,236;\n --semi-light-green-0: 38,61,19;\n --semi-light-green-1: 59,92,29;\n --semi-light-green-2: 81,123,40;\n --semi-light-green-3: 103,153,52;\n --semi-light-green-4: 127,184,64;\n --semi-light-green-5: 151,198,95;\n --semi-light-green-6: 176,212,129;\n --semi-light-green-7: 201,227,167;\n --semi-light-green-8: 228,241,209;\n --semi-light-green-9: 243,248,237;\n --semi-lime-0: 49,70,3;\n --semi-lime-1: 75,105,5;\n --semi-lime-2: 103,141,9;\n --semi-lime-3: 132,176,12;\n --semi-lime-4: 162,211,17;\n --semi-lime-5: 174,220,58;\n --semi-lime-6: 189,229,102;\n --semi-lime-7: 207,237,150;\n --semi-lime-8: 229,246,201;\n --semi-lime-9: 243,251,233;\n --semi-yellow-0: 84,73,3;\n --semi-yellow-1: 126,108,6;\n --semi-yellow-2: 168,142,10;\n --semi-yellow-3: 210,175,15;\n --semi-yellow-4: 252,206,20;\n --semi-yellow-5: 253,222,67;\n --semi-yellow-6: 253,235,113;\n --semi-yellow-7: 254,245,160;\n --semi-yellow-8: 254,251,208;\n --semi-yellow-9: 255,254,236;\n --semi-amber-0: 81,46,9;\n --semi-amber-1: 121,75,15;\n --semi-amber-2: 161,107,22;\n --semi-amber-3: 202,143,30;\n --semi-amber-4: 242,183,38;\n --semi-amber-5: 245,202,80;\n --semi-amber-6: 247,219,122;\n --semi-amber-7: 250,234,166;\n --semi-amber-8: 252,246,210;\n --semi-amber-9: 254,251,237;\n --semi-orange-0: 85,31,3;\n --semi-orange-1: 128,53,6;\n --semi-orange-2: 170,80,10;\n --semi-orange-3: 213,111,15;\n --semi-orange-4: 255,146,20;\n --semi-orange-5: 255,174,67;\n --semi-orange-6: 255,199,114;\n --semi-orange-7: 255,221,161;\n --semi-orange-8: 255,239,208;\n --semi-orange-9: 255,249,237;\n --semi-grey-0: 28,31,35;\n --semi-grey-1: 46,50,56;\n --semi-grey-2: 65,70,76;\n --semi-grey-3: 85,91,97;\n --semi-grey-4: 107,112,117;\n --semi-grey-5: 136,141,146;\n --semi-grey-6: 167,171,176;\n --semi-grey-7: 198,202,205;\n --semi-grey-8: 230,232,234;\n --semi-grey-9: 249,249,249;\n --semi-white: 255, 255, 255;\n --semi-black: 0, 0, 0;\n}\n\nbody, body[theme-mode=dark] .semi-always-light, :host, :host .semi-always-light {\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n -webkit-font-smoothing: antialiased;\n --semi-color-white: rgba(var(--semi-white), 1);\n --semi-color-black: rgba(var(--semi-black), 1);\n --semi-color-primary: rgba(var(--semi-blue-5), 1);\n --semi-color-primary-hover: rgba(var(--semi-blue-6), 1);\n --semi-color-primary-active: rgba(var(--semi-blue-7), 1);\n --semi-color-primary-disabled: rgba(var(--semi-blue-2), 1);\n --semi-color-primary-light-default: rgba(var(--semi-blue-0), 1);\n --semi-color-primary-light-hover: rgba(var(--semi-blue-1), 1);\n --semi-color-primary-light-active: rgba(var(--semi-blue-2), 1);\n --semi-color-secondary: rgba(var(--semi-light-blue-5), 1);\n --semi-color-secondary-hover: rgba(var(--semi-light-blue-6), 1);\n --semi-color-secondary-active: rgba(var(--semi-light-blue-7), 1);\n --semi-color-secondary-disabled: rgba(var(--semi-light-blue-2), 1);\n --semi-color-secondary-light-default: rgba(var(--semi-light-blue-0), 1);\n --semi-color-secondary-light-hover: rgba(var(--semi-light-blue-1), 1);\n --semi-color-secondary-light-active: rgba(var(--semi-light-blue-2), 1);\n --semi-color-tertiary: rgba(var(--semi-grey-5), 1);\n --semi-color-tertiary-hover: rgba(var(--semi-grey-6), 1);\n --semi-color-tertiary-active: rgba(var(--semi-grey-7), 1);\n --semi-color-tertiary-light-default: rgba(var(--semi-grey-0), 1);\n --semi-color-tertiary-light-hover: rgba(var(--semi-grey-1), 1);\n --semi-color-tertiary-light-active: rgba(var(--semi-grey-2), 1);\n --semi-color-default: rgba(var(--semi-grey-0), 1);\n --semi-color-default-hover: rgba(var(--semi-grey-1), 1);\n --semi-color-default-active: rgba(var(--semi-grey-2), 1);\n --semi-color-info: rgba(var(--semi-blue-5), 1);\n --semi-color-info-hover: rgba(var(--semi-blue-6), 1);\n --semi-color-info-active: rgba(var(--semi-blue-7), 1);\n --semi-color-info-disabled: rgba(var(--semi-blue-2), 1);\n --semi-color-info-light-default: rgba(var(--semi-blue-0), 1);\n --semi-color-info-light-hover: rgba(var(--semi-blue-1), 1);\n --semi-color-info-light-active: rgba(var(--semi-blue-2), 1);\n --semi-color-success: rgba(var(--semi-green-5), 1);\n --semi-color-success-hover: rgba(var(--semi-green-6), 1);\n --semi-color-success-active: rgba(var(--semi-green-7), 1);\n --semi-color-success-disabled: rgba(var(--semi-green-2), 1);\n --semi-color-success-light-default: rgba(var(--semi-green-0), 1);\n --semi-color-success-light-hover: rgba(var(--semi-green-1), 1);\n --semi-color-success-light-active: rgba(var(--semi-green-2), 1);\n --semi-color-danger: rgba(var(--semi-red-5), 1);\n --semi-color-danger-hover: rgba(var(--semi-red-6), 1);\n --semi-color-danger-active: rgba(var(--semi-red-7), 1);\n --semi-color-danger-light-default: rgba(var(--semi-red-0), 1);\n --semi-color-danger-light-hover: rgba(var(--semi-red-1), 1);\n --semi-color-danger-light-active: rgba(var(--semi-red-2), 1);\n --semi-color-warning: rgba(var(--semi-orange-5), 1);\n --semi-color-warning-hover: rgba(var(--semi-orange-6), 1);\n --semi-color-warning-active: rgba(var(--semi-orange-7), 1);\n --semi-color-warning-light-default: rgba(var(--semi-orange-0), 1);\n --semi-color-warning-light-hover: rgba(var(--semi-orange-1), 1);\n --semi-color-warning-light-active: rgba(var(--semi-orange-2), 1);\n --semi-color-focus-border: rgba(var(--semi-blue-5), 1);\n --semi-color-disabled-text: rgba(var(--semi-grey-9), .35);\n --semi-color-disabled-border: rgba(var(--semi-grey-1), 1);\n --semi-color-disabled-bg: rgba(var(--semi-grey-1), 1);\n --semi-color-disabled-fill: rgba(var(--semi-grey-8), .04);\n --semi-color-shadow: rgba(var(--semi-black), .04);\n --semi-color-link: rgba(var(--semi-blue-5), 1);\n --semi-color-link-hover: rgba(var(--semi-blue-6), 1);\n --semi-color-link-active: rgba(var(--semi-blue-7), 1);\n --semi-color-link-visited: rgba(var(--semi-blue-5), 1);\n --semi-color-border: rgba(var(--semi-grey-9), .08);\n --semi-color-nav-bg: rgba(var(--semi-white), 1);\n --semi-color-overlay-bg: rgba(22, 22, 26, .6);\n --semi-color-fill-0: rgba(var(--semi-grey-8), .05);\n --semi-color-fill-1: rgba(var(--semi-grey-8), .09);\n --semi-color-fill-2: rgba(var(--semi-grey-8), .13);\n --semi-color-bg-0: rgba(var(--semi-white), 1);\n --semi-color-bg-1: rgba(var(--semi-white), 1);\n --semi-color-bg-2: rgba(var(--semi-white), 1);\n --semi-color-bg-3: rgba(var(--semi-white), 1);\n --semi-color-bg-4: rgba(var(--semi-white), 1);\n --semi-color-text-0: rgba(var(--semi-grey-9), 1);\n --semi-color-text-1: rgba(var(--semi-grey-9), .8);\n --semi-color-text-2: rgba(var(--semi-grey-9), .62);\n --semi-color-text-3: rgba(var(--semi-grey-9), .35);\n --semi-shadow-elevated: 0 0 1px rgba(0, 0, 0, .3), 0 4px 14px rgba(0, 0, 0, .1);\n --semi-border-radius-extra-small: 3px;\n --semi-border-radius-small: 3px;\n --semi-border-radius-medium: 6px;\n --semi-border-radius-large: 12px;\n --semi-border-radius-circle: 50%;\n --semi-border-radius-full: 9999px;\n --semi-color-highlight-bg: rgba(var(--semi-yellow-4), 1);\n --semi-color-highlight: rgba(var(--semi-black), 1);\n --semi-color-data-0: rgba(87, 105, 255, 1);\n --semi-color-data-1: rgba(142, 212, 231, 1);\n --semi-color-data-2: rgba(245, 135, 0, 1);\n --semi-color-data-3: rgba(220, 183, 252, 1);\n --semi-color-data-4: rgba(74, 156, 247, 1);\n --semi-color-data-5: rgba(243, 204, 53, 1);\n --semi-color-data-6: rgba(254, 128, 144, 1);\n --semi-color-data-7: rgba(139, 215, 210, 1);\n --semi-color-data-8: rgba(131, 176, 35, 1);\n --semi-color-data-9: rgba(233, 165, 229, 1);\n --semi-color-data-10: rgba(48, 167, 206, 1);\n --semi-color-data-11: rgba(249, 192, 100, 1);\n --semi-color-data-12: rgba(177, 113, 249, 1);\n --semi-color-data-13: rgba(119, 182, 249, 1);\n --semi-color-data-14: rgba(200, 143, 2, 1);\n --semi-color-data-15: rgba(255, 170, 178, 1);\n --semi-color-data-16: rgba(51, 176, 171, 1);\n --semi-color-data-17: rgba(182, 215, 129, 1);\n --semi-color-data-18: rgba(212, 88, 212, 1);\n --semi-color-data-19: rgba(188, 198, 255, 1);\n}\n\nbody[theme-mode=dark], body .semi-always-dark, :host([theme-mode=dark]), :host .semi-always-dark {\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n -webkit-font-smoothing: antialiased;\n --semi-color-white: rgba(228, 231, 245, 1);\n --semi-color-black: rgba(var(--semi-black), 1);\n --semi-color-primary: rgba(var(--semi-blue-5), 1);\n --semi-color-primary-hover: rgba(var(--semi-blue-6), 1);\n --semi-color-primary-active: rgba(var(--semi-blue-7), 1);\n --semi-color-primary-disabled: rgba(var(--semi-blue-2), 1);\n --semi-color-primary-light-default: rgba(var(--semi-blue-5), .2);\n --semi-color-primary-light-hover: rgba(var(--semi-blue-5), .3);\n --semi-color-primary-light-active: rgba(var(--semi-blue-5), .4);\n --semi-color-secondary: rgba(var(--semi-light-blue-5), 1);\n --semi-color-secondary-hover: rgba(var(--semi-light-blue-6), 1);\n --semi-color-secondary-active: rgba(var(--semi-light-blue-7), 1);\n --semi-color-secondary-disabled: rgba(var(--semi-light-blue-2), 1);\n --semi-color-secondary-light-default: rgba(var(--semi-light-blue-5), .2);\n --semi-color-secondary-light-hover: rgba(var(--semi-light-blue-5), .3);\n --semi-color-secondary-light-active: rgba(var(--semi-light-blue-5), .4);\n --semi-color-tertiary: rgba(var(--semi-grey-5), 1);\n --semi-color-tertiary-hover: rgba(var(--semi-grey-6), 1);\n --semi-color-tertiary-active: rgba(var(--semi-grey-7), 1);\n --semi-color-tertiary-light-default: rgba(var(--semi-grey-5), .2);\n --semi-color-tertiary-light-hover: rgba(var(--semi-grey-5), .3);\n --semi-color-tertiary-light-active: rgba(var(--semi-grey-5), .4);\n --semi-color-default: rgba(var(--semi-grey-0), 1);\n --semi-color-default-hover: rgba(var(--semi-grey-1), 1);\n --semi-color-default-active: rgba(var(--semi-grey-2), 1);\n --semi-color-info: rgba(var(--semi-blue-5), 1);\n --semi-color-info-hover: rgba(var(--semi-blue-6), 1);\n --semi-color-info-active: rgba(var(--semi-blue-7), 1);\n --semi-color-info-disabled: rgba(var(--semi-blue-2), 1);\n --semi-color-info-light-default: rgba(var(--semi-blue-5), .2);\n --semi-color-info-light-hover: rgba(var(--semi-blue-5), .3);\n --semi-color-info-light-active: rgba(var(--semi-blue-5), .4);\n --semi-color-success: rgba(var(--semi-green-5), 1);\n --semi-color-success-hover: rgba(var(--semi-green-6), 1);\n --semi-color-success-active: rgba(var(--semi-green-7), 1);\n --semi-color-success-disabled: rgba(var(--semi-green-2), 1);\n --semi-color-success-light-default: rgba(var(--semi-green-5), .2);\n --semi-color-success-light-hover: rgba(var(--semi-green-5), .3);\n --semi-color-success-light-active: rgba(var(--semi-green-5), .4);\n --semi-color-danger: rgba(var(--semi-red-5), 1);\n --semi-color-danger-hover: rgba(var(--semi-red-6), 1);\n --semi-color-danger-active: rgba(var(--semi-red-7), 1);\n --semi-color-danger-light-default: rgba(var(--semi-red-5), .2);\n --semi-color-danger-light-hover: rgba(var(--semi-red-5), .3);\n --semi-color-danger-light-active: rgba(var(--semi-red-5), .4);\n --semi-color-warning: rgba(var(--semi-orange-5), 1);\n --semi-color-warning-hover: rgba(var(--semi-orange-6), 1);\n --semi-color-warning-active: rgba(var(--semi-orange-7), 1);\n --semi-color-warning-light-default: rgba(var(--semi-orange-5), .2);\n --semi-color-warning-light-hover: rgba(var(--semi-orange-5), .3);\n --semi-color-warning-light-active: rgba(var(--semi-orange-5), .4);\n --semi-color-focus-border: rgba(var(--semi-blue-5), 1);\n --semi-color-disabled-text: rgba(var(--semi-grey-9), .35);\n --semi-color-disabled-border: rgba(var(--semi-grey-1), 1);\n --semi-color-disabled-bg: rgba(var(--semi-grey-1), 1);\n --semi-color-disabled-fill: rgba(var(--semi-grey-8), .04);\n --semi-color-link: rgba(var(--semi-blue-5), 1);\n --semi-color-link-hover: rgba(var(--semi-blue-6), 1);\n --semi-color-link-active: rgba(var(--semi-blue-7), 1);\n --semi-color-link-visited: rgba(var(--semi-blue-5), 1);\n --semi-color-nav-bg: rgba(35, 36, 41, 1);\n --semi-shadow-elevated: inset 0 0 0 1px rgba(255, 255, 255, .1), 0 4px 14px rgba(0, 0, 0, .25);\n --semi-color-overlay-bg: rgba(22, 22, 26, .6);\n --semi-color-fill-0: rgba(var(--semi-white), .12);\n --semi-color-fill-1: rgba(var(--semi-white), .16);\n --semi-color-fill-2: rgba(var(--semi-white), .20);\n --semi-color-border: rgba(var(--semi-white), .08);\n --semi-color-shadow: rgba(var(--semi-black), .04);\n --semi-color-bg-0: rgba(22, 22, 26, 1);\n --semi-color-bg-1: rgba(35, 36, 41, 1);\n --semi-color-bg-2: rgba(53, 54, 60, 1);\n --semi-color-bg-3: rgba(67, 68, 74, 1);\n --semi-color-bg-4: rgba(79, 81, 89, 1);\n --semi-color-text-0: rgba(var(--semi-grey-9), 1);\n --semi-color-text-1: rgba(var(--semi-grey-9), .8);\n --semi-color-text-2: rgba(var(--semi-grey-9), .6);\n --semi-color-text-3: rgba(var(--semi-grey-9), .35);\n --semi-border-radius-extra-small: 3px;\n --semi-border-radius-small: 3px;\n --semi-border-radius-medium: 6px;\n --semi-border-radius-large: 12px;\n --semi-border-radius-circle: 50%;\n --semi-border-radius-full: 9999px;\n --semi-color-highlight-bg: rgba(var(--semi-yellow-2), 1);\n --semi-color-highlight: rgba(var(--semi-white), 1);\n --semi-color-data-0: rgba(94, 109, 194, 1);\n --semi-color-data-1: rgba(8, 104, 120, 1);\n --semi-color-data-2: rgba(250, 173, 63, 1);\n --semi-color-data-3: rgba(76, 43, 156, 1);\n --semi-color-data-4: rgba(16, 125, 248, 1);\n --semi-color-data-5: rgba(248, 202, 16, 1);\n --semi-color-data-6: rgba(195, 30, 87, 1);\n --semi-color-data-7: rgba(5, 119, 115, 1);\n --semi-color-data-8: rgba(154, 207, 13, 1);\n --semi-color-data-9: rgba(117, 29, 138, 1);\n --semi-color-data-10: rgba(16, 162, 180, 1);\n --semi-color-data-11: rgba(208, 110, 11, 1);\n --semi-color-data-12: rgba(113, 66, 197, 1);\n --semi-color-data-13: rgba(7, 100, 212, 1);\n --semi-color-data-14: rgba(251, 232, 110, 1);\n --semi-color-data-15: rgba(160, 19, 73, 1);\n --semi-color-data-16: rgba(11, 179, 167, 1);\n --semi-color-data-17: rgba(98, 138, 6, 1);\n --semi-color-data-18: rgba(162, 48, 179, 1);\n --semi-color-data-19: rgba(40, 51, 138, 1);\n}\n\n.semi-light-scrollbar::-webkit-scrollbar, .semi-light-scrollbar *::-webkit-scrollbar {\n width: 8px;\n height: 8px;\n}\n.semi-light-scrollbar::-webkit-scrollbar-track, .semi-light-scrollbar *::-webkit-scrollbar-track {\n background: rgba(0, 0, 0, 0);\n}\n.semi-light-scrollbar::-webkit-scrollbar-corner, .semi-light-scrollbar *::-webkit-scrollbar-corner {\n background-color: rgba(0, 0, 0, 0);\n}\n.semi-light-scrollbar::-webkit-scrollbar-thumb, .semi-light-scrollbar *::-webkit-scrollbar-thumb {\n border-radius: 6px;\n background: transparent;\n transition: all 1s;\n}\n.semi-light-scrollbar:hover::-webkit-scrollbar-thumb, .semi-light-scrollbar *:hover::-webkit-scrollbar-thumb {\n background: var(--semi-color-fill-2);\n}\n.semi-light-scrollbar::-webkit-scrollbar-thumb:hover, .semi-light-scrollbar *::-webkit-scrollbar-thumb:hover {\n background: var(--semi-color-fill-1);\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-backtop {\n position: fixed;\n box-sizing: border-box;\n right: 100px;\n bottom: 50px;\n z-index: 10;\n cursor: pointer;\n text-align: center;\n overflow: hidden;\n}\n\n.semi-rtl .semi-backtop,\n.semi-portal-rtl .semi-backtop {\n direction: rtl;\n right: auto;\n left: 100px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-button.semi-button-with-icon {\n display: inline-flex;\n align-items: center;\n}\n.semi-button.semi-button-with-icon .semi-button-content {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n.semi-button.semi-button-loading {\n pointer-events: none;\n cursor: not-allowed;\n}\n.semi-button.semi-button-loading .semi-button-content > svg {\n width: 16px;\n height: 16px;\n animation: 0.6s linear infinite semi-animation-rotate;\n animation-fill-mode: forwards;\n}\n.semi-button.semi-button-with-icon-only {\n padding-left: 8px;\n padding-right: 8px;\n padding-top: 8px;\n padding-bottom: 8px;\n justify-content: center;\n align-items: center;\n}\n.semi-button.semi-button-with-icon-only.semi-button-size-small {\n padding-left: 4px;\n padding-right: 4px;\n padding-top: 4px;\n padding-bottom: 4px;\n}\n.semi-button.semi-button-with-icon-only.semi-button-size-large {\n padding-left: 12px;\n padding-right: 12px;\n padding-top: 12px;\n padding-bottom: 12px;\n}\n.semi-button-content-left {\n margin-right: 8px;\n}\n.semi-button-content-right {\n margin-left: 8px;\n}\n\n.semi-rtl .semi-button,\n.semi-portal-rtl .semi-button {\n direction: rtl;\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-size-small,\n.semi-portal-rtl .semi-button-size-small {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-size-large,\n.semi-portal-rtl .semi-button-size-large {\n padding-left: 16px;\n padding-right: 16px;\n}\n.semi-rtl .semi-button-group,\n.semi-portal-rtl .semi-button-group {\n direction: rtl;\n}\n.semi-rtl .semi-button-group > .semi-button,\n.semi-portal-rtl .semi-button-group > .semi-button {\n padding-left: 0;\n padding-right: 0;\n}\n.semi-rtl .semi-button-group > .semi-button .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button .semi-button-content {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-group > .semi-button-size-large .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button-size-large .semi-button-content {\n padding-left: 16px;\n padding-right: 16px;\n}\n.semi-rtl .semi-button-group > .semi-button-size-small .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button-size-small .semi-button-content {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-group > .semi-button.semi-button-with-icon-only,\n.semi-portal-rtl .semi-button-group > .semi-button.semi-button-with-icon-only {\n padding-left: 0;\n padding-right: 0;\n}\n.semi-rtl .semi-button-group > .semi-button.semi-button-with-icon-only .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button.semi-button-with-icon-only .semi-button-content {\n padding-left: 8px;\n padding-right: 8px;\n}\n.semi-rtl .semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-small .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-small .semi-button-content {\n padding-left: 4px;\n padding-right: 4px;\n}\n.semi-rtl .semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-large .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-large .semi-button-content {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-group > .semi-button:first-child,\n.semi-portal-rtl .semi-button-group > .semi-button:first-child {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n border-top-right-radius: var(--semi-border-radius-small);\n border-bottom-right-radius: var(--semi-border-radius-small);\n}\n.semi-rtl .semi-button-group > .semi-button:not(:last-child) .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button:not(:last-child) .semi-button-content {\n border-left: 1px var(--semi-color-border) solid;\n border-right: 0;\n}\n.semi-rtl .semi-button-group > .semi-button:last-child,\n.semi-portal-rtl .semi-button-group > .semi-button:last-child {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-top-left-radius: var(--semi-border-radius-small);\n border-bottom-left-radius: var(--semi-border-radius-small);\n}\n.semi-rtl .semi-button.semi-button-with-icon-only,\n.semi-portal-rtl .semi-button.semi-button-with-icon-only {\n padding-left: 8px;\n padding-right: 8px;\n}\n.semi-rtl .semi-button.semi-button-with-icon-only.semi-button-size-small,\n.semi-portal-rtl .semi-button.semi-button-with-icon-only.semi-button-size-small {\n padding-left: 4px;\n padding-right: 4px;\n}\n.semi-rtl .semi-button.semi-button-with-icon-only.semi-button-size-large,\n.semi-portal-rtl .semi-button.semi-button-with-icon-only.semi-button-size-large {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-content-left,\n.semi-portal-rtl .semi-button-content-left {\n margin-left: 8px;\n margin-right: 0;\n}\n.semi-rtl .semi-button-content-right,\n.semi-portal-rtl .semi-button-content-right {\n margin-right: 8px;\n margin-left: 0;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-button-split {\n display: inline-block;\n}\n.semi-button-split .semi-button {\n border-radius: 0;\n margin-right: 1px;\n}\n.semi-button-split .semi-button-first {\n border-top-left-radius: var(--semi-border-radius-small);\n border-bottom-left-radius: var(--semi-border-radius-small);\n}\n.semi-button-split .semi-button-last {\n border-top-right-radius: var(--semi-border-radius-small);\n border-bottom-right-radius: var(--semi-border-radius-small);\n margin-right: unset;\n}\n.semi-button-split:hover .semi-button-borderless:active {\n background-color: var(--semi-color-fill-1);\n}\n\n.semi-button {\n box-shadow: none;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n height: 32px;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n cursor: pointer;\n user-select: none;\n border: 0 transparent solid;\n border-radius: var(--semi-border-radius-small);\n padding-left: 12px;\n padding-right: 12px;\n padding-top: 6px;\n padding-bottom: 6px;\n font-weight: 600;\n outline: none;\n vertical-align: middle;\n white-space: nowrap;\n}\n.semi-button.semi-button-primary:focus-visible, .semi-button.semi-button-secondary:focus-visible, .semi-button.semi-button-tertiary:focus-visible, .semi-button.semi-button-warning:focus-visible, .semi-button.semi-button-danger:focus-visible {\n outline: 2px solid var(--semi-color-primary-light-active);\n}\n.semi-button-danger {\n background-color: var(--semi-color-danger);\n color: rgba(var(--semi-white), 1);\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-button-danger-disabled {\n background-color: var(--semi-color-disabled-bg);\n}\n.semi-button-danger-disabled.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-border);\n}\n.semi-button-danger-disabled.semi-button-light {\n background-color: var(--semi-color-fill-0);\n}\n.semi-button-danger:hover {\n background-color: var(--semi-color-danger-hover);\n}\n.semi-button-danger:active {\n background-color: var(--semi-color-danger-active);\n}\n.semi-button-danger.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-danger);\n}\n.semi-button-danger.semi-button-light, .semi-button-danger.semi-button-outline, .semi-button-danger.semi-button-borderless {\n color: var(--semi-color-danger);\n}\n.semi-button-danger:not(.semi-button-borderless):not(.semi-button-light):focus-visible {\n outline: 2px solid var(--semi-color-danger-light-active);\n}\n.semi-button-warning {\n background-color: var(--semi-color-warning);\n color: rgba(var(--semi-white), 1);\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-button-warning-disabled {\n background-color: var(--semi-color-disabled-bg);\n}\n.semi-button-warning-disabled.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-border);\n}\n.semi-button-warning-disabled.semi-button-light {\n background-color: var(--semi-color-fill-0);\n}\n.semi-button-warning:hover {\n background-color: var(--semi-color-warning-hover);\n}\n.semi-button-warning:active {\n background-color: var(--semi-color-warning-active);\n}\n.semi-button-warning.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-warning);\n}\n.semi-button-warning.semi-button-light, .semi-button-warning.semi-button-outline, .semi-button-warning.semi-button-borderless {\n color: var(--semi-color-warning);\n}\n.semi-button-warning:not(.semi-button-borderless):not(.semi-button-light):focus-visible {\n outline: 2px solid var(--semi-color-warning-light-active);\n}\n.semi-button-tertiary {\n background-color: var(--semi-color-tertiary);\n color: rgba(var(--semi-white), 1);\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-button-tertiary-disabled {\n background-color: var(--semi-color-disabled-bg);\n}\n.semi-button-tertiary-disabled.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-border);\n}\n.semi-button-tertiary-disabled.semi-button-light {\n background-color: var(--semi-color-fill-0);\n}\n.semi-button-tertiary:hover {\n background-color: var(--semi-color-tertiary-hover);\n}\n.semi-button-tertiary:active {\n background-color: var(--semi-color-tertiary-active);\n}\n.semi-button-tertiary.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-border);\n}\n.semi-button-tertiary.semi-button-light, .semi-button-tertiary.semi-button-outline, .semi-button-tertiary.semi-button-borderless {\n color: var(--semi-color-text-1);\n}\n.semi-button-primary {\n background-color: var(--semi-color-primary);\n color: rgba(var(--semi-white), 1);\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-button-primary-disabled {\n background-color: var(--semi-color-disabled-bg);\n}\n.semi-button-primary-disabled.semi-button-light {\n background: var(--semi-color-fill-0);\n}\n.semi-button-primary-disabled.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-border);\n}\n.semi-button-primary:not(.semi-button-borderless):not(.semi-button-light):not(.semi-button-outline):hover {\n background-color: var(--semi-color-primary-hover);\n}\n.semi-button-primary.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-border);\n}\n.semi-button-primary:not(.semi-button-borderless):not(.semi-button-light):not(.semi-button-outline):active {\n background-color: var(--semi-color-primary-active);\n}\n.semi-button-primary.semi-button-light, .semi-button-primary.semi-button-outline, .semi-button-primary.semi-button-borderless {\n color: var(--semi-color-primary);\n}\n.semi-button-secondary {\n background-color: var(--semi-color-secondary);\n outline-color: var(--semi-color-secondary);\n color: rgba(var(--semi-white), 1);\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-button-secondary-disabled {\n background-color: var(--semi-color-disabled-bg);\n}\n.semi-button-secondary-disabled.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-border);\n}\n.semi-button-secondary-disabled.semi-button-light {\n background-color: var(--semi-color-fill-0);\n}\n.semi-button-secondary.semi-button-outline {\n background-color: transparent;\n border: 1px solid var(--semi-color-border);\n}\n.semi-button-secondary:hover {\n background-color: var(--semi-color-secondary-hover);\n}\n.semi-button-secondary:active {\n background-color: var(--semi-color-secondary-active);\n}\n.semi-button-secondary.semi-button-light, .semi-button-secondary.semi-button-outline, .semi-button-secondary.semi-button-borderless {\n color: var(--semi-color-secondary);\n}\n.semi-button-disabled {\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-button-disabled:not(.semi-button-borderless):not(.semi-button-light):hover {\n color: var(--semi-color-disabled-text);\n}\n.semi-button-disabled.semi-button-light, .semi-button-disabled.semi-button-borderless {\n color: var(--semi-color-disabled-text);\n}\n.semi-button-borderless {\n background-color: transparent;\n border: 0 transparent solid;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-button-borderless:not(.semi-button-disabled):hover {\n background-color: var(--semi-color-fill-0);\n border: 0 transparent solid;\n}\n.semi-button-borderless:not(.semi-button-disabled):active {\n background-color: var(--semi-color-fill-1);\n border: 0 transparent solid;\n}\n.semi-button-outline {\n background-color: transparent;\n}\n.semi-button-outline:not(.semi-button-disabled):hover {\n background-color: var(--semi-color-fill-0);\n}\n.semi-button-outline:not(.semi-button-disabled):active {\n background-color: var(--semi-color-fill-1);\n}\n.semi-button-light {\n background-color: var(--semi-color-fill-0);\n border: 0 transparent solid;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-button-light:not(.semi-button-disabled):hover {\n background-color: var(--semi-color-fill-1);\n border: 0 transparent solid;\n}\n.semi-button-light:not(.semi-button-disabled):active {\n background-color: var(--semi-color-fill-2);\n border: 0 transparent solid;\n}\n.semi-button-size-small {\n height: 24px;\n padding-top: 2px;\n padding-bottom: 2px;\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-button-size-large {\n height: 40px;\n padding-top: 10px;\n padding-bottom: 10px;\n padding-left: 16px;\n padding-right: 16px;\n}\n.semi-button-block {\n width: 100%;\n}\n.semi-button-group {\n display: flex;\n flex-wrap: wrap;\n}\n.semi-button-group > .semi-button {\n margin: 0;\n padding-left: 0;\n padding-right: 0;\n border-radius: 0;\n}\n.semi-button-group > .semi-button .semi-button-content {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-button-group > .semi-button-size-large .semi-button-content {\n padding-left: 16px;\n padding-right: 16px;\n}\n.semi-button-group > .semi-button-size-small .semi-button-content {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-button-group > .semi-button.semi-button-with-icon-only {\n padding-left: 0;\n padding-right: 0;\n}\n.semi-button-group > .semi-button.semi-button-with-icon-only .semi-button-content {\n padding-left: 8px;\n padding-right: 8px;\n}\n.semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-small .semi-button-content {\n padding-left: 4px;\n padding-right: 4px;\n}\n.semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-large .semi-button-content {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-button-group > .semi-button:first-child {\n border-top-left-radius: var(--semi-border-radius-small);\n border-bottom-left-radius: var(--semi-border-radius-small);\n}\n.semi-button-group > .semi-button:last-child {\n border-top-right-radius: var(--semi-border-radius-small);\n border-bottom-right-radius: var(--semi-border-radius-small);\n}\n.semi-button-group > .semi-button-outline:not(:last-child) {\n border-right-color: transparent;\n margin-right: -1px;\n}\n.semi-button-group-line {\n display: inline-flex;\n align-items: center;\n background-color: var(--semi-color-border);\n}\n.semi-button-group-line-primary {\n background-color: var(--semi-color-primary);\n}\n.semi-button-group-line-secondary {\n background-color: var(--semi-color-secondary);\n}\n.semi-button-group-line-tertiary {\n background-color: var(--semi-color-tertiary);\n}\n.semi-button-group-line-warning {\n background-color: var(--semi-color-warning);\n}\n.semi-button-group-line-danger {\n background-color: var(--semi-color-danger);\n}\n.semi-button-group-line-disabled {\n background-color: var(--semi-color-disabled-bg);\n}\n.semi-button-group-line-light {\n background-color: var(--semi-color-fill-0);\n}\n.semi-button-group-line-borderless {\n background-color: transparent;\n}\n.semi-button-group-line::before {\n display: block;\n content: \"\";\n width: 1px;\n height: 20px;\n background-color: var(--semi-color-border);\n}\n\n.semi-rtl .semi-button,\n.semi-portal-rtl .semi-button {\n direction: rtl;\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-size-small,\n.semi-portal-rtl .semi-button-size-small {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-size-large,\n.semi-portal-rtl .semi-button-size-large {\n padding-left: 16px;\n padding-right: 16px;\n}\n.semi-rtl .semi-button-group,\n.semi-portal-rtl .semi-button-group {\n direction: rtl;\n}\n.semi-rtl .semi-button-group > .semi-button,\n.semi-portal-rtl .semi-button-group > .semi-button {\n padding-left: 0;\n padding-right: 0;\n}\n.semi-rtl .semi-button-group > .semi-button .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button .semi-button-content {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-group > .semi-button-size-large .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button-size-large .semi-button-content {\n padding-left: 16px;\n padding-right: 16px;\n}\n.semi-rtl .semi-button-group > .semi-button-size-small .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button-size-small .semi-button-content {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-group > .semi-button.semi-button-with-icon-only,\n.semi-portal-rtl .semi-button-group > .semi-button.semi-button-with-icon-only {\n padding-left: 0;\n padding-right: 0;\n}\n.semi-rtl .semi-button-group > .semi-button.semi-button-with-icon-only .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button.semi-button-with-icon-only .semi-button-content {\n padding-left: 8px;\n padding-right: 8px;\n}\n.semi-rtl .semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-small .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-small .semi-button-content {\n padding-left: 4px;\n padding-right: 4px;\n}\n.semi-rtl .semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-large .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button.semi-button-with-icon-only.semi-button-size-large .semi-button-content {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-group > .semi-button:first-child,\n.semi-portal-rtl .semi-button-group > .semi-button:first-child {\n border-top-left-radius: 0;\n border-bottom-left-radius: 0;\n border-top-right-radius: var(--semi-border-radius-small);\n border-bottom-right-radius: var(--semi-border-radius-small);\n}\n.semi-rtl .semi-button-group > .semi-button:not(:last-child) .semi-button-content,\n.semi-portal-rtl .semi-button-group > .semi-button:not(:last-child) .semi-button-content {\n border-left: 1px var(--semi-color-border) solid;\n border-right: 0;\n}\n.semi-rtl .semi-button-group > .semi-button:last-child,\n.semi-portal-rtl .semi-button-group > .semi-button:last-child {\n border-top-right-radius: 0;\n border-bottom-right-radius: 0;\n border-top-left-radius: var(--semi-border-radius-small);\n border-bottom-left-radius: var(--semi-border-radius-small);\n}\n.semi-rtl .semi-button.semi-button-with-icon-only,\n.semi-portal-rtl .semi-button.semi-button-with-icon-only {\n padding-left: 8px;\n padding-right: 8px;\n}\n.semi-rtl .semi-button.semi-button-with-icon-only.semi-button-size-small,\n.semi-portal-rtl .semi-button.semi-button-with-icon-only.semi-button-size-small {\n padding-left: 4px;\n padding-right: 4px;\n}\n.semi-rtl .semi-button.semi-button-with-icon-only.semi-button-size-large,\n.semi-portal-rtl .semi-button.semi-button-with-icon-only.semi-button-size-large {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-button-content-left,\n.semi-portal-rtl .semi-button-content-left {\n margin-left: 8px;\n margin-right: 0;\n}\n.semi-rtl .semi-button-content-right,\n.semi-portal-rtl .semi-button-content-right {\n margin-right: 8px;\n margin-left: 0;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-icon {\n display: inline-block;\n font-style: normal;\n line-height: 0;\n text-align: center;\n text-transform: none;\n text-rendering: optimizeLegibility;\n fill: currentColor;\n}\n\n.semi-icon-extra-small {\n font-size: 8px;\n}\n\n.semi-icon-small {\n font-size: 12px;\n}\n\n.semi-icon-default {\n font-size: 16px;\n}\n\n.semi-icon-large {\n font-size: 20px;\n}\n\n.semi-icon-extra-large {\n font-size: 24px;\n}\n\n.semi-icon-spinning {\n animation: 0.6s linear infinite semi-icon-animation-rotate;\n animation-fill-mode: forwards;\n}\n\n@keyframes semi-icon-animation-rotate {\n from {\n transform: rotate(0);\n }\n to {\n transform: rotate(360deg);\n }\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-descriptions {\n line-height: 20px;\n}\n.semi-descriptions table,\n.semi-descriptions tr,\n.semi-descriptions th,\n.semi-descriptions td {\n margin: 0;\n padding: 0;\n border: 0;\n}\n.semi-descriptions th {\n padding-right: 24px;\n}\n.semi-descriptions .semi-descriptions-item {\n margin: 0;\n padding-bottom: 12px;\n text-align: left;\n vertical-align: top;\n}\n.semi-descriptions-key {\n font-weight: normal;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n min-height: 14px;\n white-space: nowrap;\n color: var(--semi-color-text-2);\n}\n.semi-descriptions-value {\n font-weight: normal;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n color: var(--semi-color-text-0);\n}\n.semi-descriptions-center .semi-descriptions-item-th {\n text-align: right;\n}\n.semi-descriptions-center .semi-descriptions-item-td {\n text-align: left;\n}\n.semi-descriptions-left .semi-descriptions-item-th,\n.semi-descriptions-left .semi-descriptions-item-td {\n text-align: left;\n}\n.semi-descriptions-justify .semi-descriptions-item-th {\n text-align: left;\n}\n.semi-descriptions-justify .semi-descriptions-item-td {\n text-align: right;\n}\n.semi-descriptions-plain .semi-descriptions-key,\n.semi-descriptions-plain .semi-descriptions-value {\n display: inline-block;\n}\n.semi-descriptions-plain .semi-descriptions-value {\n padding-left: 8px;\n}\n.semi-descriptions-plain .semi-descriptions-value .semi-tag {\n vertical-align: middle;\n}\n.semi-descriptions-double tbody {\n display: flex;\n flex-wrap: wrap;\n}\n.semi-descriptions-double tr {\n display: inline-flex;\n flex-direction: column;\n}\n.semi-descriptions-double .semi-descriptions-item {\n padding: 0;\n flex: 1;\n}\n.semi-descriptions-double .semi-descriptions-value {\n font-weight: 600;\n}\n.semi-descriptions-double-small .semi-descriptions-item {\n padding-right: 48px;\n}\n.semi-descriptions-double-small .semi-descriptions-key {\n font-size: 12px;\n line-height: 16px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n padding-bottom: 0;\n font-size: 12px;\n}\n.semi-descriptions-double-small .semi-descriptions-value {\n font-size: 16px;\n line-height: 22px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 16px;\n}\n.semi-descriptions-double-medium .semi-descriptions-item {\n padding-right: 60px;\n}\n.semi-descriptions-double-medium .semi-descriptions-key {\n padding-bottom: 4px;\n font-size: 14px;\n}\n.semi-descriptions-double-medium .semi-descriptions-value {\n font-size: 20px;\n line-height: 28px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 20px;\n}\n.semi-descriptions-double-large .semi-descriptions-item {\n padding-right: 80px;\n}\n.semi-descriptions-double-large .semi-descriptions-key {\n padding-bottom: 4px;\n font-size: 14px;\n}\n.semi-descriptions-double-large .semi-descriptions-value {\n font-size: 28px;\n line-height: 40px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-size: 28px;\n}\n.semi-descriptions-horizontal table {\n table-layout: fixed;\n}\n.semi-descriptions-horizontal table, .semi-descriptions-horizontal tbody {\n width: 100%;\n}\n.semi-descriptions-horizontal .semi-descriptions-item {\n flex: 0;\n}\n\n.semi-rtl .semi-descriptions,\n.semi-portal-rtl .semi-descriptions {\n direction: rtl;\n}\n.semi-rtl .semi-descriptions th,\n.semi-portal-rtl .semi-descriptions th {\n direction: rtl;\n padding-right: 0;\n padding-left: 24px;\n}\n.semi-rtl .semi-descriptions .semi-descriptions-item,\n.semi-portal-rtl .semi-descriptions .semi-descriptions-item {\n text-align: right;\n}\n.semi-rtl .semi-descriptions-center .semi-descriptions-item-th,\n.semi-portal-rtl .semi-descriptions-center .semi-descriptions-item-th {\n text-align: left;\n}\n.semi-rtl .semi-descriptions-center .semi-descriptions-item-td,\n.semi-portal-rtl .semi-descriptions-center .semi-descriptions-item-td {\n text-align: right;\n}\n.semi-rtl .semi-descriptions-left .semi-descriptions-item-th,\n.semi-rtl .semi-descriptions-left .semi-descriptions-item-td,\n.semi-portal-rtl .semi-descriptions-left .semi-descriptions-item-th,\n.semi-portal-rtl .semi-descriptions-left .semi-descriptions-item-td {\n text-align: left;\n}\n.semi-rtl .semi-descriptions-justify .semi-descriptions-item-th,\n.semi-portal-rtl .semi-descriptions-justify .semi-descriptions-item-th {\n text-align: right;\n}\n.semi-rtl .semi-descriptions-justify .semi-descriptions-item-td,\n.semi-portal-rtl .semi-descriptions-justify .semi-descriptions-item-td {\n text-align: left;\n}\n.semi-rtl .semi-descriptions-plain .semi-descriptions-key,\n.semi-rtl .semi-descriptions-plain .semi-descriptions-value,\n.semi-portal-rtl .semi-descriptions-plain .semi-descriptions-key,\n.semi-portal-rtl .semi-descriptions-plain .semi-descriptions-value {\n display: inline-block;\n}\n.semi-rtl .semi-descriptions-plain .semi-descriptions-value,\n.semi-portal-rtl .semi-descriptions-plain .semi-descriptions-value {\n padding-left: 0;\n padding-right: 8px;\n}\n.semi-rtl .semi-descriptions-plain .semi-descriptions-value .semi-tag,\n.semi-portal-rtl .semi-descriptions-plain .semi-descriptions-value .semi-tag {\n vertical-align: middle;\n}\n.semi-rtl .semi-descriptions-double,\n.semi-portal-rtl .semi-descriptions-double {\n direction: rtl;\n}\n.semi-rtl .semi-descriptions-double .semi-descriptions-item,\n.semi-portal-rtl .semi-descriptions-double .semi-descriptions-item {\n text-align: right;\n}\n.semi-rtl .semi-descriptions-double-small .semi-descriptions-item,\n.semi-portal-rtl .semi-descriptions-double-small .semi-descriptions-item {\n padding-right: 0;\n padding-left: 48px;\n}\n.semi-rtl .semi-descriptions-double-medium .semi-descriptions-item,\n.semi-portal-rtl .semi-descriptions-double-medium .semi-descriptions-item {\n padding-right: 0;\n padding-left: 60px;\n}\n.semi-rtl .semi-descriptions-double-large .semi-descriptions-item,\n.semi-portal-rtl .semi-descriptions-double-large .semi-descriptions-item {\n padding-right: 0;\n padding-left: 80px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-divider {\n margin: 1px 0px 1px 0px;\n border-bottom: 1px solid var(--semi-color-border);\n color: var(--semi-color-text-0);\n box-sizing: border-box;\n}\n.semi-divider-dashed {\n border-bottom-style: dashed;\n}\n.semi-divider-horizontal {\n width: 100%;\n display: flex;\n}\n.semi-divider-vertical {\n border-bottom: 0;\n display: inline-block;\n margin: 0px 1px 0px 1px;\n border-left: 1px solid var(--semi-color-border);\n height: 20px;\n vertical-align: middle;\n}\n.semi-divider-with-text {\n display: flex;\n border-bottom: 0;\n white-space: nowrap;\n align-items: center;\n}\n.semi-divider-with-text .semi-divider_inner-text {\n font-weight: 600;\n padding: 0px 8px 0px 8px;\n display: inline-block;\n}\n.semi-divider-with-text::before, .semi-divider-with-text::after {\n content: \"\";\n width: 50%;\n border-bottom: 1px solid var(--semi-color-border);\n}\n.semi-divider-with-text-left::before {\n width: 40px;\n}\n.semi-divider-with-text-left::after {\n flex: 1;\n}\n.semi-divider-with-text-right::before {\n flex: 1;\n}\n.semi-divider-with-text-right::after {\n width: 40px;\n}\n\n.semi-divider-dashed::before, .semi-divider-dashed::after {\n border-bottom: 1px dashed var(--semi-color-border);\n}\n\n.semi-divider-vertical.semi-divider-dashed {\n border-left: 1px dashed var(--semi-color-border);\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-modal {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n position: relative;\n margin: 80px auto;\n color: var(--semi-color-text-0);\n}\n.semi-modal-mask {\n position: fixed;\n top: 0;\n right: 0;\n left: 0;\n bottom: 0;\n background-color: var(--semi-color-overlay-bg);\n height: 100%;\n z-index: 1000;\n}\n.semi-modal-mask-hidden {\n display: none;\n}\n.semi-modal-icon-wrapper {\n display: inline-flex;\n margin-right: 12px;\n width: 24px;\n}\n.semi-modal-wrap {\n position: fixed;\n overflow: auto;\n top: 0;\n right: 0;\n bottom: 0;\n left: 0;\n z-index: 1000;\n -webkit-overflow-scrolling: touch;\n outline: 0;\n}\n.semi-modal-wrap-center {\n display: flex;\n align-items: center;\n}\n.semi-modal-title {\n display: inline-flex;\n align-items: flex-start;\n justify-content: flex-start;\n width: 100%;\n margin: 0;\n}\n.semi-modal-content {\n position: relative;\n display: flex;\n height: 100%;\n width: 100%;\n box-sizing: border-box;\n flex-direction: column;\n background-color: var(--semi-color-bg-2);\n border: 1px solid var(--semi-color-border);\n border-radius: var(--semi-border-radius-large);\n padding: 0 24px;\n background-clip: padding-box;\n overflow: hidden;\n box-shadow: var(--semi-shadow-elevated);\n}\n.semi-modal-footerfill {\n display: flex;\n}\n.semi-modal-content-fullScreen {\n border-radius: 0;\n border: none;\n top: 0px;\n}\n.semi-modal-header {\n display: flex;\n align-items: flex-start;\n margin: 24px 0;\n padding: 0 0;\n font-size: 14px;\n font-weight: 600;\n background-color: transparent;\n color: var(--semi-color-text-0);\n border-bottom: 0 solid transparent;\n}\n.semi-modal-body-wrapper {\n display: flex;\n align-items: flex-start;\n margin: 24px 0;\n}\n.semi-modal-body {\n flex: 1 1 auto;\n margin: 0;\n padding: 0;\n}\n.semi-modal-withIcon {\n margin-left: 36px;\n}\n.semi-modal-footer {\n margin: 24px 0;\n padding: 0 0;\n text-align: right;\n border-radius: 0 0 5px 5px;\n border-top: 0 solid transparent;\n background-color: transparent;\n}\n.semi-modal-footer .semi-button {\n margin-left: 12px;\n margin-right: 0;\n}\n.semi-modal-confirm .semi-modal-header {\n margin-bottom: 8px;\n}\n.semi-modal-confirm-icon-wrapper {\n display: inline-flex;\n margin-right: 12px;\n width: 24px;\n}\n.semi-modal-confirm-icon {\n display: inline-flex;\n color: var(--semi-color-primary);\n}\n.semi-modal-info-icon {\n color: var(--semi-color-info);\n}\n.semi-modal-success-icon {\n color: var(--semi-color-success);\n}\n.semi-modal-error-icon {\n color: var(--semi-color-danger);\n}\n.semi-modal-warning-icon {\n color: var(--semi-color-warning);\n}\n.semi-modal-small {\n width: 448px;\n}\n.semi-modal-medium {\n width: 684px;\n}\n.semi-modal-large {\n width: 920px;\n}\n.semi-modal-full-width {\n width: calc(100vw - 64px);\n}\n\n.semi-modal-centered {\n margin: 0 auto;\n}\n\n.semi-modal-popup .semi-modal-mask,\n.semi-modal-popup .semi-modal-wrap {\n position: absolute;\n overflow: hidden;\n}\n\n.semi-modal-fixed .semi-modal-mask,\n.semi-modal-fixed .semi-modal-wrap {\n position: fixed;\n overflow: hidden;\n}\n\n.semi-modal-displayNone {\n display: none;\n}\n\n.semi-modal-content-animate-show {\n animation: 120ms semi-modal-content-keyframe-show cubic-bezier(0.215, 0.61, 0.355, 1) 0ms forwards;\n animation-fill-mode: forwards;\n}\n\n.semi-modal-content-animate-hide {\n animation: 120ms semi-modal-content-keyframe-hide cubic-bezier(0.215, 0.61, 0.355, 1) 0ms forwards;\n animation-fill-mode: forwards;\n}\n\n.semi-modal-mask-animate-show {\n animation: 90ms semi-modal-mask-keyframe-show cubic-bezier(0.215, 0.61, 0.355, 1) 0ms forwards;\n animation-fill-mode: forwards;\n}\n\n.semi-modal-mask-animate-hide {\n animation: 90ms semi-modal-mask-keyframe-hide cubic-bezier(0.215, 0.61, 0.355, 1) 0ms forwards;\n animation-fill-mode: forwards;\n}\n\n@keyframes semi-modal-content-keyframe-show {\n 0% {\n opacity: 0;\n transform: scale(0.7);\n }\n 100% {\n opacity: 1;\n transform: scale(1);\n }\n}\n@keyframes semi-modal-content-keyframe-hide {\n 0% {\n opacity: 1;\n transform: scale(1);\n }\n 100% {\n opacity: 0;\n transform: scale(0.7);\n }\n}\n@keyframes semi-modal-mask-keyframe-show {\n 0% {\n opacity: 0;\n }\n 100% {\n opacity: 1;\n }\n}\n@keyframes semi-modal-mask-keyframe-hide {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n}\n.semi-modal-rtl {\n direction: rtl;\n}\n.semi-modal-rtl .semi-modal-icon-wrapper, .semi-modal-confirm-rtl .semi-modal-icon-wrapper {\n margin-right: 0;\n margin-left: 12px;\n}\n.semi-modal-rtl .semi-modal-withIcon, .semi-modal-confirm-rtl .semi-modal-withIcon {\n margin-left: 0;\n margin-right: 36px;\n}\n.semi-modal-rtl .semi-modal-footer, .semi-modal-confirm-rtl .semi-modal-footer {\n text-align: left;\n}\n.semi-modal-rtl .semi-modal-footer .semi-button, .semi-modal-confirm-rtl .semi-modal-footer .semi-button {\n margin-left: 0;\n margin-right: 12px;\n}\n.semi-modal-confirm-rtl {\n direction: rtl;\n}\n.semi-modal-confirm .semi-modal-confirm-rtl .semi-button {\n margin-left: 0;\n margin-right: 12px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-portal {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n z-index: 1;\n}\n.semi-portal-inner {\n position: absolute;\n background-color: transparent;\n min-width: max-content;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-typography {\n color: var(--semi-color-text-0);\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n.semi-typography.semi-typography-secondary {\n color: var(--semi-color-text-1);\n}\n.semi-typography.semi-typography-tertiary {\n color: var(--semi-color-text-2);\n}\n.semi-typography.semi-typography-quaternary {\n color: var(--semi-color-text-3);\n}\n.semi-typography.semi-typography-warning {\n color: var(--semi-color-warning);\n}\n.semi-typography.semi-typography-success {\n color: var(--semi-color-success);\n}\n.semi-typography.semi-typography-danger {\n color: var(--semi-color-danger);\n}\n.semi-typography.semi-typography-link {\n color: var(--semi-color-link);\n font-weight: 600;\n}\n.semi-typography.semi-typography-disabled {\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n user-select: none;\n}\n.semi-typography.semi-typography-disabled.semi-typography-link {\n color: var(--semi-color-link);\n}\n.semi-typography-icon {\n margin-right: 4px;\n vertical-align: middle;\n color: inherit;\n}\n.semi-typography-small {\n font-size: 12px;\n line-height: 16px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 400;\n}\n.semi-typography-small.semi-typography-paragraph {\n font-weight: 400;\n}\n.semi-typography code {\n border: 1px solid var(--semi-color-border);\n border-radius: 2px;\n color: var(--semi-color-text-2);\n background-color: var(--semi-color-fill-1);\n padding: 2px 4px;\n}\n.semi-typography mark {\n background-color: var(--semi-color-primary-light-default);\n}\n.semi-typography u {\n text-decoration: underline;\n text-decoration-skip-ink: auto;\n}\n.semi-typography del {\n text-decoration: line-through;\n}\n.semi-typography strong {\n font-weight: 600;\n}\n.semi-typography a {\n display: inline;\n color: var(--semi-color-link);\n cursor: pointer;\n text-decoration: none;\n}\n.semi-typography a:visited {\n color: var(--semi-color-link-visited);\n}\n.semi-typography a:hover {\n color: var(--semi-color-link-hover);\n}\n.semi-typography a:active {\n color: var(--semi-color-link-active);\n}\n.semi-typography a .semi-typography-link-underline:hover {\n border-bottom: 1px solid var(--semi-color-link-hover);\n margin-bottom: -1px;\n}\n.semi-typography a .semi-typography-link-underline:active {\n border-bottom: 1px solid var(--semi-color-link-active);\n margin-bottom: -1px;\n}\n.semi-typography-ellipsis-single-line {\n overflow: hidden;\n}\n.semi-typography-ellipsis-multiple-line {\n display: -webkit-box;\n -webkit-box-orient: vertical;\n overflow: hidden;\n}\n.semi-typography-ellipsis-multiple-line.semi-typography-ellipsis-multiple-line-text {\n display: -webkit-inline-box;\n}\n.semi-typography-ellipsis-overflow-ellipsis {\n display: block;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n.semi-typography-ellipsis-overflow-ellipsis.semi-typography-ellipsis-overflow-ellipsis-text {\n display: inline-block;\n max-width: 100%;\n vertical-align: top;\n}\n.semi-typography-ellipsis-expand {\n display: inline;\n margin-left: 8px;\n}\n.semi-typography-action-copy {\n display: inline-flex;\n vertical-align: middle;\n padding: 0;\n margin-left: 4px;\n}\n.semi-typography a.semi-typography-action-copy-icon {\n display: inline-flex;\n}\n.semi-typography-action-copied {\n display: inline-flex;\n padding: 0;\n margin-left: 4px;\n color: var(--semi-color-text-2);\n}\n.semi-typography-action-copied .semi-icon {\n vertical-align: middle;\n color: var(--semi-color-success);\n}\n.semi-typography-paragraph {\n margin: 0;\n}\n\nh1.semi-typography,\n.semi-typography-h1.semi-typography {\n font-size: 32px;\n line-height: 44px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 600;\n margin: 0;\n}\nh1.semi-typography.semi-typography-h1-weight-light,\n.semi-typography-h1.semi-typography.semi-typography-h1-weight-light {\n font-weight: 200;\n}\nh1.semi-typography.semi-typography-h1-weight-regular,\n.semi-typography-h1.semi-typography.semi-typography-h1-weight-regular {\n font-weight: 400;\n}\nh1.semi-typography.semi-typography-h1-weight-medium,\n.semi-typography-h1.semi-typography.semi-typography-h1-weight-medium {\n font-weight: 500;\n}\nh1.semi-typography.semi-typography-h1-weight-semibold,\n.semi-typography-h1.semi-typography.semi-typography-h1-weight-semibold {\n font-weight: 600;\n}\nh1.semi-typography.semi-typography-h1-weight-bold,\n.semi-typography-h1.semi-typography.semi-typography-h1-weight-bold {\n font-weight: 700;\n}\n\nh2.semi-typography,\n.semi-typography-h2.semi-typography {\n font-size: 28px;\n line-height: 40px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 600;\n margin: 0;\n}\nh2.semi-typography.semi-typography-h2-weight-light,\n.semi-typography-h2.semi-typography.semi-typography-h2-weight-light {\n font-weight: 200;\n}\nh2.semi-typography.semi-typography-h2-weight-regular,\n.semi-typography-h2.semi-typography.semi-typography-h2-weight-regular {\n font-weight: 400;\n}\nh2.semi-typography.semi-typography-h2-weight-medium,\n.semi-typography-h2.semi-typography.semi-typography-h2-weight-medium {\n font-weight: 500;\n}\nh2.semi-typography.semi-typography-h2-weight-semibold,\n.semi-typography-h2.semi-typography.semi-typography-h2-weight-semibold {\n font-weight: 600;\n}\nh2.semi-typography.semi-typography-h2-weight-bold,\n.semi-typography-h2.semi-typography.semi-typography-h2-weight-bold {\n font-weight: 700;\n}\n\nh3.semi-typography,\n.semi-typography-h3.semi-typography {\n font-size: 24px;\n line-height: 32px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 600;\n margin: 0;\n}\nh3.semi-typography.semi-typography-h3-weight-light,\n.semi-typography-h3.semi-typography.semi-typography-h3-weight-light {\n font-weight: 200;\n}\nh3.semi-typography.semi-typography-h3-weight-regular,\n.semi-typography-h3.semi-typography.semi-typography-h3-weight-regular {\n font-weight: 400;\n}\nh3.semi-typography.semi-typography-h3-weight-medium,\n.semi-typography-h3.semi-typography.semi-typography-h3-weight-medium {\n font-weight: 500;\n}\nh3.semi-typography.semi-typography-h3-weight-semibold,\n.semi-typography-h3.semi-typography.semi-typography-h3-weight-semibold {\n font-weight: 600;\n}\nh3.semi-typography.semi-typography-h3-weight-bold,\n.semi-typography-h3.semi-typography.semi-typography-h3-weight-bold {\n font-weight: 700;\n}\n\nh4.semi-typography,\n.semi-typography-h4.semi-typography {\n font-size: 20px;\n line-height: 28px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 600;\n margin: 0;\n}\nh4.semi-typography.semi-typography-h4-weight-light,\n.semi-typography-h4.semi-typography.semi-typography-h4-weight-light {\n font-weight: 200;\n}\nh4.semi-typography.semi-typography-h4-weight-regular,\n.semi-typography-h4.semi-typography.semi-typography-h4-weight-regular {\n font-weight: 400;\n}\nh4.semi-typography.semi-typography-h4-weight-medium,\n.semi-typography-h4.semi-typography.semi-typography-h4-weight-medium {\n font-weight: 500;\n}\nh4.semi-typography.semi-typography-h4-weight-semibold,\n.semi-typography-h4.semi-typography.semi-typography-h4-weight-semibold {\n font-weight: 600;\n}\nh4.semi-typography.semi-typography-h4-weight-bold,\n.semi-typography-h4.semi-typography.semi-typography-h4-weight-bold {\n font-weight: 700;\n}\n\nh5.semi-typography,\n.semi-typography-h5.semi-typography {\n font-size: 18px;\n line-height: 24px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 600;\n margin: 0;\n}\nh5.semi-typography.semi-typography-h5-weight-light,\n.semi-typography-h5.semi-typography.semi-typography-h5-weight-light {\n font-weight: 200;\n}\nh5.semi-typography.semi-typography-h5-weight-regular,\n.semi-typography-h5.semi-typography.semi-typography-h5-weight-regular {\n font-weight: 400;\n}\nh5.semi-typography.semi-typography-h5-weight-medium,\n.semi-typography-h5.semi-typography.semi-typography-h5-weight-medium {\n font-weight: 500;\n}\nh5.semi-typography.semi-typography-h5-weight-semibold,\n.semi-typography-h5.semi-typography.semi-typography-h5-weight-semibold {\n font-weight: 600;\n}\nh5.semi-typography.semi-typography-h5-weight-bold,\n.semi-typography-h5.semi-typography.semi-typography-h5-weight-bold {\n font-weight: 700;\n}\n\nh6.semi-typography,\n.semi-typography-h6.semi-typography {\n font-size: 16px;\n line-height: 22px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 600;\n margin: 0;\n}\nh6.semi-typography.semi-typography-h6-weight-light,\n.semi-typography-h6.semi-typography.semi-typography-h6-weight-light {\n font-weight: 200;\n}\nh6.semi-typography.semi-typography-h6-weight-regular,\n.semi-typography-h6.semi-typography.semi-typography-h6-weight-regular {\n font-weight: 400;\n}\nh6.semi-typography.semi-typography-h6-weight-medium,\n.semi-typography-h6.semi-typography.semi-typography-h6-weight-medium {\n font-weight: 500;\n}\nh6.semi-typography.semi-typography-h6-weight-semibold,\n.semi-typography-h6.semi-typography.semi-typography-h6-weight-semibold {\n font-weight: 600;\n}\nh6.semi-typography.semi-typography-h6-weight-bold,\n.semi-typography-h6.semi-typography.semi-typography-h6-weight-bold {\n font-weight: 700;\n}\n\np.semi-typography-extended,\n.semi-typography-paragraph.semi-typography-extended {\n line-height: 24px;\n font-weight: 400;\n}\n\n.semi-rtl .semi-typography,\n.semi-portal-rtl .semi-typography {\n direction: rtl;\n}\n.semi-rtl .semi-typography-link a,\n.semi-rtl .semi-typography a,\n.semi-portal-rtl .semi-typography-link a,\n.semi-portal-rtl .semi-typography a {\n display: inline-block;\n}\n.semi-rtl .semi-typography-icon,\n.semi-portal-rtl .semi-typography-icon {\n margin-right: auto;\n margin-left: 4px;\n}\n.semi-rtl .semi-typography-ellipsis-expand,\n.semi-portal-rtl .semi-typography-ellipsis-expand {\n margin-left: auto;\n}\n.semi-rtl .semi-typography-action-copy,\n.semi-portal-rtl .semi-typography-action-copy {\n margin-left: auto;\n margin-right: 4px;\n}\n.semi-rtl .semi-typography-action-copied,\n.semi-portal-rtl .semi-typography-action-copied {\n margin-left: auto;\n margin-right: 4px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n@keyframes semi-tooltip-zoomIn {\n from {\n opacity: 0;\n transform: scale(0.8, 0.8);\n }\n 50% {\n opacity: 1;\n }\n}\n@keyframes semi-tooltip-bounceIn {\n from {\n opacity: 0;\n transform: scale(0.6, 0.6);\n }\n 70% {\n opacity: 1;\n transform: scale(1.01, 1.01);\n }\n to {\n opacity: 1;\n transform: scale(1, 1);\n animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1);\n }\n}\n@keyframes semi-tooltip-zoomOut {\n from {\n opacity: 1;\n }\n 60% {\n opacity: 0;\n transform: scale(0.8, 0.8);\n }\n to {\n opacity: 0;\n }\n}\n.semi-tooltip-wrapper {\n position: relative;\n background-color: rgba(var(--semi-grey-7), 1);\n color: var(--semi-color-bg-0);\n border-radius: var(--semi-border-radius-medium);\n padding-top: 8px;\n padding-right: 12px;\n padding-bottom: 8px;\n padding-left: 12px;\n font-size: 14px;\n left: 0;\n top: 0;\n word-wrap: break-word;\n overflow-wrap: break-word;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n opacity: 0;\n max-width: 240px;\n}\n.semi-tooltip-wrapper-show {\n opacity: 1;\n}\n.semi-tooltip-content {\n min-width: 0;\n}\n.semi-tooltip-trigger {\n display: inline-block;\n width: auto;\n height: auto;\n}\n.semi-tooltip-with-arrow {\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n}\n.semi-tooltip-animation-show {\n animation: semi-tooltip-zoomIn 100ms cubic-bezier(0.215, 0.61, 0.355, 1);\n animation-fill-mode: forwards;\n}\n.semi-tooltip-animation-hide {\n animation: semi-tooltip-zoomOut 100ms cubic-bezier(0.215, 0.61, 0.355, 1);\n animation-fill-mode: forwards;\n}\n\n.semi-tooltip-wrapper .semi-tooltip-icon-arrow {\n height: 7px;\n width: 24px;\n position: absolute;\n color: rgba(var(--semi-grey-7), 1);\n}\n.semi-tooltip-wrapper[x-placement=top] .semi-tooltip-icon-arrow {\n left: 50%;\n transform: translateX(-50%);\n bottom: -6px;\n}\n.semi-tooltip-wrapper[x-placement=top].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=top] .semi-tooltip-with-arrow {\n min-width: 36px;\n}\n.semi-tooltip-wrapper[x-placement=topLeft] .semi-tooltip-icon-arrow {\n bottom: -6px;\n left: 6px;\n}\n.semi-tooltip-wrapper[x-placement=topLeft].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=topLeft] .semi-tooltip-with-arrow {\n min-width: 36px;\n}\n.semi-tooltip-wrapper[x-placement=topRight] .semi-tooltip-icon-arrow {\n bottom: -6px;\n right: 6px;\n}\n.semi-tooltip-wrapper[x-placement=topRight].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=topRight] .semi-tooltip-with-arrow {\n min-width: 36px;\n}\n.semi-tooltip-wrapper[x-placement=leftTop] .semi-tooltip-icon-arrow {\n width: 7px;\n height: 24px;\n right: -6px;\n top: 5px;\n}\n.semi-tooltip-wrapper[x-placement=leftTop].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=leftTop] .semi-tooltip-with-arrow {\n min-height: 34px;\n}\n.semi-tooltip-wrapper[x-placement=left] .semi-tooltip-icon-arrow {\n width: 7px;\n height: 24px;\n right: -6px;\n top: 50%;\n transform: translateY(-50%);\n}\n.semi-tooltip-wrapper[x-placement=left].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=left] .semi-tooltip-with-arrow {\n min-height: 34px;\n}\n.semi-tooltip-wrapper[x-placement=leftBottom] .semi-tooltip-icon-arrow {\n width: 7px;\n height: 24px;\n right: -6px;\n bottom: 5px;\n}\n.semi-tooltip-wrapper[x-placement=leftBottom].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=leftBottom] .semi-tooltip-with-arrow {\n min-height: 34px;\n}\n.semi-tooltip-wrapper[x-placement=rightTop] .semi-tooltip-icon-arrow {\n width: 7px;\n height: 24px;\n left: -6px;\n top: 5px;\n transform: rotate(180deg);\n}\n.semi-tooltip-wrapper[x-placement=rightTop].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=rightTop] .semi-tooltip-with-arrow {\n min-height: 34px;\n}\n.semi-tooltip-wrapper[x-placement=right] .semi-tooltip-icon-arrow {\n width: 7px;\n height: 24px;\n left: -6px;\n top: 50%;\n transform: translateY(-50%) rotate(180deg);\n}\n.semi-tooltip-wrapper[x-placement=right].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=right] .semi-tooltip-with-arrow {\n min-height: 34px;\n}\n.semi-tooltip-wrapper[x-placement=rightBottom] .semi-tooltip-icon-arrow {\n width: 7px;\n height: 24px;\n left: -6px;\n bottom: 5px;\n transform: rotate(180deg);\n}\n.semi-tooltip-wrapper[x-placement=rightBottom].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=rightBottom] .semi-tooltip-with-arrow {\n min-height: 34px;\n}\n.semi-tooltip-wrapper[x-placement=bottomLeft] .semi-tooltip-icon-arrow {\n top: -6px;\n left: 6px;\n transform: rotate(180deg);\n}\n.semi-tooltip-wrapper[x-placement=bottomLeft].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=bottomLeft] .semi-tooltip-with-arrow {\n min-width: 36px;\n}\n.semi-tooltip-wrapper[x-placement=bottom] .semi-tooltip-icon-arrow {\n top: -6px;\n left: 50%;\n transform: translateX(-50%) rotate(180deg);\n}\n.semi-tooltip-wrapper[x-placement=bottom].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=bottom] .semi-tooltip-with-arrow {\n min-width: 36px;\n}\n.semi-tooltip-wrapper[x-placement=bottomRight] .semi-tooltip-icon-arrow {\n right: 6px;\n top: -6px;\n transform: rotate(180deg);\n}\n.semi-tooltip-wrapper[x-placement=bottomRight].semi-tooltip-with-arrow,\n.semi-tooltip-wrapper[x-placement=bottomRight] .semi-tooltip-with-arrow {\n min-width: 36px;\n}\n\n.semi-rtl .semi-tooltip-wrapper,\n.semi-portal-rtl .semi-tooltip-wrapper {\n direction: rtl;\n padding-right: 12px;\n padding-left: 12px;\n left: auto;\n right: 0;\n}","/* shadow */\n/* sizing */\n/* spacing */\n@keyframes semi-popover-zoomIn {\n from {\n opacity: 0;\n transform: scale(0.8, 0.8);\n }\n 50% {\n opacity: 1;\n }\n}\n@keyframes semi-popover-zoomOut {\n from {\n opacity: 1;\n }\n 60% {\n opacity: 0;\n transform: scale(0.8, 0.8);\n }\n to {\n opacity: 0;\n }\n}\n.semi-popover-wrapper {\n position: relative;\n background-color: var(--semi-color-bg-3);\n box-shadow: var(--semi-shadow-elevated);\n z-index: 1030;\n border-radius: var(--semi-border-radius-medium);\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n opacity: 0;\n}\n.semi-popover-wrapper-show {\n opacity: 1;\n}\n.semi-popover-trigger {\n display: inline-block;\n width: auto;\n height: auto;\n}\n.semi-popover-title {\n padding: 8px;\n border-bottom: 1px solid var(--semi-color-border);\n}\n.semi-popover-confirm {\n position: absolute;\n}\n.semi-popover-with-arrow {\n padding: 12px;\n box-sizing: border-box;\n}\n.semi-popover-animation-show {\n animation: semi-popover-zoomIn 100ms cubic-bezier(0.215, 0.61, 0.355, 1);\n animation-fill-mode: forwards;\n}\n.semi-popover-animation-hide {\n animation: semi-popover-zoomOut 100ms cubic-bezier(0.215, 0.61, 0.355, 1);\n animation-fill-mode: forwards;\n}\n\n.semi-popover-wrapper .semi-popover-icon-arrow {\n height: 8px;\n width: 24px;\n position: absolute;\n color: unset;\n}\n.semi-popover-wrapper[x-placement=top] .semi-popover-icon-arrow {\n left: 50%;\n transform: translateX(-50%);\n bottom: -7px;\n}\n.semi-popover-wrapper[x-placement=top].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=top] .semi-popover-with-arrow {\n min-width: 36px;\n}\n.semi-popover-wrapper[x-placement=topLeft] .semi-popover-icon-arrow {\n bottom: -7px;\n left: 6px;\n}\n.semi-popover-wrapper[x-placement=topLeft].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=topLeft] .semi-popover-with-arrow {\n min-width: 36px;\n}\n.semi-popover-wrapper[x-placement=topRight] .semi-popover-icon-arrow {\n bottom: -7px;\n right: 6px;\n}\n.semi-popover-wrapper[x-placement=topRight].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=topRight] .semi-popover-with-arrow {\n min-width: 36px;\n}\n.semi-popover-wrapper[x-placement=leftTop] .semi-popover-icon-arrow {\n width: 8px;\n height: 24px;\n right: -7px;\n top: 6px;\n}\n.semi-popover-wrapper[x-placement=leftTop].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=leftTop] .semi-popover-with-arrow {\n min-height: 36px;\n}\n.semi-popover-wrapper[x-placement=left] .semi-popover-icon-arrow {\n width: 8px;\n height: 24px;\n right: -7px;\n top: 50%;\n transform: translateY(-50%);\n}\n.semi-popover-wrapper[x-placement=left].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=left] .semi-popover-with-arrow {\n min-height: 36px;\n}\n.semi-popover-wrapper[x-placement=leftBottom] .semi-popover-icon-arrow {\n width: 8px;\n height: 24px;\n right: -7px;\n bottom: 6px;\n}\n.semi-popover-wrapper[x-placement=leftBottom].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=leftBottom] .semi-popover-with-arrow {\n min-height: 36px;\n}\n.semi-popover-wrapper[x-placement=rightTop] .semi-popover-icon-arrow {\n width: 8px;\n height: 24px;\n left: -7px;\n top: 6px;\n transform: rotate(180deg);\n}\n.semi-popover-wrapper[x-placement=rightTop].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=rightTop] .semi-popover-with-arrow {\n min-height: 36px;\n}\n.semi-popover-wrapper[x-placement=right] .semi-popover-icon-arrow {\n width: 8px;\n height: 24px;\n left: -7px;\n top: 50%;\n transform: translateY(-50%) rotate(180deg);\n}\n.semi-popover-wrapper[x-placement=right].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=right] .semi-popover-with-arrow {\n min-height: 36px;\n}\n.semi-popover-wrapper[x-placement=rightBottom] .semi-popover-icon-arrow {\n width: 8px;\n height: 24px;\n left: -7px;\n bottom: 6px;\n transform: rotate(180deg);\n}\n.semi-popover-wrapper[x-placement=rightBottom].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=rightBottom] .semi-popover-with-arrow {\n min-height: 36px;\n}\n.semi-popover-wrapper[x-placement=bottomLeft] .semi-popover-icon-arrow {\n top: -7px;\n left: 6px;\n transform: rotate(180deg);\n}\n.semi-popover-wrapper[x-placement=bottomLeft].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=bottomLeft] .semi-popover-with-arrow {\n min-width: 36px;\n}\n.semi-popover-wrapper[x-placement=bottom] .semi-popover-icon-arrow {\n top: -7px;\n left: 50%;\n transform: translateX(-50%) rotate(180deg);\n}\n.semi-popover-wrapper[x-placement=bottom].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=bottom] .semi-popover-with-arrow {\n min-width: 36px;\n}\n.semi-popover-wrapper[x-placement=bottomRight] .semi-popover-icon-arrow {\n right: 6px;\n top: -7px;\n transform: rotate(180deg);\n}\n.semi-popover-wrapper[x-placement=bottomRight].semi-popover-with-arrow,\n.semi-popover-wrapper[x-placement=bottomRight] .semi-popover-with-arrow {\n min-width: 36px;\n}\n\n.semi-popover.semi-popover-rtl {\n direction: rtl;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-dropdown {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n.semi-dropdown-wrapper {\n overflow-y: auto;\n box-shadow: var(--semi-shadow-elevated);\n position: relative;\n z-index: 1050;\n border-radius: var(--semi-border-radius-medium);\n background: var(--semi-color-bg-3);\n opacity: 0;\n}\n.semi-dropdown-wrapper-show {\n opacity: 1;\n}\n.semi-dropdown-trigger {\n display: inline-block;\n}\n.semi-dropdown-menu {\n list-style: none;\n padding: 4px 0;\n margin: 0;\n}\n.semi-dropdown-title {\n color: var(--semi-color-text-2);\n padding-top: 8px;\n padding-bottom: 4px;\n padding-left: 16px;\n padding-right: 16px;\n font-size: 12px;\n line-height: 16px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n cursor: default;\n}\n.semi-dropdown-title-withTick {\n padding-left: 31px;\n}\n.semi-dropdown-item {\n padding: 8px 16px;\n color: var(--semi-color-text-0);\n max-width: 280px;\n display: flex;\n align-items: center;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeOut) 0ms;\n}\n.semi-dropdown-item-hover {\n background-color: var(--semi-color-fill-0);\n}\n.semi-dropdown-item:not(.semi-dropdown-item-active):hover {\n background-color: var(--semi-color-fill-0);\n cursor: pointer;\n}\n.semi-dropdown-item:not(.semi-dropdown-item-active):active {\n background-color: var(--semi-color-fill-1);\n}\n.semi-dropdown-item:focus-visible {\n background-color: var(--semi-color-fill-0);\n outline: 0;\n}\n.semi-dropdown-item-icon {\n display: inline-flex;\n align-items: center;\n margin-right: 8px;\n}\n.semi-dropdown-item-danger {\n color: var(--semi-color-danger);\n}\n.semi-dropdown-item-secondary {\n color: var(--semi-color-secondary);\n}\n.semi-dropdown-item-warning {\n color: var(--semi-color-warning);\n}\n.semi-dropdown-item-tertiary {\n color: var(--semi-color-tertiary);\n}\n.semi-dropdown-item-primary {\n color: var(--semi-color-primary);\n}\n.semi-dropdown-item-withTick {\n padding-left: 12px;\n}\n.semi-dropdown-item > .semi-icon {\n flex-shrink: 0;\n margin-right: 9px;\n font-size: 12px;\n}\n.semi-dropdown-item-active {\n font-weight: 600;\n}\n.semi-dropdown-item.semi-dropdown-item-disabled {\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-dropdown-item.semi-dropdown-item-disabled:hover, .semi-dropdown-item.semi-dropdown-item-disabled:active {\n cursor: not-allowed;\n background-color: transparent;\n}\n.semi-dropdown-divider {\n display: block;\n height: 1px;\n width: 100%;\n min-width: 100%;\n clear: both;\n background: var(--semi-color-border);\n margin: 4px 0;\n}\n\n.semi-rtl .semi-dropdown-wrapper,\n.semi-portal-rtl .semi-dropdown-wrapper {\n direction: rtl;\n}\n.semi-rtl .semi-dropdown-title-withTick,\n.semi-portal-rtl .semi-dropdown-title-withTick {\n padding-left: 0;\n padding-right: 31px;\n}\n.semi-rtl .semi-dropdown-item-withTick,\n.semi-portal-rtl .semi-dropdown-item-withTick {\n padding-left: auto;\n padding-right: 12px;\n}\n.semi-rtl .semi-dropdown-item > .semi-icon,\n.semi-portal-rtl .semi-dropdown-item > .semi-icon {\n margin-right: 0;\n margin-left: 9px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-layout {\n display: flex;\n flex: auto;\n flex-direction: column;\n min-height: auto;\n}\n.semi-layout, .semi-layout-header, .semi-layout-footer, .semi-layout-content, .semi-layout-sider, .semi-layout-sider-children {\n box-sizing: border-box;\n}\n.semi-layout-header, .semi-layout-footer {\n flex: 0 0 auto;\n}\n.semi-layout-content {\n flex: auto;\n min-height: auto;\n}\n.semi-layout-sider {\n position: relative;\n min-width: auto;\n}\n.semi-layout-sider-children {\n height: 100%;\n margin-top: -0.1px;\n padding-top: 0.1px;\n}\n\n.semi-layout-has-sider {\n flex-direction: row;\n}\n.semi-layout-has-sider > .semi-layout, .semi-layout-has-sider > .semi-layout-content {\n overflow-x: hidden;\n}\n\n.semi-rtl .semi-layout,\n.semi-portal-rtl .semi-layout {\n direction: rtl;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-input-textarea-wrapper {\n box-sizing: border-box;\n display: inline-block;\n position: relative;\n width: 100%;\n border: 1px transparent solid;\n border-radius: var(--semi-border-radius-small);\n vertical-align: bottom;\n background-color: var(--semi-color-fill-0);\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n}\n.semi-input-textarea-wrapper:hover {\n background-color: var(--semi-color-fill-1);\n}\n.semi-input-textarea-wrapper-focus {\n background-color: var(--semi-color-fill-0);\n border: 1px var(--semi-color-focus-border) solid;\n}\n.semi-input-textarea-wrapper-focus:hover, .semi-input-textarea-wrapper-focus:active {\n background-color: var(--semi-color-fill-0);\n}\n.semi-input-textarea-wrapper:active {\n background-color: var(--semi-color-fill-2);\n}\n.semi-input-textarea-wrapper .semi-input-clearbtn {\n position: absolute;\n top: 0;\n min-width: 24px;\n color: var(--semi-color-text-2);\n right: 4px;\n height: 32px;\n}\n.semi-input-textarea-wrapper .semi-input-clearbtn > svg {\n pointer-events: none;\n}\n.semi-input-textarea-wrapper .semi-input-clearbtn:hover {\n cursor: pointer;\n}\n.semi-input-textarea-wrapper .semi-input-clearbtn:hover .semi-icon {\n color: var(--semi-color-primary-hover);\n}\n.semi-input-textarea-wrapper .semi-input-clearbtn-hidden {\n visibility: hidden;\n}\n.semi-input-textarea-wrapper-disabled, .semi-input-textarea-wrapper-readonly {\n cursor: not-allowed;\n color: var(--semi-color-disabled-text);\n background-color: var(--semi-color-disabled-fill);\n}\n.semi-input-textarea-wrapper-disabled:hover, .semi-input-textarea-wrapper-readonly:hover {\n background-color: var(--semi-color-disabled-fill);\n}\n.semi-input-textarea-wrapper-disabled::placeholder, .semi-input-textarea-wrapper-readonly::placeholder {\n color: var(--semi-color-disabled-text);\n}\n.semi-input-textarea-wrapper-readonly {\n cursor: text;\n}\n.semi-input-textarea-wrapper-error {\n background-color: var(--semi-color-danger-light-default);\n border-color: var(--semi-color-danger-light-default);\n}\n.semi-input-textarea-wrapper-error:hover {\n background-color: var(--semi-color-danger-light-hover);\n border-color: var(--semi-color-danger-light-hover);\n}\n.semi-input-textarea-wrapper-error.semi-input-textarea-wrapper-focus {\n background-color: var(--semi-color-danger-light-default);\n border-color: var(--semi-color-danger);\n}\n.semi-input-textarea-wrapper-error:active {\n background-color: var(--semi-color-danger-light-active);\n border-color: var(--semi-color-danger);\n}\n.semi-input-textarea-wrapper-warning {\n background-color: var(--semi-color-warning-light-default);\n border-color: var(--semi-color-warning-light-default);\n}\n.semi-input-textarea-wrapper-warning:hover {\n background-color: var(--semi-color-warning-light-hover);\n border-color: var(--semi-color-warning-light-hover);\n}\n.semi-input-textarea-wrapper-warning.semi-input-textarea-wrapper-focus {\n background-color: var(--semi-color-warning-light-default);\n border-color: var(--semi-color-warning);\n}\n.semi-input-textarea-wrapper-warning:active {\n background-color: var(--semi-color-warning-light-active);\n border-color: var(--semi-color-warning);\n}\n\n.semi-input-textarea {\n position: relative;\n resize: none;\n padding: 5px 12px;\n box-shadow: none;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n background-color: transparent;\n border: 0 solid transparent;\n vertical-align: bottom;\n width: 100%;\n outline: none;\n cursor: text;\n box-sizing: border-box;\n color: var(--semi-color-text-0);\n}\n.semi-input-textarea:hover {\n border-color: transparent;\n}\n.semi-input-textarea::placeholder {\n color: var(--semi-color-text-2);\n}\n.semi-input-textarea-showClear {\n padding-right: 36px;\n}\n.semi-input-textarea-disabled, .semi-input-textarea-readonly {\n cursor: not-allowed;\n color: var(--semi-color-disabled-text);\n background-color: transparent;\n}\n.semi-input-textarea-disabled:hover, .semi-input-textarea-readonly:hover {\n background-color: transparent;\n}\n.semi-input-textarea-disabled::placeholder, .semi-input-textarea-readonly::placeholder {\n color: var(--semi-color-disabled-text);\n}\n.semi-input-textarea-readonly {\n cursor: text;\n}\n.semi-input-textarea-autosize {\n overflow: hidden;\n}\n.semi-input-textarea-counter {\n font-size: 12px;\n line-height: 16px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n display: flex;\n flex-direction: column;\n justify-content: center;\n padding: 3px 12px 5px 12px;\n min-height: 24px;\n text-align: right;\n color: var(--semi-color-text-2);\n}\n.semi-input-textarea-counter-exceed {\n color: var(--semi-color-danger);\n}\n\n.semi-input-textarea-borderless:not(:focus-within):not(:hover) {\n background-color: transparent;\n border-color: transparent;\n}\n.semi-input-textarea-borderless:focus-within:not(:active) {\n background-color: transparent;\n}\n.semi-input-textarea-borderless.semi-input-textarea-wrapper-error:not(:focus-within) {\n border-color: var(--semi-color-danger);\n}\n.semi-input-textarea-borderless.semi-input-textarea-wrapper-warning:not(:focus-within) {\n border-color: var(--semi-color-warning);\n}\n.semi-input-textarea-borderless.semi-input-textarea-wrapper-error .semi-input-textarea-counter {\n color: var(--semi-color-danger);\n}\n.semi-input-textarea-borderless.semi-input-textarea-wrapper-warning .semi-input-textarea-counter {\n color: var(--semi-color-warning);\n}\n\n.semi-rtl .semi-input-wrapper,\n.semi-portal-rtl .semi-input-wrapper {\n direction: rtl;\n}\n.semi-rtl .semi-input-wrapper__with-prefix .semi-input,\n.semi-portal-rtl .semi-input-wrapper__with-prefix .semi-input {\n padding-left: auto;\n padding-right: 0;\n}\n.semi-rtl .semi-input-wrapper__with-suffix .semi-input,\n.semi-portal-rtl .semi-input-wrapper__with-suffix .semi-input {\n padding-right: auto;\n padding-left: 0;\n}\n.semi-rtl .semi-input,\n.semi-portal-rtl .semi-input {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-input-inset-label,\n.semi-portal-rtl .semi-input-inset-label {\n margin-right: auto;\n margin-left: 12px;\n}\n.semi-rtl .semi-input-clearbtn + .semi-rtl .semi-input-suffix + .semi-input-suffix-text,\n.semi-rtl .semi-input-clearbtn + .semi-portal-rtl .semi-input-suffix + .semi-input-suffix-text,\n.semi-portal-rtl .semi-input-clearbtn + .semi-rtl .semi-input-suffix + .semi-input-suffix-text,\n.semi-portal-rtl .semi-input-clearbtn + .semi-portal-rtl .semi-input-suffix + .semi-input-suffix-text {\n margin-left: auto;\n margin-right: 0;\n}\n.semi-rtl .semi-input-clearbtn + .semi-rtl .semi-input-suffix + .semi-input-suffix-icon,\n.semi-rtl .semi-input-clearbtn + .semi-portal-rtl .semi-input-suffix + .semi-input-suffix-icon,\n.semi-portal-rtl .semi-input-clearbtn + .semi-rtl .semi-input-suffix + .semi-input-suffix-icon,\n.semi-portal-rtl .semi-input-clearbtn + .semi-portal-rtl .semi-input-suffix + .semi-input-suffix-icon {\n margin-left: auto;\n margin-right: 0;\n}\n.semi-rtl .semi-input-append,\n.semi-portal-rtl .semi-input-append {\n border-left: 0;\n border-right: 1px transparent solid;\n}\n.semi-rtl .semi-input-prepend,\n.semi-portal-rtl .semi-input-prepend {\n border-right: 0;\n border-left: 1px transparent solid;\n}\n.semi-rtl .semi-input-group .semi-select:not(:last-child)::after,\n.semi-rtl .semi-input-group .semi-cascader:not(:last-child)::after,\n.semi-rtl .semi-input-group .semi-tree-select:not(:last-child)::after, .semi-rtl .semi-input-group > .semi-input-wrapper:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group .semi-select:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group .semi-cascader:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group .semi-tree-select:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group > .semi-input-wrapper:not(:last-child)::after {\n right: auto;\n left: -1px;\n}\n.semi-rtl .semi-input-group .semi-input-number:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group .semi-input-number:not(:last-child)::after {\n right: auto;\n left: -1px;\n}\n.semi-rtl .semi-input-textarea-wrapper,\n.semi-portal-rtl .semi-input-textarea-wrapper {\n direction: rtl;\n}\n.semi-rtl .semi-input-textarea-counter,\n.semi-portal-rtl .semi-input-textarea-counter {\n text-align: left;\n}\n.semi-rtl .semi-input-textarea-showClear,\n.semi-portal-rtl .semi-input-textarea-showClear {\n padding-right: 0;\n padding-left: 36px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-navigation {\n box-sizing: border-box;\n display: inline-flex;\n width: 240px;\n outline: none;\n overflow: hidden;\n margin: 0;\n padding-left: 8px;\n padding-right: 8px;\n user-select: none;\n transition: padding-left 100ms ease-out, width 200ms cubic-bezier(0.62, 0.05, 0.36, 0.95);\n border-right: 1px solid var(--semi-color-border);\n background-color: var(--semi-color-nav-bg);\n}\n.semi-navigation-inner {\n width: 100%;\n height: 100%;\n display: flex;\n justify-content: space-between;\n}\n.semi-navigation-list {\n margin: 0;\n padding: 0;\n list-style: none;\n}\n.semi-navigation-list > .semi-navigation-item-normal {\n height: 36px;\n}\n.semi-navigation-list > .semi-navigation-item {\n font-weight: 600;\n}\n.semi-navigation-list > .semi-navigation-item > .semi-navigation-sub-title {\n font-weight: 600;\n}\n.semi-navigation-collapsed {\n width: 60px;\n padding-left: 8px;\n padding-right: 8px;\n transition: padding-left 100ms ease-out, width 200ms cubic-bezier(0.62, 0.05, 0.36, 0.95);\n}\n.semi-navigation-collapsed .semi-navigation-item-text {\n transition: opacity 0.2s cubic-bezier(0.5, -0.1, 1, 0.4);\n opacity: 0;\n}\n.semi-navigation-collapsed .semi-navigation-item-icon:last-child {\n transition: opacity 0.2s cubic-bezier(0.5, -0.1, 1, 0.4);\n opacity: 0;\n}\n.semi-navigation-sub-wrap .semi-navigation-sub-title, .semi-navigation-item {\n cursor: pointer;\n display: flex;\n border-radius: var(--semi-border-radius-small);\n padding: 8px 12px;\n box-sizing: border-box;\n margin-top: 0;\n margin-bottom: 8px;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 400;\n color: var(--semi-color-text-0);\n width: 100%;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n}\n.semi-navigation-sub-wrap .semi-navigation-sub-title-text, .semi-navigation-item-text {\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n transition: opacity 0.2s cubic-bezier(0.5, -0.1, 1, 0.4);\n opacity: 1;\n}\n.semi-navigation-sub-wrap .semi-navigation-sub-title-indent, .semi-navigation-item-indent {\n width: 32px;\n}\n.semi-navigation-sub-wrap .semi-navigation-sub-title:focus-visible, .semi-navigation-item:focus-visible {\n outline: 2px solid var(--semi-color-primary-light-active);\n outline-offset: -2px;\n}\n.semi-navigation-header-link, .semi-navigation-item-link {\n display: flex;\n width: 100%;\n color: inherit;\n text-decoration: none;\n align-items: center;\n justify-content: flex-start;\n}\n.semi-navigation-item-has-link {\n padding: 0;\n}\n.semi-navigation-item-has-link .semi-navigation-item-link {\n padding: 8px 12px;\n}\n.semi-navigation-item-sub {\n padding: 0;\n}\n.semi-navigation-sub-wrap > .semi-navigation-item-inner {\n width: 100%;\n}\n.semi-navigation-sub-wrap .semi-navigation-sub-title > .semi-navigation-item-inner {\n display: flex;\n}\n.semi-navigation-item-inner {\n display: flex;\n align-items: center;\n width: 100%;\n flex: 0 0 auto;\n}\n.semi-navigation-item-title {\n opacity: 1;\n transition: opacity 100ms 100s ease-out;\n}\n.semi-navigation .semi-navigation-sub-title {\n margin-bottom: 0;\n}\n.semi-navigation-item-icon-info {\n display: inline-flex;\n color: var(--semi-color-text-2);\n margin-right: 12px;\n min-width: 20px;\n margin-left: 0;\n}\n.semi-navigation-item-icon-toggle-left {\n display: inline-flex;\n color: var(--semi-color-text-2);\n margin-right: 12px;\n min-width: 20px;\n}\n.semi-navigation-item-icon-toggle-right {\n display: inline-flex;\n color: var(--semi-color-text-2);\n margin-left: auto;\n transition: opacity 0.2s cubic-bezier(0.5, -0.1, 1, 0.4);\n opacity: 1;\n}\n.semi-navigation-item-selected {\n background-color: var(--semi-color-primary-light-default);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-item-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-item-selected.semi-navigation-item-disabled {\n background-color: transparent;\n color: var(--semi-color-primary-disabled);\n cursor: not-allowed;\n}\n.semi-navigation-item-selected.semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary-disabled);\n}\n.semi-navigation-item-disabled {\n background-color: transparent;\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-inner > .semi-navigation-item {\n color: var(--semi-color-text-0);\n}\n.semi-navigation-item-normal:hover:not(.semi-navigation-item-selected) {\n background-color: var(--semi-color-fill-0);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-item-normal:hover:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-2);\n}\n.semi-navigation-item-normal:hover.semi-navigation-item-selected {\n color: var(--semi-color-text-0);\n background-color: var(--semi-color-fill-0);\n}\n.semi-navigation-item-normal:hover.semi-navigation-item-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-item-normal:hover.semi-navigation-item-disabled {\n background-color: transparent;\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-item-normal:hover.semi-navigation-item-selected.semi-navigation-item-disabled {\n background-color: transparent;\n color: var(--semi-color-primary-disabled);\n cursor: not-allowed;\n}\n.semi-navigation-item-normal:hover.semi-navigation-item-selected.semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary-disabled);\n}\n.semi-navigation-item-normal:active:not(.semi-navigation-item-selected), .semi-navigation-inner > .semi-navigation-item-normal:active:not(.semi-navigation-item-selected) {\n background-color: var(--semi-color-fill-1);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-item-normal:active:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child, .semi-navigation-inner > .semi-navigation-item-normal:active:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-2);\n}\n.semi-navigation-item-normal:active.semi-navigation-item-selected, .semi-navigation-inner > .semi-navigation-item-normal:active.semi-navigation-item-selected {\n color: var(--semi-color-text-0);\n background-color: var(--semi-color-fill-1);\n}\n.semi-navigation-item-normal:active.semi-navigation-item-selected .semi-navigation-item-icon:first-child, .semi-navigation-inner > .semi-navigation-item-normal:active.semi-navigation-item-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-item-normal:active.semi-navigation-item-disabled, .semi-navigation-inner > .semi-navigation-item-normal:active.semi-navigation-item-disabled {\n background-color: transparent;\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon:first-child, .semi-navigation-inner > .semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-inner > .semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-sub-wrap .semi-navigation-item-inner {\n display: block;\n}\n.semi-navigation-sub-wrap {\n display: block;\n padding: 0;\n margin-top: 0;\n height: inherit;\n}\n.semi-navigation-sub-wrap .semi-navigation-sub-title {\n display: flex;\n justify-content: flex-start;\n height: 36px;\n align-items: center;\n}\n.semi-navigation-sub {\n font-weight: 400;\n font-size: 14px;\n list-style: none;\n outline: none;\n padding: 0;\n text-overflow: ellipsis;\n white-space: nowrap;\n overflow: hidden;\n}\n.semi-navigation-sub .semi-navigation-item {\n color: var(--semi-color-text-0);\n background-color: transparent;\n height: 36px;\n font-weight: 400;\n width: 100%;\n}\n.semi-navigation-sub .semi-navigation-item:first-child {\n margin-top: 8px;\n}\n.semi-navigation-sub .semi-navigation-item > .semi-navigation-sub .semi-navigation-item-text:first-child {\n margin-left: 44px;\n}\n.semi-navigation-sub .semi-navigation-item:hover:not(.semi-navigation-sub-wrap):not(.semi-navigation-item-selected):not(.semi-navigation-item-disabled) {\n background-color: var(--semi-color-fill-0);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-sub .semi-navigation-item:hover:not(.semi-navigation-sub-wrap):not(.semi-navigation-item-selected):not(.semi-navigation-item-disabled) .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-2);\n}\n.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-disabled {\n background-color: transparent;\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-selected {\n background-color: var(--semi-color-primary-light-default);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-sub .semi-navigation-item:hover.semi-navigation-item-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-sub .semi-navigation-item:active:not(.semi-navigation-sub-wrap):not(.semi-navigation-item-selected):not(.semi-navigation-item-disabled) {\n background-color: var(--semi-color-fill-1);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-sub .semi-navigation-item:active:not(.semi-navigation-sub-wrap):not(.semi-navigation-item-selected):not(.semi-navigation-item-disabled) .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-2);\n}\n.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-disabled {\n background-color: transparent;\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-selected {\n background-color: var(--semi-color-primary-light-default);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-sub .semi-navigation-item:active.semi-navigation-item-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-sub .semi-navigation-item-selected {\n background-color: var(--semi-color-primary-light-default);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-sub .semi-navigation-item-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-sub .semi-navigation-item-selected.semi-navigation-item-disabled {\n cursor: not-allowed;\n background-color: var(--semi-color-primary-light-default);\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-sub .semi-navigation-item-disabled {\n background-color: transparent;\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-navigation-sub .semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-sub .semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-sub .semi-navigation-sub-wrap {\n height: inherit;\n}\n.semi-navigation-icon-rotate-0 {\n transition: transform 200ms ease-in-out;\n transform: rotate(0);\n}\n.semi-navigation-icon-rotate-180 {\n transition: transform 200ms ease-in-out;\n transform: rotate(-180deg);\n}\n\n/* Header、Footer-Common */\n.semi-navigation-header {\n display: inline-flex;\n align-items: center;\n box-sizing: border-box;\n}\n.semi-navigation-header-logo {\n margin-left: 0;\n margin-right: 8px;\n display: inline-flex;\n}\n.semi-navigation-header-logo > .semi-icon, .semi-navigation-header-logo > img {\n width: 36px;\n height: 36px;\n object-fit: scale-down;\n}\n.semi-navigation-header-text {\n font-size: 18px;\n line-height: 24px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 600;\n display: inline-flex;\n color: var(--semi-color-text-0);\n white-space: nowrap;\n text-overflow: ellipsis;\n transition: opacity 0.2s cubic-bezier(0.5, -0.1, 1, 0.4);\n opacity: 1;\n}\n.semi-navigation-footer {\n box-sizing: border-box;\n padding: 16px 24px;\n display: inline-flex;\n align-items: center;\n}\n.semi-navigation-footer .semi-navigation-collapse-btn {\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.semi-navigation-collapsed .semi-navigation-header {\n justify-content: center;\n}\n.semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo {\n margin-right: 0;\n width: 100%;\n}\n.semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo > .semi-icon, .semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo > img {\n width: 36px;\n max-width: 100%;\n max-height: 100%;\n}\n.semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-text {\n transition: opacity 0.2s cubic-bezier(0.5, -0.1, 1, 0.4);\n opacity: 0;\n}\n\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-item-selected:not(.semi-navigation-item-disabled).semi-navigation-item-normal:hover .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title {\n color: var(--semi-color-text-0);\n background-color: transparent;\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title-selected {\n font-weight: 600;\n background-color: var(--semi-color-primary-light-default);\n color: var(--semi-color-text-0);\n background-color: transparent;\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title-selected.semi-navigation-sub-title-disabled {\n background-color: transparent;\n color: var(--semi-color-primary-disabled);\n cursor: not-allowed;\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title-selected.semi-navigation-sub-title-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary-disabled);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title-disabled {\n font-weight: 600;\n background-color: transparent;\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title-disabled .semi-navigation-item-icon,\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:hover:not(.semi-navigation-sub-title-selected) {\n background-color: var(--semi-color-fill-0);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:hover:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-2);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:hover.semi-navigation-sub-title-selected {\n color: var(--semi-color-text-0);\n background-color: var(--semi-color-fill-0);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:hover.semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:active:not(.semi-navigation-sub-title-selected) {\n background-color: var(--semi-color-fill-1);\n color: var(--semi-color-text-0);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:active:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-2);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:active.semi-navigation-sub-title-selected {\n color: var(--semi-color-text-0);\n background-color: var(--semi-color-fill-1);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:active.semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected), .semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:active.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected) {\n background-color: transparent;\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon,\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon:first-child, .semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:active.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon,\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:active.semi-navigation-sub-title-disabled:not(.semi-navigation-sub-title-selected) .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled.semi-navigation-sub-title-selected, .semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:active.semi-navigation-sub-title-disabled.semi-navigation-sub-title-selected {\n background-color: transparent;\n color: var(--semi-color-primary-disabled);\n cursor: not-allowed;\n}\n.semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:hover.semi-navigation-sub-title-disabled.semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child, .semi-navigation-vertical .semi-navigation-list > .semi-navigation-sub-wrap > .semi-navigation-sub-title:active.semi-navigation-sub-title-disabled.semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-primary-disabled);\n}\n.semi-navigation-vertical .semi-navigation-item:last-of-type {\n margin-bottom: 0;\n}\n.semi-navigation-vertical .semi-navigation-inner {\n flex-direction: column;\n}\n.semi-navigation-vertical .semi-navigation-header-list-outer {\n height: 100%;\n}\n.semi-navigation-vertical .semi-navigation-list-wrapper {\n padding-top: 12px;\n overflow-y: auto;\n overflow-x: hidden;\n}\n.semi-navigation-vertical .semi-navigation-header {\n padding-top: 32px;\n padding-bottom: 36px;\n padding-left: 5.5px;\n padding-right: 8px;\n width: 100%;\n}\n.semi-navigation-vertical .semi-navigation-header-collapsed {\n padding-left: 5.5px;\n padding-right: 0;\n transition: padding-left 100ms ease-out, width 200ms cubic-bezier(0.62, 0.05, 0.36, 0.95);\n}\n.semi-navigation-vertical .semi-navigation-footer {\n color: var(--semi-color-text-2);\n padding-left: 8px;\n padding-right: 8px;\n}\n.semi-navigation-vertical .semi-navigation-footer .semi-navigation-collapse-btn .semi-button-content-right {\n margin-left: 12px;\n opacity: 1;\n transition: opacity 0.2s cubic-bezier(0.5, -0.1, 1, 0.4);\n}\n.semi-navigation-vertical .semi-navigation-footer .semi-navigation-collapse-btn > .semi-button {\n padding-left: 8px;\n padding-right: 8px;\n}\n.semi-navigation-vertical .semi-navigation-footer-collapsed {\n justify-content: center;\n}\n.semi-navigation-vertical .semi-navigation-footer-collapsed .semi-navigation-collapse-btn {\n width: 100%;\n}\n.semi-navigation-vertical .semi-navigation-footer-collapsed .semi-navigation-collapse-btn .semi-button-content-right {\n opacity: 0;\n transition: opacity 0.2s cubic-bezier(0.5, -0.1, 1, 0.4);\n}\n\n.semi-navigation-horizontal {\n width: 100%;\n height: 60px;\n border-right: none;\n border-bottom: 1px solid var(--semi-color-border);\n padding-left: 24px;\n padding-right: 24px;\n}\n.semi-navigation-horizontal .semi-navigation-inner {\n flex-direction: row;\n}\n.semi-navigation-horizontal .semi-navigation-header-list-outer {\n display: inline-flex;\n align-items: center;\n}\n.semi-navigation-horizontal .semi-navigation-header-list-outer-collapsed {\n align-items: baseline;\n}\n.semi-navigation-horizontal .semi-navigation-header {\n width: inherit;\n margin-right: 24px;\n}\n.semi-navigation-horizontal .semi-navigation-list {\n display: inline-flex;\n align-items: center;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item {\n margin-bottom: 0;\n color: var(--semi-color-text-2);\n background-color: transparent;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-2);\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-selected {\n color: var(--semi-color-text-0);\n background-color: transparent;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-selected .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-0);\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-disabled {\n color: var(--semi-color-disabled-text);\n background-color: transparent;\n cursor: not-allowed;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover:not(.semi-navigation-item-selected) {\n color: var(--semi-color-text-1);\n background-color: transparent;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-1);\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover:not(.semi-navigation-item-selected) .semi-navigation-item-text {\n color: var(--semi-color-text-1);\n background-color: transparent;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active:not(.semi-navigation-item-selected) {\n color: var(--semi-color-text-0);\n background-color: transparent;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active:not(.semi-navigation-item-selected) .semi-navigation-item-icon:first-child {\n color: var(--semi-color-text-0);\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover.semi-navigation-item-disabled, .semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active.semi-navigation-item-disabled {\n color: var(--semi-color-disabled-text);\n background-color: transparent;\n cursor: not-allowed;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-icon:first-child, .semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon,\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-icon:first-child {\n color: var(--semi-color-disabled-text);\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:hover.semi-navigation-item-disabled .semi-navigation-item-text, .semi-navigation-horizontal .semi-navigation-list .semi-navigation-item-normal:active.semi-navigation-item-disabled .semi-navigation-item-text {\n color: var(--semi-color-disabled-text);\n background-color: transparent;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-item:not(:last-of-type) {\n margin-right: 8px;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title .semi-navigation-item-text {\n color: var(--semi-color-text-2);\n background-color: transparent;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-selected .semi-navigation-item-icon:first-child,\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-selected .semi-navigation-item-text {\n color: var(--semi-color-text-0);\n background-color: transparent;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-disabled {\n cursor: not-allowed;\n}\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-disabled .semi-navigation-item-icon:first-child,\n.semi-navigation-horizontal .semi-navigation-list .semi-navigation-sub-title-disabled .semi-navigation-item-text {\n color: var(--semi-color-disabled-text);\n background-color: transparent;\n}\n.semi-navigation-horizontal .semi-navigation-item-inner {\n width: auto;\n}\n.semi-navigation-horizontal .semi-navigation-item-icon:last-child {\n margin-left: 8px;\n}\n.semi-navigation-horizontal .semi-navigation-item-icon:first-child {\n margin-right: 8px;\n}\n.semi-navigation-horizontal .semi-navigation-item {\n width: auto;\n}\n.semi-navigation-horizontal .semi-navigation-item-collapsed {\n word-wrap: none;\n text-overflow: ellipsis;\n}\n.semi-navigation-horizontal .semi-navigation-footer {\n border-top: none;\n padding-right: 0;\n}\n.semi-navigation-horizontal .semi-navigation-footer-collapsed {\n justify-content: center;\n flex-direction: row;\n align-items: center;\n}\n\n.semi-navigation-popover .semi-navigation-sub-title {\n width: 100%;\n}\n.semi-navigation-popover .semi-navigation-item-selected {\n font-weight: normal;\n}\n\n.semi-dropdown-item .semi-navigation-sub-title {\n box-sizing: border-box;\n padding: 8px 12px;\n width: 100%;\n}\n.semi-dropdown-item.semi-navigation-item {\n margin-top: 0;\n margin-bottom: 0;\n min-width: 150px;\n}\n\n.semi-dropdown-menu .semi-navigation-item-sub {\n padding: 0;\n}\n\n.semi-rtl .semi-navigation,\n.semi-portal-rtl .semi-navigation {\n direction: rtl;\n border-right: 0;\n border-left: 1px solid var(--semi-color-border);\n transition: padding-right 100ms ease-out, width 200ms cubic-bezier(0.62, 0.05, 0.36, 0.95);\n}\n.semi-rtl .semi-navigation-collapsed,\n.semi-portal-rtl .semi-navigation-collapsed {\n transition: padding-right 100ms ease-out, width 200ms cubic-bezier(0.62, 0.05, 0.36, 0.95);\n}\n.semi-rtl .semi-navigation-item-icon:first-child,\n.semi-portal-rtl .semi-navigation-item-icon:first-child {\n margin-right: 0;\n margin-left: 12px;\n}\n.semi-rtl .semi-navigation-item-icon:last-child,\n.semi-portal-rtl .semi-navigation-item-icon:last-child {\n margin-left: 0;\n margin-right: auto;\n}\n.semi-rtl .semi-navigation-sub .semi-navigation-item > .semi-rtl .semi-navigation-sub .semi-navigation-item-text:first-child,\n.semi-rtl .semi-navigation-sub .semi-navigation-item > .semi-portal-rtl .semi-navigation-sub .semi-navigation-item-text:first-child,\n.semi-portal-rtl .semi-navigation-sub .semi-navigation-item > .semi-rtl .semi-navigation-sub .semi-navigation-item-text:first-child,\n.semi-portal-rtl .semi-navigation-sub .semi-navigation-item > .semi-portal-rtl .semi-navigation-sub .semi-navigation-item-text:first-child {\n margin-left: auto;\n margin-right: 44px;\n}\n.semi-rtl .semi-navigation-sub .semi-navigation-item > .semi-navigation-item-icon:first-child,\n.semi-portal-rtl .semi-navigation-sub .semi-navigation-item > .semi-navigation-item-icon:first-child {\n margin-right: 12px;\n}\n.semi-rtl .semi-navigation-header,\n.semi-portal-rtl .semi-navigation-header {\n display: inline-flex;\n align-items: center;\n box-sizing: border-box;\n}\n.semi-rtl .semi-navigation-header-logo,\n.semi-portal-rtl .semi-navigation-header-logo {\n margin-left: 8px;\n margin-right: 0;\n display: inline-flex;\n}\n.semi-rtl .semi-navigation-collapsed,\n.semi-portal-rtl .semi-navigation-collapsed {\n direction: rtl;\n}\n.semi-rtl .semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo,\n.semi-portal-rtl .semi-navigation-collapsed .semi-navigation-header .semi-navigation-header-logo {\n margin-right: auto;\n margin-left: 0;\n}\n.semi-rtl .semi-navigation-vertical,\n.semi-portal-rtl .semi-navigation-vertical {\n direction: rtl;\n}\n.semi-rtl .semi-navigation-vertical .semi-navigation-header,\n.semi-portal-rtl .semi-navigation-vertical .semi-navigation-header {\n padding-right: 5.5px;\n padding-left: 8px;\n}\n.semi-rtl .semi-navigation-vertical .semi-navigation-header-collapsed,\n.semi-portal-rtl .semi-navigation-vertical .semi-navigation-header-collapsed {\n padding-right: 5.5px;\n padding-left: 0;\n transition: padding-right 100ms ease-out, width 200ms cubic-bezier(0.62, 0.05, 0.36, 0.95);\n}\n.semi-rtl .semi-navigation-vertical .semi-navigation-footer .semi-navigation-collapse-btn .semi-button-content-right,\n.semi-portal-rtl .semi-navigation-vertical .semi-navigation-footer .semi-navigation-collapse-btn .semi-button-content-right {\n margin-left: auto;\n margin-right: 12px;\n transition: opacity 0.2s cubic-bezier(0.5, -0.1, 1, 0.4);\n}\n.semi-rtl .semi-navigation-horizontal,\n.semi-portal-rtl .semi-navigation-horizontal {\n direction: rtl;\n border-right: auto;\n border-left: none;\n padding-left: 24px;\n padding-right: 24px;\n}\n.semi-rtl .semi-navigation-horizontal .semi-navigation-header,\n.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-header {\n margin-right: auto;\n margin-left: 24px;\n}\n.semi-rtl .semi-navigation-horizontal .semi-navigation-list .semi-navigation-item:not(:last-of-type),\n.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-list .semi-navigation-item:not(:last-of-type) {\n margin-right: auto;\n margin-left: 8px;\n}\n.semi-rtl .semi-navigation-horizontal .semi-navigation-item-icon:last-child,\n.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-item-icon:last-child {\n margin-left: auto;\n margin-right: 8px;\n}\n.semi-rtl .semi-navigation-horizontal .semi-navigation-item-icon:first-child,\n.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-item-icon:first-child {\n margin-right: auto;\n margin-left: 8px;\n}\n.semi-rtl .semi-navigation-horizontal .semi-navigation-footer,\n.semi-portal-rtl .semi-navigation-horizontal .semi-navigation-footer {\n padding-right: auto;\n padding-left: 0;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-collapsible-transition {\n transition: height 250ms cubic-bezier(0.25, 0.1, 0.25, 1) var(--semi-transition_delay-none), opacity 250ms var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-popconfirm {\n box-sizing: border-box;\n max-width: 400px;\n}\n.semi-popconfirm-inner {\n display: flex;\n flex-direction: column;\n padding: 24px 24px 24px 20px;\n position: relative;\n}\n.semi-popconfirm-header {\n display: flex;\n justify-content: flex-start;\n}\n.semi-popconfirm-header-title {\n font-size: 16px;\n line-height: 22px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 600;\n margin-bottom: 8px;\n color: var(--semi-color-text-0);\n}\n.semi-popconfirm-header-icon {\n width: 24px;\n height: 24px;\n margin-right: 12px;\n}\n.semi-popconfirm-header .semi-icon-alert_triangle {\n color: var(--semi-color-warning);\n}\n.semi-popconfirm-header-body {\n display: inline-flex;\n flex-grow: 1;\n flex-direction: column;\n}\n.semi-popconfirm-body {\n color: var(--semi-color-text-2);\n}\n.semi-popconfirm-body-withIcon {\n margin-left: 36px;\n}\n.semi-popconfirm-body > p {\n margin: 0;\n padding: 0;\n}\n.semi-popconfirm-footer {\n margin-top: 25px;\n display: flex;\n justify-content: flex-end;\n}\n.semi-popconfirm-footer > .semi-button:first-child:not(:last-child) {\n margin-right: 8px;\n}\n.semi-popconfirm-popover {\n border-radius: var(--semi-border-radius-medium);\n}\n\n.semi-popover-with-arrow .semi-popconfirm-inner {\n padding: 12px 12px 12px 8px;\n}\n\n.semi-popconfirm-rtl {\n direction: rtl;\n}\n.semi-popconfirm-rtl .semi-popconfirm-inner {\n padding: 24px 20px 24px 24px;\n}\n.semi-popconfirm-rtl .semi-popconfirm-header {\n margin-right: 0;\n}\n.semi-popconfirm-rtl .semi-popconfirm-header-icon {\n margin-right: 0;\n margin-left: 12px;\n}\n.semi-popconfirm-rtl .semi-popconfirm-footer {\n justify-content: flex-end;\n}\n.semi-popconfirm-rtl .semi-popconfirm-footer > .semi-button:first-child:not(:last-child) {\n margin-right: 0;\n margin-left: 8px;\n}\n\n.semi-popover-with-arrow.semi-popconfirm-rtl {\n direction: rtl;\n}\n.semi-popover-with-arrow.semi-popconfirm-rtl .semi-popconfirm-inner {\n padding: 12px 8px 12px 12px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-space {\n display: inline-flex;\n}\n.semi-space-vertical {\n flex-direction: column;\n}\n.semi-space-horizontal {\n flex-direction: row;\n}\n.semi-space-align-center {\n align-items: center;\n}\n.semi-space-align-end {\n align-items: flex-end;\n}\n.semi-space-align-start {\n align-items: flex-start;\n}\n.semi-space-align-baseline {\n align-items: baseline;\n}\n.semi-space-wrap {\n flex-wrap: wrap;\n}\n.semi-space-tight-horizontal {\n column-gap: 8px;\n}\n.semi-space-tight-vertical {\n row-gap: 8px;\n}\n.semi-space-medium-horizontal {\n column-gap: 16px;\n}\n.semi-space-medium-vertical {\n row-gap: 16px;\n}\n.semi-space-loose-horizontal {\n column-gap: 24px;\n}\n.semi-space-loose-vertical {\n row-gap: 24px;\n}\n\n.semi-rtl .semi-space,\n.semi-portal-rtl .semi-space {\n direction: rtl;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-table-panel-operation {\n background-color: var(--semi-color-primary);\n padding-left: 16px;\n padding-right: 16px;\n padding-top: 8px;\n padding-bottom: 8px;\n display: flex;\n justify-content: space-between;\n color: var(--semi-color-text-2);\n}\n.semi-table-panel-operation-right, .semi-table-panel-operation-left {\n display: flex;\n justify-content: space-between;\n}\n.semi-table-panel-operation-selected {\n color: var(--semi-color-primary-light-active);\n}\n\n.semi-table-pagination-info {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 400;\n}\n.semi-table-pagination-outer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n}\n\n.semi-table {\n width: 100%;\n text-align: left;\n border-collapse: separate;\n border-spacing: 0;\n font-size: inherit;\n display: table;\n}\n.semi-table-wrapper {\n zoom: 1;\n position: relative;\n clear: both;\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n color: var(--semi-color-text-0);\n width: 100%;\n}\n.semi-table-wrapper[data-column-fixed=true] {\n z-index: 1;\n}\n.semi-table-wrapper-ltr {\n direction: ltr;\n}\n.semi-table-wrapper-ltr .semi-spin {\n direction: ltr;\n}\n.semi-table-middle .semi-table-tbody > .semi-table-row > .semi-table-row-cell {\n padding-top: 12px;\n padding-bottom: 12px;\n}\n.semi-table-small .semi-table-tbody > .semi-table-row > .semi-table-row-cell {\n padding-top: 8px;\n padding-bottom: 8px;\n}\n.semi-table-title {\n position: relative;\n padding-top: 16px;\n padding-bottom: 16px;\n padding-left: 0;\n padding-right: 0;\n}\n.semi-table-container {\n position: relative;\n}\n.semi-table-header {\n overflow: hidden;\n scrollbar-base-color: transparent;\n}\n.semi-table-header::-webkit-scrollbar {\n background-color: transparent;\n border-bottom: 2px solid var(--semi-color-border);\n}\n.semi-table-header-sticky {\n position: sticky;\n z-index: 102;\n}\n.semi-table-header-sticky .semi-table-thead > .semi-table-row > .semi-table-row-head {\n background-color: var(--semi-color-bg-1);\n}\n.semi-table-header-hidden {\n height: 0;\n}\n.semi-table-align-center .semi-table-operate-wrapper {\n justify-content: center;\n}\n.semi-table-align-right .semi-table-operate-wrapper {\n justify-content: flex-end;\n}\n.semi-table-operate-wrapper {\n display: flex;\n justify-content: flex-start;\n}\n.semi-table-body {\n overflow: auto;\n width: 100%;\n box-sizing: border-box;\n}\n.semi-table-colgroup {\n display: table-column-group;\n}\n.semi-table-colgroup .semi-table-col {\n display: table-column;\n}\n.semi-table-colgroup .semi-table-column-expand, .semi-table-colgroup .semi-table-column-selection {\n width: 48px;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head {\n background-color: var(--semi-color-bg-1);\n color: var(--semi-color-text-2);\n font-weight: 600;\n text-align: left;\n border-bottom: 2px solid var(--semi-color-border);\n padding-left: 16px;\n padding-right: 16px;\n padding-top: 8px;\n padding-bottom: 8px;\n vertical-align: middle;\n overflow-wrap: break-word;\n position: relative;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-left, .semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-right {\n z-index: 101;\n position: sticky;\n background-color: var(--semi-color-bg-1);\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-left::before, .semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-right::before {\n background-color: var(--semi-color-bg-1);\n content: \"\";\n position: absolute;\n left: 0;\n top: 0;\n right: 0;\n bottom: 0;\n display: block;\n z-index: -1;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-left-last {\n border-right: 1px solid var(--semi-color-border);\n box-shadow: 3px 0 0 0 var(--semi-color-shadow);\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-left-last.resizing {\n border-right: 2px solid var(--semi-color-primary);\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-left-last.resizing .react-resizable-handle:hover {\n background-color: unset;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-right-first {\n border-left: 1px solid var(--semi-color-border);\n box-shadow: -3px 0 0 0 var(--semi-color-shadow);\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-right-first.resizing {\n border-right: 2px solid var(--semi-color-primary);\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-right-first.resizing .react-resizable-handle:hover {\n background-color: unset;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-right-first[x-type=column-scrollbar] {\n box-shadow: none;\n border-left: transparent;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-column-selection {\n text-align: center;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head[colspan]:not([colspan=\"1\"]) {\n text-align: center;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head .semi-table-header-column {\n display: inline-flex;\n align-items: center;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head-ellipsis {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.semi-table-thead > .semi-table-row > .semi-table-row-head-ellipsis .semi-table-row-head-title {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.semi-table-thead > .semi-table-row .react-resizable {\n position: relative;\n background-clip: padding-box;\n}\n.semi-table-thead > .semi-table-row .resizing {\n border-right: 2px solid var(--semi-color-primary);\n}\n.semi-table-thead > .semi-table-row .resizing .react-resizable-handle:hover {\n background-color: unset;\n}\n.semi-table-thead > .semi-table-row .react-resizable-handle {\n position: absolute;\n width: 9px;\n height: calc(100% - 4px * 2);\n background-color: var(--semi-color-border);\n bottom: 4px;\n right: -1px;\n cursor: col-resize;\n z-index: 0;\n}\n.semi-table-thead > .semi-table-row .react-resizable-handle:hover {\n background-color: var(--semi-color-primary);\n}\n.semi-table-tbody {\n display: table-row-group;\n}\n.semi-table-tbody > .semi-table-row {\n display: table-row;\n background-color: var(--semi-color-bg-1);\n}\n.semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell {\n background-image: linear-gradient(0deg, var(--semi-color-fill-0), var(--semi-color-fill-0));\n background-color: var(--semi-color-bg-0);\n}\n.semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell.semi-table-cell-fixed-left, .semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell.semi-table-cell-fixed-right {\n background-image: linear-gradient(0deg, var(--semi-color-bg-1), var(--semi-color-bg-1));\n}\n.semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell.semi-table-cell-fixed-left::before, .semi-table-tbody > .semi-table-row:hover > .semi-table-row-cell.semi-table-cell-fixed-right::before {\n background-color: var(--semi-color-fill-0);\n content: \"\";\n position: absolute;\n left: 0;\n top: 0;\n right: 0;\n bottom: 0;\n display: block;\n z-index: -1;\n}\n.semi-table-tbody > .semi-table-row > .semi-table-row-cell {\n display: table-cell;\n overflow-wrap: break-word;\n border-left: none;\n border-right: none;\n border-bottom: 1px solid var(--semi-color-border);\n padding: 16px;\n box-sizing: border-box;\n position: relative;\n vertical-align: middle;\n}\n.semi-table-tbody > .semi-table-row > .semi-table-row-cell.resizing {\n border-right: 2px solid var(--semi-color-primary);\n}\n.semi-table-tbody > .semi-table-row > .semi-table-row-cell-ellipsis {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.semi-table-tbody > .semi-table-row.semi-table-row-expand > .semi-table-row-cell {\n background-color: var(--semi-color-fill-0);\n}\n.semi-table-tbody > .semi-table-row.semi-table-row-hidden {\n display: none;\n}\n.semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-left, .semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-right {\n z-index: 101;\n position: sticky;\n background-color: var(--semi-color-bg-1);\n}\n.semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-left-last {\n border-right: 1px solid var(--semi-color-border);\n box-shadow: 3px 0 0 0 var(--semi-color-shadow);\n}\n.semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-right-first {\n border-left: 1px solid var(--semi-color-border);\n box-shadow: -3px 0 0 0 var(--semi-color-shadow);\n}\n.semi-table-tbody > .semi-table-row > .semi-table-cell-fixed > * {\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeOut) 0ms;\n}\n.semi-table-tbody > .semi-table-row > * {\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeOut) 0ms;\n}\n.semi-table-tbody > .semi-table-row-section {\n display: table-row;\n}\n.semi-table-tbody > .semi-table-row-section > .semi-table-row-cell {\n background-color: rgba(var(--semi-grey-0), 1);\n border-bottom: 1px solid var(--semi-color-border);\n}\n.semi-table-tbody > .semi-table-row-section > .semi-table-row-cell:not(.semi-table-column-selection) {\n padding: 10px 16px;\n}\n.semi-table-tbody > .semi-table-row-section .semi-table-section-inner {\n display: inline-flex;\n align-items: center;\n}\n.semi-table-virtualized .semi-table-tbody {\n display: block;\n}\n.semi-table-virtualized .semi-table-tbody > .semi-table-row {\n display: flex;\n}\n.semi-table-virtualized .semi-table-tbody > .semi-table-row > .semi-table-row-cell {\n word-wrap: unset;\n word-break: unset;\n white-space: nowrap;\n display: inline-flex;\n align-items: center;\n overflow: hidden;\n}\n.semi-table-virtualized .semi-table-tbody > .semi-table-row-section > .semi-table-row-cell {\n padding-top: 16px;\n padding-bottom: 16px;\n display: flex;\n}\n.semi-table-virtualized .semi-table-tbody > .semi-table-row-expand > .semi-table-row-cell {\n padding: 0;\n overflow: unset;\n}\n.semi-table-footer {\n background-color: var(--semi-color-fill-0);\n padding: 16px;\n margin: 0;\n position: relative;\n}\n.semi-table .semi-table-selection-wrap {\n display: inline-flex;\n vertical-align: bottom;\n}\n.semi-table .semi-table-selection-disabled {\n cursor: not-allowed;\n}\n.semi-table .semi-table-selection-disabled > .semi-checkbox {\n pointer-events: none;\n}\n.semi-table .semi-table-column-hidden {\n display: none;\n}\n.semi-table .semi-table-column-selection {\n text-align: center;\n}\n.semi-table .semi-table-column-selection .semi-checkbox-inner-display .semi-icon {\n left: 0;\n top: 0;\n}\n.semi-table .semi-table-column-expand .semi-table-expand-icon {\n transform: translateY(2px);\n}\n.semi-table .semi-table-column-expand .semi-table-expand-icon:last-child {\n margin-right: 0;\n}\n.semi-table .semi-table-column-sorter {\n display: inline-block;\n width: 16px;\n height: 16px;\n vertical-align: middle;\n text-align: center;\n}\n.semi-table .semi-table-column-sorter-wrapper {\n display: flex;\n gap: 4px;\n align-items: center;\n cursor: pointer;\n overflow: hidden;\n}\n.semi-table .semi-table-column-sorter-up, .semi-table .semi-table-column-sorter-down {\n height: 0;\n display: block;\n color: var(--semi-color-text-2);\n}\n.semi-table .semi-table-column-sorter-up:hover .anticon, .semi-table .semi-table-column-sorter-down:hover .anticon {\n color: var(--semi-color-text-2);\n}\n.semi-table .semi-table-column-sorter-up svg, .semi-table .semi-table-column-sorter-down svg {\n width: 16px;\n height: 16px;\n}\n.semi-table .semi-table-column-sorter-up.on .semi-icon-caretup,\n.semi-table .semi-table-column-sorter-up.on .semi-icon-caretdown, .semi-table .semi-table-column-sorter-down.on .semi-icon-caretup,\n.semi-table .semi-table-column-sorter-down.on .semi-icon-caretdown {\n color: var(--semi-color-primary);\n}\n.semi-table .semi-table-column-filter {\n margin-left: 4px;\n display: inline-flex;\n cursor: pointer;\n color: var(--semi-color-text-2);\n align-items: center;\n}\n.semi-table .semi-table-column-filter svg {\n width: 16px;\n height: 16px;\n}\n.semi-table .semi-table-column-filter.on {\n color: var(--semi-color-primary);\n}\n.semi-table-bordered .semi-table-title {\n padding-left: 16px;\n padding-right: 16px;\n border-top: 1px solid var(--semi-color-border);\n border-right: 1px solid var(--semi-color-border);\n border-left: 1px solid var(--semi-color-border);\n}\n.semi-table-bordered .semi-table-container {\n border: 1px solid var(--semi-color-border);\n border-right: 0;\n border-bottom: 0;\n}\n.semi-table-bordered .semi-table-header::-webkit-scrollbar {\n border-right: 1px solid var(--semi-color-border);\n}\n.semi-table-bordered .semi-table-footer {\n border-left: 1px solid var(--semi-color-border);\n border-right: 1px solid var(--semi-color-border);\n border-bottom: 1px solid var(--semi-color-border);\n}\n.semi-table-bordered .semi-table-thead > .semi-table-row > .semi-table-row-head .react-resizable-handle {\n background-color: transparent;\n}\n.semi-table-bordered .semi-table-thead > .semi-table-row > .semi-table-row-head,\n.semi-table-bordered .semi-table-tbody > .semi-table-row > .semi-table-row-cell {\n border-right: 1px solid var(--semi-color-border);\n}\n.semi-table-bordered .semi-table-placeholder {\n border-right: 1px solid var(--semi-color-border);\n}\n.semi-table-placeholder {\n position: sticky;\n left: 0px;\n z-index: 1;\n padding: 16px 12px;\n color: var(--semi-color-text-2);\n font-size: 14px;\n text-align: center;\n background: transparent;\n border-bottom: 1px solid var(--semi-color-border);\n}\n.semi-table-fixed {\n table-layout: fixed;\n min-width: 100%;\n}\n.semi-table-fixed > .semi-table-tbody > .semi-table-row-expand > .semi-table-row-cell > .semi-table-expand-inner, .semi-table-fixed > .semi-table-tbody > .semi-table-row-section > .semi-table-row-cell > .semi-table-section-inner {\n position: sticky;\n overflow: auto;\n left: 0;\n margin-left: -16px;\n margin-right: -16px;\n padding-left: 16px;\n padding-right: 16px;\n height: 100%;\n display: flex;\n align-items: center;\n}\n.semi-table-fixed-header table {\n table-layout: fixed;\n}\n.semi-table-scroll-position-left .semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-left-last,\n.semi-table-scroll-position-left .semi-table-thead > .semi-table-row > .semi-table-cell-fixed-left-last {\n box-shadow: none;\n}\n.semi-table-scroll-position-right .semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-right-first,\n.semi-table-scroll-position-right .semi-table-thead > .semi-table-row > .semi-table-cell-fixed-right-first {\n box-shadow: none;\n}\n.semi-table-pagination-outer {\n color: var(--semi-color-text-2);\n min-height: 60px;\n}\n\n.semi-table-expand-icon {\n color: var(--semi-color-text-2);\n cursor: pointer;\n display: inline-flex;\n align-items: center;\n user-select: none;\n background: transparent;\n position: relative;\n margin-right: 8px;\n}\n.semi-table-expand-icon-cell {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n.semi-table-expand-icon .semi-table-expandedIcon-show {\n transition: transform 150ms cubic-bezier(0.62, 0.05, 0.36, 0.95);\n transform: rotate(90deg);\n}\n.semi-table-expand-icon .semi-table-expandedIcon-hide {\n transition: transform 150ms cubic-bezier(0.62, 0.05, 0.36, 0.95);\n transform: rotate(0deg);\n}\n\n.semi-table-column-filter-dropdown .semi-dropdown-menu {\n max-height: 290px;\n overflow-y: auto;\n}\n\n.semi-table-wrapper-rtl .semi-table {\n direction: rtl;\n text-align: right;\n}\n.semi-table-wrapper-rtl .semi-table-align-left .semi-table-operate-wrapper {\n justify-content: flex-end;\n}\n.semi-table-wrapper-rtl .semi-table-align-right .semi-table-operate-wrapper {\n justify-content: flex-start;\n}\n.semi-table-wrapper-rtl .semi-table-thead > .semi-table-row > .semi-table-row-head {\n text-align: right;\n}\n.semi-table-wrapper-rtl .semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-left-last {\n border-right: 0;\n border-left: 1px solid var(--semi-color-border);\n}\n.semi-table-wrapper-rtl .semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-left-last.resizing {\n border-left: 2px solid var(--semi-color-primary);\n}\n.semi-table-wrapper-rtl .semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-right-first {\n border-left: 0;\n border-right: 1px solid var(--semi-color-border);\n}\n.semi-table-wrapper-rtl .semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-right-first.resizing {\n border-left: 2px solid var(--semi-color-primary);\n}\n.semi-table-wrapper-rtl .semi-table-thead > .semi-table-row > .semi-table-row-head.semi-table-cell-fixed-right-first[x-type=column-scrollbar] {\n box-shadow: none;\n border-right: transparent;\n}\n.semi-table-wrapper-rtl .semi-table-thead > .semi-table-row .resizing {\n border-left: 2px solid var(--semi-color-primary);\n}\n.semi-table-wrapper-rtl .semi-table-thead > .semi-table-row .react-resizable-handle {\n right: auto;\n left: -1px;\n}\n.semi-table-wrapper-rtl .semi-table-tbody {\n display: table-row-group;\n}\n.semi-table-wrapper-rtl .semi-table-tbody > .semi-table-row > .semi-table-row-cell.resizing {\n border-right: 0;\n border-left: 2px solid var(--semi-color-primary);\n}\n.semi-table-wrapper-rtl .semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-left-last {\n border-right: 0;\n border-left: 1px solid var(--semi-color-border);\n}\n.semi-table-wrapper-rtl .semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-right-first {\n border-left: 0;\n border-right: 1px solid var(--semi-color-border);\n}\n.semi-table-wrapper-rtl .semi-table .semi-table-column-selection .semi-checkbox-inner-display .semi-icon {\n left: auto;\n right: 0;\n}\n.semi-table-wrapper-rtl .semi-table .semi-table-column-expand .semi-table-expand-icon {\n transform: scaleX(-1) translateY(2px);\n}\n.semi-table-wrapper-rtl .semi-table .semi-table-column-expand .semi-table-expand-icon:last-child {\n margin-right: auto;\n margin-left: 0;\n}\n.semi-table-wrapper-rtl .semi-table .semi-table-column-sorter {\n margin-left: 0;\n margin-right: 4px;\n}\n.semi-table-wrapper-rtl .semi-table .semi-table-column-filter {\n margin-left: 0;\n margin-right: 4px;\n}\n.semi-table-wrapper-rtl .semi-table-bordered .semi-table-container {\n border-left: 0;\n border-right: 1px solid var(--semi-color-border);\n}\n.semi-table-wrapper-rtl .semi-table-bordered .semi-table-thead > .semi-table-row > .semi-table-row-head,\n.semi-table-wrapper-rtl .semi-table-bordered .semi-table-tbody > .semi-table-row > .semi-table-row-cell {\n border-right: 0;\n border-left: 1px solid var(--semi-color-border);\n}\n.semi-table-wrapper-rtl .semi-table-bordered .semi-table-placeholder {\n border-left: 1px solid var(--semi-color-border);\n border-right: 0;\n}\n.semi-table-wrapper-rtl .semi-table-bordered .semi-table-header::-webkit-scrollbar {\n border-right: 0;\n border-left: 1px solid var(--semi-color-border);\n}\n.semi-table-wrapper-rtl .semi-table-fixed > .semi-table-tbody > .semi-table-row-expand > .semi-table-row-cell > .semi-table-expand-inner, .semi-table-wrapper-rtl .semi-table-fixed > .semi-table-tbody > .semi-table-row-section > .semi-table-row-cell > .semi-table-section-inner {\n left: auto;\n right: 0;\n margin-right: -16px;\n margin-left: -16px;\n padding-right: 16px;\n padding-left: 16px;\n}\n.semi-table-wrapper-rtl .semi-table-scroll-position-left .semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-left-last,\n.semi-table-wrapper-rtl .semi-table-scroll-position-left .semi-table-thead > .semi-table-row > .semi-table-cell-fixed-left-last {\n box-shadow: 3px 0 0 0 var(--semi-color-shadow);\n}\n.semi-table-wrapper-rtl .semi-table-scroll-position-left .semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-right-first,\n.semi-table-wrapper-rtl .semi-table-scroll-position-left .semi-table-thead > .semi-table-row > .semi-table-cell-fixed-right-first {\n box-shadow: none;\n}\n.semi-table-wrapper-rtl .semi-table-scroll-position-right .semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-left-last,\n.semi-table-wrapper-rtl .semi-table-scroll-position-right .semi-table-thead > .semi-table-row > .semi-table-cell-fixed-left-last {\n box-shadow: none;\n}\n.semi-table-wrapper-rtl .semi-table-scroll-position-right .semi-table-tbody > .semi-table-row > .semi-table-cell-fixed-right-first,\n.semi-table-wrapper-rtl .semi-table-scroll-position-right .semi-table-thead > .semi-table-row > .semi-table-cell-fixed-right-first {\n box-shadow: -3px 0 0 0 var(--semi-color-shadow);\n}\n.semi-table-wrapper-rtl .semi-table-expand-icon {\n margin-right: auto;\n margin-left: 8px;\n transform: scaleX(-1) translateY(2px);\n}\n.semi-table-wrapper-rtl .semi-spin {\n direction: rtl;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-checkbox {\n box-sizing: border-box;\n position: relative;\n display: flex;\n align-items: flex-start;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n cursor: pointer;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n column-gap: 8px;\n}\n.semi-checkbox input[type=checkbox] {\n position: absolute;\n left: 0;\n top: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n opacity: 0;\n}\n.semi-checkbox-content {\n flex: 1;\n display: flex;\n flex-direction: column;\n row-gap: 4px;\n}\n.semi-checkbox-addon {\n display: flex;\n flex: 1;\n align-items: center;\n color: var(--semi-color-text-0);\n line-height: 20px;\n user-select: none;\n}\n.semi-checkbox:hover .semi-checkbox-inner-display {\n background: var(--semi-color-fill-0);\n box-shadow: inset 0 0 0 1px var(--semi-color-focus-border);\n}\n.semi-checkbox:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display {\n background: var(--semi-color-primary-hover);\n box-shadow: none;\n}\n.semi-checkbox:active .semi-checkbox-inner-display {\n background: var(--semi-color-fill-1);\n}\n.semi-checkbox:active .semi-checkbox-inner-checked .semi-checkbox-inner-display {\n background: var(--semi-color-primary-active);\n box-shadow: none;\n}\n.semi-checkbox.semi-checkbox-disabled:hover .semi-checkbox-inner-display, .semi-checkbox.semi-checkbox-disabled:active .semi-checkbox-inner-display {\n background: var(--semi-color-disabled-fill);\n box-shadow: inset 0 0 0 1px var(--semi-color-border);\n}\n.semi-checkbox.semi-checkbox-disabled:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display, .semi-checkbox.semi-checkbox-disabled:active .semi-checkbox-inner-checked .semi-checkbox-inner-display {\n background: var(--semi-color-primary-disabled);\n box-shadow: none;\n}\n.semi-checkbox-inner {\n position: relative;\n display: flex;\n align-items: center;\n width: 16px;\n height: 20px;\n user-select: none;\n cursor: pointer;\n}\n.semi-checkbox-inner-display {\n box-sizing: border-box;\n position: relative;\n width: 16px;\n height: 16px;\n margin: 0;\n background: transparent;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n box-shadow: inset 0 0 0 1px var(--semi-color-text-3);\n border-radius: var(--semi-border-radius-extra-small);\n}\n.semi-checkbox-inner-display .semi-icon {\n font-size: 16px;\n}\n.semi-checkbox-inner-checked .semi-checkbox-inner-display {\n background: var(--semi-color-primary);\n color: var(--semi-color-white);\n box-shadow: inset 0 0 0 1px var(--semi-color-primary);\n border-radius: var(--semi-border-radius-extra-small);\n}\n.semi-checkbox-inner-checked > .semi-checkbox-addon {\n color: var(--semi-color-text-0);\n}\n.semi-checkbox:hover .semi-checkbox-inner-display {\n background: var(--semi-color-fill-0);\n}\n.semi-checkbox:hover.semi-checkbox-indeterminate .semi-checkbox-inner-display {\n background: var(--semi-color-primary-hover);\n box-shadow: none;\n color: var(--semi-color-white);\n}\n.semi-checkbox:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display {\n background: var(--semi-color-primary-hover);\n border-color: var(--semi-color-primary-hover);\n color: var(--semi-color-white);\n}\n.semi-checkbox:hover.semi-checkbox-cardType.semi-checkbox-unChecked.semi-checkbox-cardType_unDisabled .semi-checkbox-inner-display {\n background: var(--semi-color-white);\n}\n.semi-checkbox:active .semi-checkbox-inner-display {\n background: var(--semi-color-fill-1);\n}\n.semi-checkbox:active.semi-checkbox-indeterminate .semi-checkbox-inner-display {\n background: var(--semi-color-primary-active);\n border-color: var(--semi-color-primary-active);\n color: var(--semi-color-white);\n box-shadow: none;\n}\n.semi-checkbox:active .semi-checkbox-inner-checked .semi-checkbox-inner-display {\n background: var(--semi-color-primary-active);\n border-color: var(--semi-color-primary-active);\n color: var(--semi-color-white);\n}\n.semi-checkbox:active.semi-checkbox-cardType.semi-checkbox-unChecked.semi-checkbox-cardType_unDisabled .semi-checkbox-inner-display {\n background: var(--semi-color-white);\n}\n.semi-checkbox-cardType {\n flex-wrap: nowrap;\n align-items: flex-start;\n border-radius: 3px;\n padding: 12px 16px;\n background: transparent;\n border: 1px solid transparent;\n}\n.semi-checkbox-cardType .semi-checkbox-inner {\n position: relative;\n flex-shrink: 0;\n}\n.semi-checkbox-cardType .semi-checkbox-inner-display {\n background: var(--semi-color-white);\n}\n.semi-checkbox-cardType .semi-checkbox-inner-pureCardType {\n opacity: 0;\n width: 0;\n}\n.semi-checkbox-cardType .semi-checkbox-addon {\n font-weight: 600;\n font-size: 14px;\n line-height: 20px;\n color: var(--semi-color-text-0);\n}\n.semi-checkbox-cardType .semi-checkbox-extra {\n font-weight: normal;\n font-size: 14px;\n line-height: 20px;\n color: var(--semi-color-text-2);\n}\n.semi-checkbox-cardType .semi-checkbox-extra.semi-checkbox-cardType_extra_noChildren {\n margin-top: 0;\n}\n.semi-checkbox-cardType:hover {\n background: var(--semi-color-fill-0);\n}\n.semi-checkbox-cardType:active {\n background: var(--semi-color-fill-1);\n}\n.semi-checkbox-cardType_checked {\n background: var(--semi-color-primary-light-default);\n border: 1px solid var(--semi-color-primary);\n}\n.semi-checkbox-cardType_checked:hover {\n background: var(--semi-color-primary-light-default);\n border-color: var(--semi-color-primary-hover);\n}\n.semi-checkbox-cardType_checked:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display {\n box-shadow: none;\n}\n.semi-checkbox-cardType_checked:active {\n background: var(--semi-color-primary-light-default);\n border-color: var(--semi-color-primary-active);\n}\n.semi-checkbox-cardType_disabled:active {\n background: transparent;\n}\n.semi-checkbox-cardType_disabled:hover {\n background: transparent;\n}\n.semi-checkbox-cardType_checked_disabled.semi-checkbox-cardType {\n background: var(--semi-color-primary-light-default);\n border: 1px solid var(--semi-color-primary-disabled);\n}\n.semi-checkbox-cardType_checked_disabled.semi-checkbox-cardType:hover .semi-checkbox-inner-checked .semi-checkbox-inner-display {\n box-shadow: none;\n}\n.semi-checkbox-indeterminate .semi-checkbox-inner-display, .semi-checkbox-checked .semi-checkbox-inner-display {\n background: var(--semi-color-primary);\n color: var(--semi-color-white);\n box-shadow: inset 0 0 0 1px var(--semi-color-primary);\n border-radius: var(--semi-border-radius-extra-small);\n}\n.semi-checkbox-indeterminate .semi-checkbox-inner-display:hover, .semi-checkbox-checked .semi-checkbox-inner-display:hover {\n background: var(--semi-color-primary-hover);\n border-color: var(--semi-color-primary-hover);\n color: var(--semi-color-white);\n}\n.semi-checkbox-indeterminate .semi-checkbox-inner-display:active, .semi-checkbox-checked .semi-checkbox-inner-display:active {\n background: var(--semi-color-primary-active);\n border-color: var(--semi-color-primary-active);\n color: var(--semi-color-white);\n}\n.semi-checkbox-indeterminate .semi-checkbox-inner-addon, .semi-checkbox-checked .semi-checkbox-inner-addon {\n color: var(--semi-color-text-0);\n}\n.semi-checkbox-disabled {\n cursor: not-allowed;\n}\n.semi-checkbox-disabled .semi-checkbox-inner {\n cursor: not-allowed;\n}\n.semi-checkbox-disabled .semi-checkbox-inner-display {\n color: var(--semi-color-white);\n background: var(--semi-color-disabled-fill);\n box-shadow: inset 0 0 0 1px var(--semi-color-border);\n}\n.semi-checkbox-disabled .semi-checkbox-inner-display:hover {\n color: var(--semi-color-white);\n background: transparent;\n}\n.semi-checkbox-disabled .semi-checkbox-inner-checked {\n color: var(--semi-color-white);\n}\n.semi-checkbox-disabled .semi-checkbox-inner-checked .semi-checkbox-inner-display {\n opacity: 0.75;\n background: var(--semi-color-primary-disabled);\n box-shadow: inset 0 0 0 1px var(--semi-color-primary-disabled);\n}\n.semi-checkbox-disabled .semi-checkbox-inner-checked .semi-checkbox-inner-display:hover {\n color: var(--semi-color-white);\n background: var(--semi-color-primary-disabled);\n}\n.semi-checkbox-disabled .semi-checkbox-addon {\n color: var(--semi-color-disabled-text);\n}\n.semi-checkbox-disabled .semi-checkbox-extra {\n color: var(--semi-color-disabled-text);\n}\n.semi-checkbox.semi-checkbox-disabled.semi-checkbox-indeterminate .semi-checkbox-inner-display {\n opacity: 0.75;\n background: var(--semi-color-primary-disabled);\n box-shadow: inset 0 0 0 1px var(--semi-color-primary-disabled);\n color: var(--semi-color-white);\n}\n.semi-checkbox-extra {\n flex-shrink: 0;\n flex-grow: 1;\n flex-basis: 100%;\n box-sizing: border-box;\n color: var(--semi-color-text-2);\n}\n.semi-checkbox-focus {\n outline: 2px solid var(--semi-color-primary-light-active);\n}\n.semi-checkbox-focus-border {\n box-shadow: inset 0 0 0 1px var(--semi-color-focus-border);\n}\n\n.semi-checkboxGroup {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n line-height: 14px;\n}\n.semi-checkboxGroup .semi-checkbox.semi-checkbox-vertical {\n margin-bottom: 16px;\n}\n.semi-checkboxGroup-horizontal {\n display: flex;\n flex-wrap: wrap;\n gap: 16px;\n}\n.semi-checkboxGroup-horizontal .semi-checkbox {\n display: inline-flex;\n}\n.semi-checkboxGroup-vertical {\n display: flex;\n flex-direction: column;\n row-gap: 12px;\n}\n.semi-checkboxGroup-vertical-cardType {\n row-gap: 16px;\n}\n.semi-checkboxGroup-vertical-pureCardType .semi-checkbox {\n column-gap: 0;\n}\n\n.semi-rtl .semi-checkbox,\n.semi-portal-rtl .semi-checkbox {\n direction: rtl;\n}\n.semi-rtl .semi-checkbox input[type=checkbox],\n.semi-portal-rtl .semi-checkbox input[type=checkbox] {\n left: auto;\n right: 0;\n}\n.semi-rtl .semi-checkboxGroup,\n.semi-portal-rtl .semi-checkboxGroup {\n direction: rtl;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-radio {\n box-sizing: border-box;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n position: relative;\n display: inline-flex;\n column-gap: 8px;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n min-height: 20px;\n min-width: 16px;\n cursor: pointer;\n vertical-align: bottom;\n text-align: left;\n}\n.semi-radio.semi-radio-vertical {\n display: block;\n}\n.semi-radio input[type=checkbox],\n.semi-radio input[type=radio] {\n position: absolute;\n left: 0;\n top: 0;\n opacity: 0;\n width: 100%;\n height: 100%;\n margin: 0;\n cursor: pointer;\n}\n.semi-radio:hover .semi-radio-inner-display {\n background: var(--semi-color-fill-0);\n border: solid 1px var(--semi-color-focus-border);\n}\n.semi-radio:hover.semi-radio-cardRadioGroup .semi-radio-inner-display {\n background: var(--semi-color-white);\n}\n.semi-radio:hover .semi-radio-inner-checked .semi-radio-inner-display {\n background: var(--semi-color-primary-hover);\n border-color: var(--semi-color-primary-hover);\n}\n.semi-radio:active .semi-radio-inner-display {\n background: var(--semi-color-fill-1);\n}\n.semi-radio:active.semi-radio-cardRadioGroup .semi-radio-inner-display {\n background: var(--semi-color-white);\n}\n.semi-radio:active .semi-radio-inner-checked .semi-radio-inner-display {\n background: var(--semi-color-primary-active);\n border-color: var(--semi-color-primary-active);\n}\n.semi-radio-buttonRadioComponent {\n padding: 4px;\n background: var(--semi-color-fill-0);\n border-radius: var(--semi-border-radius-small);\n}\n.semi-radio-buttonRadioGroup {\n position: relative;\n padding: 4px;\n border-radius: var(--semi-border-radius-small);\n line-height: 16px;\n}\n.semi-radio-buttonRadioGroup:not(:last-child) {\n padding-right: 0;\n}\n.semi-radio-buttonRadioGroup-small {\n padding: 2px 4px;\n line-height: 16px;\n}\n.semi-radio-buttonRadioGroup-large {\n padding: 4px;\n line-height: 20px;\n}\n.semi-radio-cardRadioGroup {\n flex-wrap: nowrap;\n border-radius: var(--semi-border-radius-small);\n padding: 12px 16px;\n background: transparent;\n border: 1px solid transparent;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n}\n.semi-radio-cardRadioGroup .semi-radio-inner {\n flex-shrink: 0;\n}\n.semi-radio-cardRadioGroup .semi-radio-inner-display {\n background: var(--semi-color-white);\n}\n.semi-radio-cardRadioGroup .semi-radio-addon {\n font-weight: 600;\n font-size: 14px;\n line-height: 20px;\n color: var(--semi-color-text-0);\n}\n.semi-radio-cardRadioGroup .semi-radio-extra {\n font-weight: normal;\n font-size: 14px;\n line-height: 20px;\n color: var(--semi-color-text-2);\n padding-left: 0;\n}\n.semi-radio-cardRadioGroup:active {\n background: var(--semi-color-fill-1);\n}\n.semi-radio-cardRadioGroup_checked {\n background: var(--semi-color-primary-light-default);\n border: 1px solid var(--semi-color-primary);\n}\n.semi-radio-cardRadioGroup_checked:hover {\n border: 1px solid var(--semi-color-primary-hover);\n}\n.semi-radio-cardRadioGroup_checked:hover .semi-radio-inner-checked .semi-radio-inner-display {\n border-color: var(--semi-color-primary-hover);\n}\n.semi-radio-cardRadioGroup_checked:active {\n background: var(--semi-color-primary-light-default);\n border: 1px solid var(--semi-color-primary-active);\n}\n.semi-radio-cardRadioGroup_checked:active .semi-radio-inner-checked .semi-radio-inner-display {\n border-color: var(--semi-color-primary-active);\n}\n.semi-radio-cardRadioGroup_checked:active .semi-radio-inner-checked:hover .semi-radio-inner-display {\n background: var(--semi-color-primary-active);\n}\n.semi-radio-cardRadioGroup_hover {\n background: var(--semi-color-fill-0);\n}\n.semi-radio-cardRadioGroup_disabled:active {\n background: transparent;\n}\n.semi-radio-cardRadioGroup_checked_disabled.semi-radio-cardRadioGroup {\n background: var(--semi-color-primary-light-default);\n border: 1px solid var(--semi-color-primary-disabled);\n}\n.semi-radio-cardRadioGroup_checked_disabled.semi-radio-cardRadioGroup .semi-radio-inner-checked .semi-radio-inner-display {\n border-color: var(--semi-color-primary-disabled);\n}\n.semi-radio-cardRadioGroup_checked_disabled.semi-radio-cardRadioGroup:hover .semi-radio-inner-checked .semi-radio-inner-display {\n border-color: var(--semi-color-primary-disabled);\n}\n.semi-radio.semi-radio-disabled:hover .semi-radio-inner-display, .semi-radio.semi-radio-disabled:active .semi-radio-inner-display {\n background: var(--semi-color-disabled-fill);\n border: solid 1px var(--semi-color-border);\n}\n.semi-radio.semi-radio-disabled:hover .semi-radio-inner-checked .semi-radio-inner-display, .semi-radio.semi-radio-disabled:active .semi-radio-inner-checked .semi-radio-inner-display {\n background: var(--semi-color-primary-disabled);\n border-color: var(--semi-color-primary-disabled);\n}\n.semi-radio-inner {\n display: inline-flex;\n margin-top: 2px;\n position: relative;\n width: 16px;\n height: 16px;\n vertical-align: sub;\n user-select: none;\n}\n.semi-radio-inner-display {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n width: 16px;\n height: 16px;\n border: solid 1px var(--semi-color-text-3);\n border-radius: 16px;\n background: transparent;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n}\n.semi-radio-inner-display .semi-icon {\n width: 100%;\n height: 100%;\n font-size: 14px;\n}\n.semi-radio-content {\n display: flex;\n flex-direction: column;\n row-gap: 4px;\n}\n.semi-radio:hover .semi-radio-inner-display {\n background: var(--semi-color-fill-0);\n}\n.semi-radio:active .semi-radio-inner-display {\n background: var(--semi-color-fill-1);\n}\n.semi-radio-addon {\n user-select: none;\n color: var(--semi-color-text-0);\n display: inline-flex;\n align-items: center;\n}\n.semi-radio-addon-buttonRadio {\n text-align: center;\n border-radius: var(--semi-border-radius-small);\n font-weight: 600;\n color: var(--semi-color-text-1);\n font-size: 12px;\n padding: 4px 16px;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n}\n.semi-radio-addon-buttonRadio-hover {\n font-weight: 600;\n background: var(--semi-color-fill-1);\n}\n.semi-radio-addon-buttonRadio-checked {\n font-weight: 600;\n background: var(--semi-color-bg-3);\n color: var(--semi-color-primary);\n}\n.semi-radio-addon-buttonRadio-disabled {\n cursor: not-allowed;\n color: var(--semi-color-disabled-text);\n}\n.semi-radio-addon-buttonRadio-small {\n font-size: 12px;\n padding: 2px 16px;\n}\n.semi-radio-addon-buttonRadio-large {\n font-size: 14px;\n padding: 6px 24px;\n}\n.semi-radio .semi-radio-inner-checked:hover .semi-radio-inner-display {\n background: var(--semi-color-primary-hover);\n}\n.semi-radio .semi-radio-inner-checked:active .semi-radio-inner-display {\n background: var(--semi-color-primary-active);\n}\n.semi-radio .semi-radio-inner-checked .semi-radio-inner-display {\n border: solid 1px var(--semi-color-primary);\n background: var(--semi-color-primary);\n color: rgba(var(--semi-white), 1);\n border-radius: 16px;\n}\n.semi-radio .semi-radio-inner-checked > .semi-radio-addon {\n color: var(--semi-color-text-0);\n}\n.semi-radio .semi-radio-inner-buttonRadio,\n.semi-radio .semi-radio-inner-pureCardRadio {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n margin-top: 0;\n z-index: -1;\n opacity: 0;\n}\n.semi-radio-disabled, .semi-radio-disabled:hover {\n cursor: not-allowed;\n}\n.semi-radio-disabled .semi-radio-inner {\n cursor: not-allowed;\n}\n.semi-radio-disabled .semi-radio-inner-display {\n opacity: 0.75;\n background: var(--semi-color-disabled-fill);\n border-color: var(--semi-color-border);\n}\n.semi-radio-disabled .semi-radio-inner-display:hover {\n background: transparent;\n}\n.semi-radio-disabled .semi-radio-inner-checked .semi-radio-inner-display {\n background: var(--semi-color-primary-disabled);\n border-color: var(--semi-color-primary-disabled);\n}\n.semi-radio-disabled .semi-radio-inner-checked .semi-radio-inner-display:hover {\n background: var(--semi-color-primary-disabled);\n border-color: var(--semi-color-primary-disabled);\n}\n.semi-radio-disabled .semi-radio-addon {\n color: var(--semi-color-disabled-text);\n}\n.semi-radio-disabled .semi-radio-extra {\n color: var(--semi-color-disabled-text);\n}\n.semi-radio-extra {\n color: var(--semi-color-text-2);\n box-sizing: border-box;\n}\n.semi-radio-focus {\n outline: 2px solid var(--semi-color-primary-light-active);\n}\n.semi-radio-focus-border {\n border: solid 1px var(--semi-color-focus-border);\n}\n\n.semi-radioGroup {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n.semi-radioGroup-vertical {\n display: flex;\n flex-direction: column;\n row-gap: 12px;\n}\n.semi-radioGroup-vertical-default .semi-radio {\n display: flex;\n}\n.semi-radioGroup-vertical-card .semi-radio {\n display: flex;\n}\n.semi-radioGroup-horizontal {\n display: inline-flex;\n flex-wrap: wrap;\n vertical-align: bottom;\n gap: 16px;\n}\n.semi-radioGroup-buttonRadio {\n display: inline-block;\n background: var(--semi-color-fill-0);\n border-radius: var(--semi-border-radius-small);\n vertical-align: middle;\n}\n\n.semi-rtl .semi-radio,\n.semi-portal-rtl .semi-radio {\n direction: rtl;\n}\n.semi-rtl .semi-radio input[type=checkbox],\n.semi-rtl .semi-radio input[type=radio],\n.semi-portal-rtl .semi-radio input[type=checkbox],\n.semi-portal-rtl .semi-radio input[type=radio] {\n left: auto;\n right: 0;\n}\n.semi-rtl .semi-radio-buttonRadioGroup:not(:last-child),\n.semi-portal-rtl .semi-radio-buttonRadioGroup:not(:last-child) {\n padding-left: 0;\n}\n.semi-rtl .semi-radioGroup,\n.semi-portal-rtl .semi-radioGroup {\n direction: rtl;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-page {\n display: flex;\n list-style: none;\n padding: 0;\n align-items: center;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n margin-block-start: 0;\n margin-block-end: 0;\n}\n.semi-page-small {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 400;\n color: var(--semi-color-text-2);\n padding: 0 0;\n}\n.semi-page-disabled {\n cursor: not-allowed;\n}\n.semi-page-disabled .semi-page-total {\n color: var(--semi-color-disabled-text);\n}\n.semi-page-item {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n min-width: 32px;\n border: 0px solid transparent;\n cursor: pointer;\n user-select: none;\n height: 32px;\n margin-left: 4px;\n margin-right: 4px;\n font-weight: 400;\n color: var(--semi-color-text-0);\n border-radius: var(--semi-border-radius-small);\n text-align: center;\n line-height: 32px;\n display: flex;\n align-items: center;\n justify-content: center;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-page-item:hover {\n border-color: transparent;\n background-color: var(--semi-color-fill-0);\n color: var(--semi-color-text-0);\n}\n.semi-page-item-rest-opening {\n background-color: var(--semi-color-fill-0);\n color: var(--semi-color-text-0);\n}\n.semi-page-item:active {\n border-color: transparent;\n background-color: var(--semi-color-fill-1);\n color: var(--semi-color-text-0);\n}\n.semi-page-item-active {\n border-color: transparent;\n color: var(--semi-color-primary);\n font-weight: 600;\n background-color: var(--semi-color-primary-light-default);\n}\n.semi-page-item-active:hover {\n border-color: transparent;\n color: var(--semi-color-primary);\n background-color: var(--semi-color-primary-light-default);\n}\n.semi-page-item-disabled {\n border-color: transparent;\n color: var(--semi-color-disabled-text);\n background-color: transparent;\n cursor: not-allowed;\n}\n.semi-page-item-disabled:hover {\n background-color: transparent;\n}\n.semi-page-item-small {\n min-width: 44px;\n margin: 0;\n}\n.semi-page-item-all-disabled {\n border-color: transparent;\n color: var(--semi-color-disabled-text);\n background-color: transparent;\n cursor: not-allowed;\n}\n.semi-page-item-all-disabled:hover {\n background-color: transparent;\n color: var(--semi-color-disabled-text);\n}\n.semi-page-item-all-disabled-active {\n background-color: var(--semi-color-disabled-fill);\n font-weight: 600;\n}\n.semi-page-item-all-disabled-active:hover {\n background-color: var(--semi-color-disabled-fill);\n}\n.semi-page-total {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n color: var(--semi-color-text-2);\n}\n.semi-page-prev, .semi-page-next {\n color: var(--semi-color-tertiary);\n cursor: pointer;\n}\n.semi-page-prev.semi-page-item-disabled, .semi-page-next.semi-page-item-disabled {\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-page-quickjump {\n margin-left: 24px;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n display: flex;\n justify-content: center;\n align-items: center;\n flex-shrink: 0;\n color: var(--semi-color-text-0);\n}\n.semi-page-quickjump-input-number {\n max-width: 50px;\n margin-left: 4px;\n margin-right: 4px;\n}\n.semi-page-quickjump-disabled {\n color: var(--semi-color-disabled-text);\n}\n.semi-page .semi-select {\n user-select: none;\n}\n\n.semi-select-dropdown {\n user-select: none;\n}\n\n.semi-page-rest-list {\n padding-top: 4px;\n padding-bottom: 4px;\n}\n.semi-page-rest-list > div {\n position: relative;\n}\n.semi-page-rest-item {\n height: 32px;\n line-height: 32px;\n display: flex;\n justify-content: center;\n box-sizing: border-box;\n cursor: pointer;\n}\n.semi-page-rest-item:hover {\n background-color: var(--semi-color-fill-0);\n}\n.semi-page-rest-item:active {\n background-color: var(--semi-color-fill-1);\n}\n\n.semi-rtl .semi-page,\n.semi-portal-rtl .semi-page {\n direction: rtl;\n}\n.semi-rtl .semi-page-item,\n.semi-portal-rtl .semi-page-item {\n margin-right: 4px;\n margin-left: 4px;\n}\n.semi-rtl .semi-page-prev, .semi-rtl .semi-page-next,\n.semi-portal-rtl .semi-page-prev,\n.semi-portal-rtl .semi-page-next {\n transform: scaleX(-1);\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-select-option {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n word-break: break-all;\n padding-left: 12px;\n padding-right: 12px;\n padding-top: 8px;\n padding-bottom: 8px;\n color: var(--semi-color-text-0);\n border-radius: 0px;\n position: relative;\n display: flex;\n flex-wrap: nowrap;\n align-items: center;\n cursor: pointer;\n box-sizing: border-box;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n}\n.semi-select-option-icon {\n width: 12px;\n color: transparent;\n margin-right: 8px;\n display: flex;\n justify-content: center;\n align-content: center;\n}\n.semi-select-option-text {\n display: flex;\n flex-wrap: wrap;\n white-space: pre;\n}\n.semi-select-option-keyword {\n color: var(--semi-color-primary);\n background-color: inherit;\n font-weight: 600;\n}\n.semi-select-option:active {\n background-color: var(--semi-color-fill-1);\n}\n.semi-select-option-empty {\n cursor: not-allowed;\n color: var(--semi-color-disabled-text);\n justify-content: center;\n}\n.semi-select-option-empty:hover {\n background-color: inherit;\n}\n.semi-select-option-empty:active {\n background-color: inherit;\n}\n.semi-select-option-disabled {\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-select-option-disabled:hover {\n background-color: var(--semi-color-fill-0);\n}\n.semi-select-option-selected {\n font-weight: 600;\n background: transparent;\n}\n.semi-select-option-selected .semi-select-option-icon {\n color: var(--semi-color-text-2);\n}\n.semi-select-option-focused {\n background-color: var(--semi-color-fill-0);\n}\n\n.semi-select {\n box-sizing: border-box;\n border-radius: var(--semi-border-radius-small);\n border: 1px solid transparent;\n height: 32px;\n font-weight: 400;\n background-color: var(--semi-color-fill-0);\n display: inline-flex;\n vertical-align: middle;\n position: relative;\n outline: none;\n cursor: pointer;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n max-height: 300px;\n overflow-y: auto;\n}\n.semi-select:hover {\n background-color: var(--semi-color-fill-1);\n border: 1px solid transparent;\n}\n.semi-select:focus {\n border: 1px solid var(--semi-color-focus-border);\n background-color: var(--semi-color-fill-0);\n outline: 0;\n}\n.semi-select:active {\n background-color: var(--semi-color-fill-2);\n}\n.semi-select-small {\n height: 24px;\n line-height: 24px;\n}\n.semi-select-large {\n min-height: 40px;\n line-height: 40px;\n}\n.semi-select-large .semi-select-selection {\n font-size: 16px;\n line-height: 22px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n.semi-select-open, .semi-select-focus {\n border: 1px solid var(--semi-color-focus-border);\n outline: 0;\n}\n.semi-select-open:hover, .semi-select-focus:hover {\n background-color: var(--semi-color-fill-0);\n border: 1px solid var(--semi-color-focus-border);\n}\n.semi-select-open:active, .semi-select-focus:active {\n background-color: var(--semi-color-fill-2);\n border: 1px solid var(--semi-color-focus-border);\n}\n.semi-select-warning {\n background-color: var(--semi-color-warning-light-default);\n border-color: var(--semi-color-warning-light-default);\n}\n.semi-select-warning:hover {\n background-color: var(--semi-color-warning-light-hover);\n border-color: var(--semi-color-warning-light-hover);\n}\n.semi-select-warning:focus {\n background-color: var(--semi-color-warning-light-default);\n border-color: var(--semi-color-warning);\n}\n.semi-select-warning:active {\n background-color: var(--semi-color-warning-light-active);\n border-color: var(--semi-color-warning-light-active);\n}\n.semi-select-error {\n background-color: var(--semi-color-danger-light-default);\n border-color: var(--semi-color-danger-light-default);\n}\n.semi-select-error:hover {\n background-color: var(--semi-color-danger-light-hover);\n border-color: var(--semi-color-danger-light-hover);\n}\n.semi-select-error:focus {\n background-color: var(--semi-color-danger-light-default);\n border-color: var(--semi-color-danger);\n}\n.semi-select-error:active {\n background-color: var(--semi-color-danger-light-active);\n border-color: var(--semi-color-danger-light-active);\n}\n.semi-select-disabled {\n cursor: not-allowed;\n background-color: var(--semi-color-disabled-fill);\n}\n.semi-select-disabled:hover {\n background-color: var(--semi-color-disabled-fill);\n}\n.semi-select-disabled:focus {\n border: 1px solid transparent;\n}\n.semi-select-disabled .semi-select-selection,\n.semi-select-disabled .semi-select-selection-placeholder {\n color: var(--semi-color-disabled-text);\n cursor: not-allowed;\n}\n.semi-select-disabled .semi-select-arrow,\n.semi-select-disabled .semi-select-prefix,\n.semi-select-disabled .semi-select-suffix {\n color: var(--semi-color-disabled-text);\n}\n.semi-select-disabled .semi-tag {\n color: var(--semi-color-disabled-text);\n background-color: transparent;\n}\n.semi-select-selection {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n height: 100%;\n display: flex;\n align-items: center;\n flex-grow: 1;\n overflow: hidden;\n margin-left: 12px;\n cursor: pointer;\n color: var(--semi-color-text-0);\n}\n.semi-select-selection-text {\n width: 100%;\n overflow: hidden;\n text-overflow: ellipsis;\n}\n.semi-select-selection-text-inactive {\n display: flex;\n opacity: 0.4;\n}\n.semi-select-selection-text-hide {\n display: none;\n}\n.semi-select-selection-placeholder {\n color: var(--semi-color-text-2);\n}\n.semi-select-selection .semi-tag {\n margin-top: 1px;\n margin-right: 4px;\n margin-bottom: 1px;\n}\n.semi-select-selection .semi-tag:nth-of-type(1) {\n margin-left: 0;\n}\n.semi-select-selection .semi-tag-group {\n height: inherit;\n}\n.semi-select-selection .semi-tag-group .semi-tag {\n margin-top: 1px;\n margin-right: 4px;\n margin-bottom: 1px;\n}\n.semi-select-content-wrapper {\n white-space: nowrap;\n overflow: hidden;\n display: flex;\n align-items: center;\n height: 100%;\n}\n.semi-select-content-wrapper-collapse {\n display: inline-flex;\n flex-shrink: 0;\n width: 100%;\n}\n.semi-select-content-wrapper-collapse .semi-overflow-list-overflow {\n max-width: 100%;\n min-width: 50px;\n}\n.semi-select-content-wrapper-collapse > .semi-select-content-wrapper-collapse-tag {\n background-color: transparent;\n}\n.semi-select-content-wrapper-collapse > .semi-select-content-wrapper-collapse-N {\n background-color: transparent;\n padding: 4px;\n color: var(--semi-color-text-0);\n font-size: 12px;\n}\n.semi-select-multiple {\n height: auto;\n}\n.semi-select-multiple .semi-select-selection {\n margin-left: 4px;\n}\n.semi-select-multiple .semi-select-content-wrapper {\n width: 100%;\n min-height: 30px;\n flex-wrap: wrap;\n}\n.semi-select-multiple .semi-select-content-wrapper-empty {\n margin-left: 8px;\n}\n.semi-select-multiple .semi-select-content-wrapper .semi-tag-group {\n display: flex;\n align-items: center;\n}\n.semi-select-multiple .semi-select-content-wrapper-one-line {\n flex-wrap: nowrap;\n}\n.semi-select-multiple .semi-select-content-wrapper-one-line .semi-tag-group {\n flex-wrap: nowrap;\n justify-content: flex-start;\n overflow: hidden;\n flex-shrink: 0;\n}\n.semi-select-multiple .semi-select-inline-label-wrapper {\n flex-shrink: 0;\n}\n.semi-select-multiple.semi-select-large .semi-select-content-wrapper {\n min-height: 38px;\n}\n.semi-select-multiple.semi-select-small .semi-select-content-wrapper {\n min-height: 22px;\n}\n.semi-select-arrow {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n color: var(--semi-color-text-2);\n flex-shrink: 0;\n transform: rotate(var(--semi-transform-rotate-none));\n}\n.semi-select-arrow-empty {\n display: flex;\n width: 12px;\n}\n.semi-select-prefix, .semi-select-suffix {\n display: flex;\n justify-content: center;\n align-items: center;\n}\n.semi-select-prefix-text, .semi-select-suffix-text {\n margin: 0px 12px;\n}\n.semi-select-prefix-icon, .semi-select-suffix-icon {\n color: var(--semi-color-text-2);\n margin: 0px 8px;\n}\n.semi-select-suffix {\n display: flex;\n justify-content: center;\n align-items: center;\n}\n.semi-select-clear {\n display: flex;\n justify-content: center;\n align-items: center;\n width: 32px;\n color: var(--semi-color-text-2);\n flex-shrink: 0;\n}\n.semi-select-clear:hover {\n color: var(--semi-color-primary);\n}\n.semi-select-inset-label-wrapper {\n display: inline;\n}\n.semi-select-inset-label {\n margin-right: 12px;\n font-weight: 600;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n color: var(--semi-color-text-2);\n flex-shrink: 0;\n white-space: nowrap;\n}\n.semi-select-create-tips {\n color: var(--semi-color-text-2);\n margin-right: 4px;\n}\n\n.semi-select-with-prefix .semi-select-selection {\n margin-left: 0;\n}\n\n.semi-select-single.semi-select-filterable .semi-select-content-wrapper {\n flex-grow: 1;\n height: 100%;\n overflow: hidden;\n position: relative;\n}\n.semi-select-single.semi-select-filterable .semi-input-wrapper {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n width: 100%;\n border: none;\n background-color: transparent;\n}\n.semi-select-single.semi-select-filterable .semi-input-wrapper-focus {\n border: none;\n}\n.semi-select-single.semi-select-filterable .semi-input {\n padding-left: 0;\n padding-right: 0;\n height: 100%;\n}\n\n.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper {\n flex-grow: 1;\n height: 100%;\n overflow: hidden;\n position: relative;\n}\n.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper {\n height: 24px;\n line-height: 24px;\n}\n.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper .semi-input-default {\n height: 24px;\n}\n.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper-empty .semi-input-wrapper {\n position: absolute;\n top: 0;\n left: 0;\n height: 100%;\n}\n.semi-select-multiple.semi-select-filterable .semi-select-content-wrapper-empty .semi-input-wrapper .semi-input-default {\n height: 100%;\n}\n.semi-select-multiple.semi-select-filterable .semi-input-wrapper {\n height: 100%;\n width: 100%;\n border: none;\n background-color: transparent;\n}\n.semi-select-multiple.semi-select-filterable .semi-input-wrapper-focus {\n border: none;\n}\n.semi-select-multiple.semi-select-filterable .semi-input {\n padding-left: 0;\n padding-right: 0;\n}\n\n.semi-select-multiple.semi-select-filterable.semi-select-large .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper-large {\n height: 24px;\n line-height: 24px;\n}\n.semi-select-multiple.semi-select-filterable.semi-select-large .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper-large .semi-input-large {\n height: 24px;\n}\n\n.semi-select-multiple.semi-select-filterable.semi-select-small .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper {\n height: 20px;\n line-height: 20px;\n}\n.semi-select-multiple.semi-select-filterable.semi-select-small .semi-select-content-wrapper:not(.semi-select-content-wrapper-empty) .semi-input-wrapper .semi-input-small {\n height: 20px;\n}\n\n.semi-select-option-list-wrapper {\n padding-top: 4px;\n padding-bottom: 4px;\n padding-left: 0px;\n padding-right: 0px;\n}\n\n.semi-select-option-list {\n overflow-x: hidden;\n overflow-y: auto;\n}\n.semi-select-option-list-chosen .semi-select-option-icon {\n display: flex;\n}\n\n.semi-select-group {\n color: var(--semi-color-text-2);\n padding-top: 12px;\n margin-top: 4px;\n padding-bottom: 4px;\n padding-left: 32px;\n padding-right: 16px;\n font-size: 12px;\n line-height: 16px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n cursor: default;\n}\n.semi-select-group:not(:nth-of-type(1)) {\n border-top: 1px solid var(--semi-color-border);\n}\n\n.semi-select-loading-wrapper {\n padding-left: 16px;\n padding-right: 16px;\n padding-top: 8px;\n padding-bottom: 8px;\n cursor: not-allowed;\n height: 20px;\n box-sizing: content-box;\n}\n\n.semi-select-borderless:not(:focus-within):not(:hover) {\n background-color: transparent;\n border-color: transparent;\n}\n.semi-select-borderless:not(:focus-within):not(:hover) .semi-select-arrow {\n opacity: 0;\n}\n.semi-select-borderless:focus-within:not(:active) {\n background-color: transparent;\n}\n.semi-select-borderless.semi-select-error:not(:focus-within) {\n border-color: var(--semi-color-danger);\n}\n.semi-select-borderless.semi-select-warning:not(:focus-within) {\n border-color: var(--semi-color-warning);\n}\n.semi-select-borderless.semi-select-error:focus-within {\n border-color: var(--semi-color-danger);\n}\n.semi-select-borderless.semi-select-warning:focus-within {\n border-color: var(--semi-color-warning);\n}\n\n.semi-select-dropdown-search-wrapper {\n padding-top: 8px;\n padding-right: 12px;\n padding-bottom: 8px;\n padding-top: 8px;\n padding-left: 12px;\n border-bottom: 1px solid transparent;\n}\n\n.semi-rtl .semi-select,\n.semi-portal-rtl .semi-select {\n direction: rtl;\n}\n.semi-rtl .semi-select-selection,\n.semi-portal-rtl .semi-select-selection {\n margin-left: 0;\n margin-right: 12px;\n}\n.semi-rtl .semi-select-selection .semi-tag:nth-of-type(1),\n.semi-portal-rtl .semi-select-selection .semi-tag:nth-of-type(1) {\n margin-right: 0;\n}\n.semi-rtl .semi-select-selection .semi-tag-group .semi-tag,\n.semi-portal-rtl .semi-select-selection .semi-tag-group .semi-tag {\n margin-left: 4px;\n margin-right: 0;\n}\n.semi-rtl .semi-select-multiple .semi-select-selection,\n.semi-portal-rtl .semi-select-multiple .semi-select-selection {\n margin-left: 0;\n margin-right: 4px;\n}\n.semi-rtl .semi-select-multiple .semi-select-content-wrapper-empty,\n.semi-portal-rtl .semi-select-multiple .semi-select-content-wrapper-empty {\n margin-left: 0;\n margin-right: 8px;\n}\n.semi-rtl .semi-select-inset-label,\n.semi-portal-rtl .semi-select-inset-label {\n margin-left: 12px;\n}\n.semi-rtl .semi-select-create-tips,\n.semi-portal-rtl .semi-select-create-tips {\n margin-right: 0;\n margin-left: 4px;\n}\n.semi-rtl .semi-select-with-prefix .semi-select-selection,\n.semi-portal-rtl .semi-select-with-prefix .semi-select-selection {\n margin-left: auto;\n margin-right: 0;\n}\n.semi-rtl .semi-select-single.semi-select-filterable .semi-input-wrapper,\n.semi-portal-rtl .semi-select-single.semi-select-filterable .semi-input-wrapper {\n left: auto;\n right: 0;\n}\n.semi-rtl .semi-select-multiple.semi-select-filterable .semi-select-content-wrapper-empty .semi-input-wrapper,\n.semi-portal-rtl .semi-select-multiple.semi-select-filterable .semi-select-content-wrapper-empty .semi-input-wrapper {\n left: auto;\n right: 0;\n}\n.semi-rtl .semi-select-group,\n.semi-portal-rtl .semi-select-group {\n padding-left: 32px;\n padding-right: 16px;\n}\n.semi-rtl .semi-select-option-icon,\n.semi-portal-rtl .semi-select-option-icon {\n margin-right: 0;\n margin-left: 8px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-tag {\n box-sizing: border-box;\n border-radius: var(--semi-border-radius-small);\n background-color: transparent;\n position: relative;\n user-select: none;\n overflow: hidden;\n white-space: nowrap;\n vertical-align: bottom;\n display: flex;\n justify-content: center;\n align-items: center;\n display: inline-flex;\n}\n.semi-tag-default, .semi-tag-small {\n font-size: 12px;\n line-height: 16px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n height: 20px;\n padding: 2px 8px;\n}\n.semi-tag-default:focus-visible, .semi-tag-small:focus-visible {\n outline: 2px solid var(--semi-color-primary-light-active);\n}\n.semi-tag-square {\n border-radius: var(--semi-border-radius-small);\n}\n.semi-tag-circle {\n border-radius: var(--semi-border-radius-full);\n}\n.semi-tag-large {\n font-size: 12px;\n line-height: 16px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n padding: 4px 8px;\n height: 24px;\n}\n.semi-tag-large:focus-visible {\n outline: 2px solid var(--semi-color-primary-light-active);\n}\n.semi-tag-invisible {\n display: none;\n}\n.semi-tag-prefix-icon {\n display: flex;\n padding-right: 4px;\n}\n.semi-tag-suffix-icon {\n display: flex;\n padding-left: 4px;\n}\n.semi-tag-content {\n flex: 1;\n}\n.semi-tag-content-ellipsis {\n overflow: hidden;\n white-space: nowrap;\n text-overflow: ellipsis;\n}\n.semi-tag-content-center {\n display: flex;\n display: flex;\n justify-content: center;\n align-items: center;\n height: 100%;\n min-width: 0;\n}\n.semi-tag-close {\n display: flex;\n justify-content: center;\n align-items: center;\n color: var(--semi-color-text-2);\n padding-left: 4px;\n cursor: pointer;\n}\n.semi-tag-close:hover {\n color: var(--semi-color-text-1);\n}\n.semi-tag-close:active {\n color: var(--semi-color-text-0);\n}\n.semi-tag-closable {\n padding: 4px 4px 4px 8px;\n}\n.semi-tag-avatar-square .semi-avatar, .semi-tag-avatar-circle .semi-avatar {\n margin-right: 4px;\n}\n.semi-tag-avatar-square {\n padding: 0 4px 0 0;\n}\n.semi-tag-avatar-square .semi-avatar > img {\n background-color: var(--semi-color-default);\n}\n.semi-tag-avatar-circle {\n padding: 2px 4px 2px 2px;\n}\n.semi-tag-avatar-square.semi-tag-default .semi-avatar, .semi-tag-avatar-square.semi-tag-small .semi-avatar {\n width: 20px;\n height: 20px;\n}\n.semi-tag-avatar-square.semi-tag-large .semi-avatar {\n width: 24px;\n height: 24px;\n}\n.semi-tag-avatar-circle.semi-tag-small, .semi-tag-avatar-circle.semi-tag-default {\n border-radius: 11px;\n}\n.semi-tag-avatar-circle.semi-tag-small .semi-avatar, .semi-tag-avatar-circle.semi-tag-default .semi-avatar {\n width: 16px;\n height: 16px;\n}\n.semi-tag-avatar-circle.semi-tag-large {\n border-radius: 13px;\n}\n.semi-tag-avatar-circle.semi-tag-large .semi-avatar {\n width: 20px;\n height: 20px;\n}\n\n.semi-tag-group {\n display: block;\n height: auto;\n}\n.semi-tag-group .semi-tag {\n margin-bottom: 0;\n margin-right: 8px;\n}\n.semi-tag-group-max.semi-tag-group-small {\n height: 22px;\n}\n.semi-tag-group-max.semi-tag-group-large {\n height: 26px;\n}\n\n.semi-tag-rest-group-popover .semi-tag {\n margin-right: 8px;\n margin-bottom: 0;\n}\n.semi-tag-rest-group-popover .semi-tag:last-of-type {\n margin-right: 0;\n}\n\n.semi-tag-amber-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-amber-4), 1);\n color: rgba(var(--semi-amber-5), 1);\n}\n\n.semi-tag-amber-solid {\n background-color: rgba(var(--semi-amber-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-amber-light {\n background-color: rgba(var(--semi-amber-5), 0.15);\n color: rgba(var(--semi-amber-8), 1);\n}\n\n.semi-tag-blue-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-blue-4), 1);\n color: rgba(var(--semi-blue-5), 1);\n}\n\n.semi-tag-blue-solid {\n background-color: rgba(var(--semi-blue-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-blue-light {\n background-color: rgba(var(--semi-blue-5), 0.15);\n color: rgba(var(--semi-blue-8), 1);\n}\n\n.semi-tag-cyan-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-cyan-4), 1);\n color: rgba(var(--semi-cyan-5), 1);\n}\n\n.semi-tag-cyan-solid {\n background-color: rgba(var(--semi-cyan-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-cyan-light {\n background-color: rgba(var(--semi-cyan-5), 0.15);\n color: rgba(var(--semi-cyan-8), 1);\n}\n\n.semi-tag-green-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-green-4), 1);\n color: rgba(var(--semi-green-5), 1);\n}\n\n.semi-tag-green-solid {\n background-color: rgba(var(--semi-green-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-green-light {\n background-color: rgba(var(--semi-green-5), 0.15);\n color: rgba(var(--semi-green-8), 1);\n}\n\n.semi-tag-grey-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-grey-4), 1);\n color: rgba(var(--semi-grey-5), 1);\n}\n\n.semi-tag-grey-solid {\n background-color: rgba(var(--semi-grey-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-grey-light {\n background-color: rgba(var(--semi-grey-5), 0.15);\n color: rgba(var(--semi-grey-8), 1);\n}\n\n.semi-tag-indigo-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-indigo-4), 1);\n color: rgba(var(--semi-indigo-5), 1);\n}\n\n.semi-tag-indigo-solid {\n background-color: rgba(var(--semi-indigo-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-indigo-light {\n background-color: rgba(var(--semi-indigo-5), 0.15);\n color: rgba(var(--semi-indigo-8), 1);\n}\n\n.semi-tag-light-blue-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-light-blue-4), 1);\n color: rgba(var(--semi-light-blue-5), 1);\n}\n\n.semi-tag-light-blue-solid {\n background-color: rgba(var(--semi-light-blue-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-light-blue-light {\n background-color: rgba(var(--semi-light-blue-5), 0.15);\n color: rgba(var(--semi-light-blue-8), 1);\n}\n\n.semi-tag-light-green-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-light-green-4), 1);\n color: rgba(var(--semi-light-green-5), 1);\n}\n\n.semi-tag-light-green-solid {\n background-color: rgba(var(--semi-light-green-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-light-green-light {\n background-color: rgba(var(--semi-light-green-5), 0.15);\n color: rgba(var(--semi-light-green-8), 1);\n}\n\n.semi-tag-lime-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-lime-4), 1);\n color: rgba(var(--semi-lime-5), 1);\n}\n\n.semi-tag-lime-solid {\n background-color: rgba(var(--semi-lime-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-lime-light {\n background-color: rgba(var(--semi-lime-5), 0.15);\n color: rgba(var(--semi-lime-8), 1);\n}\n\n.semi-tag-orange-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-orange-4), 1);\n color: rgba(var(--semi-orange-5), 1);\n}\n\n.semi-tag-orange-solid {\n background-color: rgba(var(--semi-orange-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-orange-light {\n background-color: rgba(var(--semi-orange-5), 0.15);\n color: rgba(var(--semi-orange-8), 1);\n}\n\n.semi-tag-pink-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-pink-4), 1);\n color: rgba(var(--semi-pink-5), 1);\n}\n\n.semi-tag-pink-solid {\n background-color: rgba(var(--semi-pink-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-pink-light {\n background-color: rgba(var(--semi-pink-5), 0.15);\n color: rgba(var(--semi-pink-8), 1);\n}\n\n.semi-tag-purple-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-purple-4), 1);\n color: rgba(var(--semi-purple-5), 1);\n}\n\n.semi-tag-purple-solid {\n background-color: rgba(var(--semi-purple-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-purple-light {\n background-color: rgba(var(--semi-purple-5), 0.15);\n color: rgba(var(--semi-purple-8), 1);\n}\n\n.semi-tag-red-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-red-4), 1);\n color: rgba(var(--semi-red-5), 1);\n}\n\n.semi-tag-red-solid {\n background-color: rgba(var(--semi-red-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-red-light {\n background-color: rgba(var(--semi-red-5), 0.15);\n color: rgba(var(--semi-red-8), 1);\n}\n\n.semi-tag-teal-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-teal-4), 1);\n color: rgba(var(--semi-teal-5), 1);\n}\n\n.semi-tag-teal-solid {\n background-color: rgba(var(--semi-teal-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-teal-light {\n background-color: rgba(var(--semi-teal-5), 0.15);\n color: rgba(var(--semi-teal-8), 1);\n}\n\n.semi-tag-violet-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-violet-4), 1);\n color: rgba(var(--semi-violet-5), 1);\n}\n\n.semi-tag-violet-solid {\n background-color: rgba(var(--semi-violet-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-violet-light {\n background-color: rgba(var(--semi-violet-5), 0.15);\n color: rgba(var(--semi-violet-8), 1);\n}\n\n.semi-tag-yellow-ghost {\n background-color: transparent;\n border: 1px solid rgba(var(--semi-yellow-4), 1);\n color: rgba(var(--semi-yellow-5), 1);\n}\n\n.semi-tag-yellow-solid {\n background-color: rgba(var(--semi-yellow-5), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-tag-yellow-light {\n background-color: rgba(var(--semi-yellow-5), 0.15);\n color: rgba(var(--semi-yellow-8), 1);\n}\n\n.semi-tag-white-ghost {\n background-color: var(--semi-color-bg-4);\n border: 1px solid rgba(var(--semi-grey-2), 0.7);\n color: var(--semi-color-text-0);\n}\n\n.semi-tag-white-solid {\n background-color: var(--semi-color-bg-4);\n border: 1px solid rgba(var(--semi-grey-2), 0.7);\n color: var(--semi-color-text-0);\n}\n\n.semi-tag-white-light {\n background-color: var(--semi-color-bg-4);\n border: 1px solid rgba(var(--semi-grey-2), 0.7);\n color: var(--semi-color-text-0);\n}\n\n.semi-tag-white-ghost .semi-tag-close,\n.semi-tag-white-light .semi-tag-close,\n.semi-tag-white-solid .semi-tag-close {\n color: var(--semi-color-text-2);\n}\n\n.semi-tag-avatar-square,\n.semi-tag-avatar-circle {\n background-color: var(--semi-color-bg-4);\n border: 1px solid var(--semi-color-border);\n color: var(--semi-color-text-0);\n}\n\n.semi-tag-solid .semi-tag-close {\n color: var(--semi-color-white);\n opacity: 0.8;\n}\n.semi-tag-solid .semi-tag-close:hover {\n opacity: 1;\n}\n.semi-tag-solid .semi-tag-close:active {\n opacity: 0.9;\n}\n\n.semi-rtl .semi-tag,\n.semi-portal-rtl .semi-tag {\n direction: rtl;\n}\n.semi-rtl .semi-tag-close,\n.semi-portal-rtl .semi-tag-close {\n padding-left: auto;\n padding-right: 4px;\n}\n.semi-rtl .semi-tag-closable,\n.semi-portal-rtl .semi-tag-closable {\n padding: 4px 8px 4px 4px;\n}\n.semi-rtl .semi-tag-avatar-square .semi-avatar, .semi-rtl .semi-tag-avatar-circle .semi-avatar,\n.semi-portal-rtl .semi-tag-avatar-square .semi-avatar,\n.semi-portal-rtl .semi-tag-avatar-circle .semi-avatar {\n margin-right: auto;\n margin-left: 4px;\n}\n.semi-rtl .semi-tag-avatar-square,\n.semi-portal-rtl .semi-tag-avatar-square {\n padding-right: auto;\n padding-left: 4px;\n}\n.semi-rtl .semi-tag-avatar-circle,\n.semi-portal-rtl .semi-tag-avatar-circle {\n padding: 2px 2px 2px 4px;\n}\n.semi-rtl .semi-tag-group,\n.semi-portal-rtl .semi-tag-group {\n direction: rtl;\n}\n.semi-rtl .semi-tag-group .semi-tag,\n.semi-portal-rtl .semi-tag-group .semi-tag {\n margin-right: auto;\n margin-left: 8px;\n}\n.semi-rtl .semi-tag-rest-group-popover,\n.semi-portal-rtl .semi-tag-rest-group-popover {\n direction: rtl;\n}\n.semi-rtl .semi-tag-rest-group-popover .semi-tag,\n.semi-portal-rtl .semi-tag-rest-group-popover .semi-tag {\n margin-right: 0;\n margin-left: 8px;\n}\n.semi-rtl .semi-tag-rest-group-popover .semi-tag:last-of-type,\n.semi-portal-rtl .semi-tag-rest-group-popover .semi-tag:last-of-type {\n margin-right: auto;\n margin-left: 0;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-avatar {\n position: relative;\n display: inline-flex;\n overflow: hidden;\n align-items: center;\n justify-content: center;\n white-space: nowrap;\n text-align: center;\n vertical-align: middle;\n}\n.semi-avatar:focus-visible {\n outline: 2px solid var(--semi-color-primary-light-active);\n}\n.semi-avatar-focus {\n outline: 2px solid var(--semi-color-primary-light-active);\n}\n.semi-avatar-no-focus-visible:focus-visible {\n outline: none;\n}\n.semi-avatar .semi-avatar-label {\n display: flex;\n align-items: center;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n font-weight: 600;\n}\n.semi-avatar-content {\n user-select: none;\n}\n.semi-avatar-extra-extra-small {\n width: 20px;\n height: 20px;\n border-radius: 3px;\n}\n.semi-avatar-extra-extra-small .semi-avatar-content {\n transform-origin: center;\n transform: scale(0.8);\n}\n.semi-avatar-extra-extra-small .semi-avatar-label {\n font-size: 10px;\n line-height: 15px;\n}\n.semi-avatar-extra-small {\n width: 24px;\n height: 24px;\n border-radius: 3px;\n}\n.semi-avatar-extra-small .semi-avatar-content {\n transform-origin: center;\n transform: scale(0.8);\n}\n.semi-avatar-extra-small .semi-avatar-label {\n font-size: 10px;\n line-height: 15px;\n}\n.semi-avatar-small {\n width: 32px;\n height: 32px;\n border-radius: 3px;\n}\n.semi-avatar-small .semi-avatar-label {\n font-size: 12px;\n line-height: 16px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n.semi-avatar-default {\n width: 40px;\n height: 40px;\n border-radius: 3px;\n}\n.semi-avatar-default .semi-avatar-label {\n font-size: 18px;\n line-height: 24px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n.semi-avatar-medium {\n width: 48px;\n height: 48px;\n border-radius: 3px;\n}\n.semi-avatar-medium .semi-avatar-label {\n font-size: 20px;\n line-height: 28px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n.semi-avatar-large {\n width: 72px;\n height: 72px;\n border-radius: 6px;\n}\n.semi-avatar-large .semi-avatar-label {\n font-size: 32px;\n line-height: 44px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n.semi-avatar-extra-large {\n width: 128px;\n height: 128px;\n border-radius: 12px;\n}\n.semi-avatar-extra-large .semi-avatar-label {\n font-size: 64px;\n line-height: 77px;\n}\n.semi-avatar-circle {\n border-radius: var(--semi-border-radius-circle);\n}\n.semi-avatar-image {\n background-color: transparent;\n}\n.semi-avatar > img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: cover;\n}\n.semi-avatar-hover {\n position: absolute;\n left: 0;\n top: 0;\n width: 100%;\n height: 100%;\n}\n.semi-avatar:hover {\n cursor: pointer;\n}\n\n.semi-avatar-wrapper {\n position: relative;\n display: inline-flex;\n flex-direction: column;\n align-items: center;\n width: fit-content;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg {\n position: absolute;\n display: flex;\n justify-content: center;\n border-radius: 50%;\n overflow: hidden;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-small {\n width: 32px;\n height: 32px;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-default {\n width: 40px;\n height: 40px;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-medium {\n width: 48px;\n height: 48px;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-large {\n width: 72px;\n height: 72px;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-extra-large {\n width: 128px;\n height: 128px;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg {\n position: absolute;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-small {\n top: -28px;\n scale: 0.4;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-default {\n top: -32px;\n scale: 0.7;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-medium {\n top: -30px;\n scale: 0.8;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-large {\n top: -30px;\n scale: 1.1;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-bg-svg-extra-large {\n top: -32px;\n scale: 1.4;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-wrapper {\n position: absolute;\n display: flex;\n justify-content: center;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot {\n color: var(--semi-color-bg-0);\n font-weight: 600;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content {\n user-select: none;\n position: relative;\n line-height: normal;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-small {\n font-size: 5px;\n margin-top: 0px;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-default {\n font-size: 6px;\n margin-top: -2px;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-medium {\n font-size: 8px;\n margin-top: 0px;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-large {\n font-size: 14px;\n margin-top: 0px;\n}\n.semi-avatar-wrapper .semi-avatar-top_slot-wrapper .semi-avatar-top_slot-content-extra-large {\n font-size: 16px;\n margin-top: 0px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot {\n color: var(--semi-color-bg-0);\n position: absolute;\n cursor: pointer;\n bottom: 3.5px;\n transform: translateY(50%);\n user-select: none;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle {\n display: flex;\n justify-content: center;\n align-items: center;\n background: var(--semi-color-primary);\n border-radius: var(--semi-border-radius-circle);\n line-height: normal;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-extra-small {\n width: 12px;\n height: 12px;\n font-size: 5px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-small {\n width: 12px;\n height: 12px;\n font-size: 5px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-default {\n width: 16px;\n height: 16px;\n font-size: 12px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-medium {\n width: 18px;\n height: 18px;\n font-size: 12px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-large {\n width: 28px;\n height: 28px;\n font-size: 12px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_circle-extra-large {\n width: 28px;\n height: 28px;\n font-size: 14px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square {\n display: flex;\n justify-content: center;\n align-items: center;\n background: var(--semi-color-primary);\n border-radius: 4px;\n padding: 1px 4px;\n font-weight: 600;\n border-style: solid;\n border-color: var(--semi-color-bg-0);\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-extra_small {\n font-size: 5px;\n border-width: 2px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-small {\n font-size: 5px;\n border-width: 2px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-default {\n font-size: 12px;\n border-width: 2px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-medium {\n font-size: 12px;\n border-width: 2px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-large {\n font-size: 12px;\n border-width: 2px;\n}\n.semi-avatar-wrapper .semi-avatar-bottom_slot-shape_square-extra-large {\n font-size: 14px;\n border-width: 2px;\n}\n\n.semi-avatar-group {\n display: inline-block;\n}\n.semi-avatar-group .semi-avatar {\n box-sizing: border-box;\n}\n.semi-avatar-group .semi-avatar:first-child {\n margin-left: 0;\n}\n.semi-avatar-group .semi-avatar-extra-large {\n border: 3px var(--semi-color-bg-1) solid;\n margin-left: -32px;\n}\n.semi-avatar-group .semi-avatar-large {\n border: 3px var(--semi-color-bg-1) solid;\n margin-left: -18px;\n}\n.semi-avatar-group .semi-avatar-medium {\n border: 2px var(--semi-color-bg-1) solid;\n margin-left: -12px;\n}\n.semi-avatar-group .semi-avatar-default {\n border: 2px var(--semi-color-bg-1) solid;\n margin-left: -12px;\n}\n.semi-avatar-group .semi-avatar-small {\n border: 2px var(--semi-color-bg-1) solid;\n margin-left: -12px;\n}\n.semi-avatar-group .semi-avatar-extra-small {\n border: 1px var(--semi-color-bg-1) solid;\n margin-left: -10px;\n}\n.semi-avatar-group .semi-avatar-extra-extra-small {\n border: 1px var(--semi-color-bg-1) solid;\n margin-left: -4px;\n}\n.semi-avatar-group .semi-avatar-item-start-0 {\n z-index: 100;\n}\n.semi-avatar-group .semi-avatar-item-end-0 {\n z-index: 80;\n}\n.semi-avatar-group .semi-avatar-item-start-1 {\n z-index: 99;\n}\n.semi-avatar-group .semi-avatar-item-end-1 {\n z-index: 81;\n}\n.semi-avatar-group .semi-avatar-item-start-2 {\n z-index: 98;\n}\n.semi-avatar-group .semi-avatar-item-end-2 {\n z-index: 82;\n}\n.semi-avatar-group .semi-avatar-item-start-3 {\n z-index: 97;\n}\n.semi-avatar-group .semi-avatar-item-end-3 {\n z-index: 83;\n}\n.semi-avatar-group .semi-avatar-item-start-4 {\n z-index: 96;\n}\n.semi-avatar-group .semi-avatar-item-end-4 {\n z-index: 84;\n}\n.semi-avatar-group .semi-avatar-item-start-5 {\n z-index: 95;\n}\n.semi-avatar-group .semi-avatar-item-end-5 {\n z-index: 85;\n}\n.semi-avatar-group .semi-avatar-item-start-6 {\n z-index: 94;\n}\n.semi-avatar-group .semi-avatar-item-end-6 {\n z-index: 86;\n}\n.semi-avatar-group .semi-avatar-item-start-7 {\n z-index: 93;\n}\n.semi-avatar-group .semi-avatar-item-end-7 {\n z-index: 87;\n}\n.semi-avatar-group .semi-avatar-item-start-8 {\n z-index: 92;\n}\n.semi-avatar-group .semi-avatar-item-end-8 {\n z-index: 88;\n}\n.semi-avatar-group .semi-avatar-item-start-9 {\n z-index: 91;\n}\n.semi-avatar-group .semi-avatar-item-end-9 {\n z-index: 89;\n}\n.semi-avatar-group .semi-avatar-item-start-10 {\n z-index: 90;\n}\n.semi-avatar-group .semi-avatar-item-end-10 {\n z-index: 90;\n}\n.semi-avatar-group .semi-avatar-item-start-11 {\n z-index: 89;\n}\n.semi-avatar-group .semi-avatar-item-end-11 {\n z-index: 91;\n}\n.semi-avatar-group .semi-avatar-item-start-12 {\n z-index: 88;\n}\n.semi-avatar-group .semi-avatar-item-end-12 {\n z-index: 92;\n}\n.semi-avatar-group .semi-avatar-item-start-13 {\n z-index: 87;\n}\n.semi-avatar-group .semi-avatar-item-end-13 {\n z-index: 93;\n}\n.semi-avatar-group .semi-avatar-item-start-14 {\n z-index: 86;\n}\n.semi-avatar-group .semi-avatar-item-end-14 {\n z-index: 94;\n}\n.semi-avatar-group .semi-avatar-item-start-15 {\n z-index: 85;\n}\n.semi-avatar-group .semi-avatar-item-end-15 {\n z-index: 95;\n}\n.semi-avatar-group .semi-avatar-item-start-16 {\n z-index: 84;\n}\n.semi-avatar-group .semi-avatar-item-end-16 {\n z-index: 96;\n}\n.semi-avatar-group .semi-avatar-item-start-17 {\n z-index: 83;\n}\n.semi-avatar-group .semi-avatar-item-end-17 {\n z-index: 97;\n}\n.semi-avatar-group .semi-avatar-item-start-18 {\n z-index: 82;\n}\n.semi-avatar-group .semi-avatar-item-end-18 {\n z-index: 98;\n}\n.semi-avatar-group .semi-avatar-item-start-19 {\n z-index: 81;\n}\n.semi-avatar-group .semi-avatar-item-end-19 {\n z-index: 99;\n}\n.semi-avatar-group .semi-avatar-item-start-20 {\n z-index: 80;\n}\n.semi-avatar-group .semi-avatar-item-end-20 {\n z-index: 100;\n}\n.semi-avatar-group .semi-avatar-item-more {\n background-color: rgba(var(--semi-grey-5), 1);\n}\n\n.semi-avatar-amber {\n background-color: rgba(var(--semi-amber-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-blue {\n background-color: rgba(var(--semi-blue-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-cyan {\n background-color: rgba(var(--semi-cyan-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-green {\n background-color: rgba(var(--semi-green-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-grey {\n background-color: rgba(var(--semi-grey-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-indigo {\n background-color: rgba(var(--semi-indigo-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-light-blue {\n background-color: rgba(var(--semi-light-blue-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-light-green {\n background-color: rgba(var(--semi-light-green-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-lime {\n background-color: rgba(var(--semi-lime-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-orange {\n background-color: rgba(var(--semi-orange-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-pink {\n background-color: rgba(var(--semi-pink-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-purple {\n background-color: rgba(var(--semi-purple-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-red {\n background-color: rgba(var(--semi-red-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-teal {\n background-color: rgba(var(--semi-teal-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-violet {\n background-color: rgba(var(--semi-violet-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-yellow {\n background-color: rgba(var(--semi-yellow-3), 1);\n color: rgba(var(--semi-white), 1);\n}\n\n.semi-avatar-additionalBorder {\n border-style: solid;\n border-color: var(--semi-color-primary);\n display: inline-block;\n box-sizing: border-box;\n position: absolute;\n border-width: 1.5px;\n top: -3.5px;\n left: -3.5px;\n}\n.semi-avatar-additionalBorder-extra-extra-small {\n width: 27px;\n height: 27px;\n}\n.semi-avatar-additionalBorder-extra-small {\n width: 31px;\n height: 31px;\n}\n.semi-avatar-additionalBorder-small {\n width: 39px;\n height: 39px;\n}\n.semi-avatar-additionalBorder-default {\n width: 47px;\n height: 47px;\n}\n.semi-avatar-additionalBorder-medium {\n width: 55px;\n height: 55px;\n}\n.semi-avatar-additionalBorder-large {\n width: 79px;\n height: 79px;\n}\n.semi-avatar-additionalBorder-extra-large {\n width: 135px;\n height: 135px;\n}\n\n.semi-avatar-square.semi-avatar-additionalBorder-extra_extra_small {\n border-radius: 3px;\n}\n\n.semi-avatar-square.semi-avatar-additionalBorder-extra_small {\n border-radius: 3px;\n}\n\n.semi-avatar-square.semi-avatar-additionalBorder-small {\n border-radius: 3px;\n}\n\n.semi-avatar-square.semi-avatar-additionalBorder-default {\n border-radius: 3px;\n}\n\n.semi-avatar-square.semi-avatar-additionalBorder-medium {\n border-radius: 3px;\n}\n\n.semi-avatar-square.semi-avatar-additionalBorder-large {\n border-radius: 6px;\n}\n\n.semi-avatar-additionalBorder-circle {\n border-radius: var(--semi-border-radius-circle);\n}\n\n.semi-avatar-additionalBorder-animated {\n animation: 800ms linear infinite semi-avatar-additionalBorder;\n}\n\n.semi-avatar-animated {\n animation: 1000ms linear infinite semi-avatar-content;\n}\n\n@keyframes semi-avatar-additionalBorder {\n 0% {\n opacity: 1;\n transform: scale(1);\n }\n to {\n border-width: 0;\n opacity: 0;\n transform: scale(1.15);\n }\n}\n@keyframes semi-avatar-content {\n 0% {\n transform: scale(1);\n }\n 50% {\n transform: scale(0.9);\n }\n to {\n transform: scale(1);\n }\n}\n.semi-rtl .semi-avatar,\n.semi-portal-rtl .semi-avatar {\n direction: rtl;\n}\n.semi-rtl .semi-avatar-extra-extra-small .semi-avatar-content,\n.semi-portal-rtl .semi-avatar-extra-extra-small .semi-avatar-content {\n transform: scale(0.8);\n}\n.semi-rtl .semi-avatar-extra-small .semi-avatar-content,\n.semi-portal-rtl .semi-avatar-extra-small .semi-avatar-content {\n transform: scale(0.8);\n}\n.semi-rtl .semi-avatar-hover,\n.semi-portal-rtl .semi-avatar-hover {\n left: auto;\n right: 0;\n}\n.semi-rtl .semi-avatar-group,\n.semi-portal-rtl .semi-avatar-group {\n direction: rtl;\n}\n.semi-rtl .semi-avatar-group .semi-avatar:first-child,\n.semi-portal-rtl .semi-avatar-group .semi-avatar:first-child {\n margin-left: auto;\n margin-right: 0;\n}\n.semi-rtl .semi-avatar-group .semi-avatar-extra-large,\n.semi-portal-rtl .semi-avatar-group .semi-avatar-extra-large {\n margin-left: auto;\n margin-right: -32px;\n}\n.semi-rtl .semi-avatar-group .semi-avatar-large,\n.semi-portal-rtl .semi-avatar-group .semi-avatar-large {\n margin-left: auto;\n margin-right: -18px;\n}\n.semi-rtl .semi-avatar-group .semi-avatar-medium,\n.semi-rtl .semi-avatar-group .semi-avatar-small,\n.semi-portal-rtl .semi-avatar-group .semi-avatar-medium,\n.semi-portal-rtl .semi-avatar-group .semi-avatar-small {\n margin-left: auto;\n margin-right: -12px;\n}\n.semi-rtl .semi-avatar-group .semi-avatar-extra-small,\n.semi-portal-rtl .semi-avatar-group .semi-avatar-extra-small {\n margin-left: auto;\n margin-right: -10px;\n}\n.semi-rtl .semi-avatar-group .semi-avatar-extra-extra-small,\n.semi-portal-rtl .semi-avatar-group .semi-avatar-extra-extra-small {\n margin-left: auto;\n margin-right: -4px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n@keyframes semi-input-active {\n from {\n transform: scale(1);\n }\n to {\n transform: scale(0.97);\n }\n}\n@keyframes semi-input-inactive {\n from {\n transform: scale(0.97);\n }\n to {\n transform: scale(1);\n }\n}\n.semi-input {\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n\n.semi-input-wrapper {\n display: inline-block;\n position: relative;\n vertical-align: middle;\n box-shadow: none;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n background-color: var(--semi-color-fill-0);\n border: 1px transparent solid;\n border-radius: var(--semi-border-radius-small);\n width: 100%;\n outline: none;\n cursor: text;\n box-sizing: border-box;\n color: var(--semi-color-text-0);\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-input-wrapper-default {\n height: 32px;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n line-height: 30px;\n}\n.semi-input-wrapper-small {\n height: 24px;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n line-height: 22px;\n}\n.semi-input-wrapper-large {\n height: 40px;\n font-size: 16px;\n line-height: 22px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n line-height: 38px;\n}\n.semi-input-wrapper:hover {\n background-color: var(--semi-color-fill-1);\n border-color: transparent;\n}\n.semi-input-wrapper-focus {\n background-color: var(--semi-color-fill-0);\n border: var(--semi-color-focus-border) solid 1px;\n}\n.semi-input-wrapper-focus:hover {\n background-color: var(--semi-color-fill-0);\n border-color: var(--semi-color-focus-border);\n}\n.semi-input-wrapper-focus:active {\n background-color: var(--semi-color-fill-2);\n border-color: var(--semi-color-focus-border);\n}\n.semi-input-wrapper.semi-input-readonly {\n cursor: default;\n}\n.semi-input-wrapper-error {\n background-color: var(--semi-color-danger-light-default);\n border-color: var(--semi-color-danger-light-default);\n}\n.semi-input-wrapper-error:hover {\n background-color: var(--semi-color-danger-light-hover);\n border-color: var(--semi-color-danger-light-hover);\n}\n.semi-input-wrapper-error.semi-input-wrapper-focus {\n background-color: var(--semi-color-danger-light-default);\n border-color: var(--semi-color-danger);\n}\n.semi-input-wrapper-error:active {\n background-color: var(--semi-color-danger-light-active);\n border-color: var(--semi-color-danger);\n}\n.semi-input-wrapper-warning {\n background-color: var(--semi-color-warning-light-default);\n border-color: var(--semi-color-warning-light-default);\n}\n.semi-input-wrapper-warning:hover {\n background-color: var(--semi-color-warning-light-hover);\n border-color: var(--semi-color-warning-light-hover);\n}\n.semi-input-wrapper-warning.semi-input-wrapper-focus {\n background-color: var(--semi-color-warning-light-default);\n border-color: var(--semi-color-warning);\n}\n.semi-input-wrapper-warning:active {\n background-color: var(--semi-color-warning-light-active);\n border-color: var(--semi-color-warning);\n}\n.semi-input-wrapper__with-prefix {\n display: inline-flex;\n align-items: center;\n}\n.semi-input-wrapper__with-prefix .semi-input {\n padding-left: 0;\n}\n.semi-input-wrapper__with-suffix {\n display: inline-flex;\n align-items: center;\n}\n.semi-input-wrapper__with-suffix .semi-input {\n padding-right: 0;\n}\n.semi-input-wrapper-clearable, .semi-input-wrapper-modebtn {\n display: inline-flex;\n align-items: center;\n}\n.semi-input-wrapper-hidden {\n border: none;\n}\n.semi-input-wrapper .semi-icon {\n color: var(--semi-color-text-2);\n}\n.semi-input-wrapper .semi-input-clearbtn,\n.semi-input-wrapper .semi-input-modebtn {\n color: var(--semi-color-primary-hover);\n}\n.semi-input-wrapper .semi-input-clearbtn > svg,\n.semi-input-wrapper .semi-input-modebtn > svg {\n pointer-events: none;\n}\n.semi-input-wrapper .semi-input-clearbtn:hover,\n.semi-input-wrapper .semi-input-modebtn:hover {\n cursor: pointer;\n}\n.semi-input-wrapper .semi-input-clearbtn:hover .semi-icon,\n.semi-input-wrapper .semi-input-modebtn:hover .semi-icon {\n color: var(--semi-color-primary-hover);\n}\n.semi-input-wrapper .semi-input-clearbtn:focus-visible,\n.semi-input-wrapper .semi-input-modebtn:focus-visible {\n border-radius: var(--semi-border-radius-small);\n outline: 2px solid var(--semi-color-primary-light-active);\n outline-offset: -1px;\n}\n.semi-input-wrapper__with-suffix-icon.semi-input-wrapper-clearable:not(.semi-input-wrapper__with-suffix-hidden) .semi-input-clearbtn {\n min-width: 24px;\n justify-content: flex-end;\n}\n.semi-input-wrapper-modebtn.semi-input-wrapper-clearable .semi-input-clearbtn {\n min-width: 16px;\n justify-content: center;\n}\n.semi-input-wrapper.semi-input-wrapper__with-append-only .semi-input {\n border-radius: 0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0;\n}\n.semi-input-wrapper.semi-input-wrapper__with-append-only .semi-input:not(:last-child) {\n border-right-style: none;\n border-radius: 0;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend-only .semi-input {\n border-radius: var(--semi-border-radius-small) 0 0 var(--semi-border-radius-small);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend-only .semi-input:not(:last-child) {\n border-right-style: none;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend, .semi-input-wrapper.semi-input-wrapper__with-append {\n display: inline-flex;\n align-items: center;\n background-color: transparent;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend:hover, .semi-input-wrapper.semi-input-wrapper__with-append:hover {\n background-color: transparent;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-focus, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-focus {\n border: 1px transparent solid;\n background-color: transparent;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input {\n background-color: var(--semi-color-fill-0);\n border: 1px transparent solid;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:hover, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:hover {\n background-color: var(--semi-color-fill-1);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:hover + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:hover ~ .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:hover + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:hover ~ .semi-input-modebtn {\n background-color: var(--semi-color-fill-1);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus {\n border: 1px var(--semi-color-focus-border) solid;\n background-color: var(--semi-color-fill-0);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus.semi-input-sibling-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus.semi-input-sibling-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus.semi-input-sibling-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus.semi-input-sibling-modebtn {\n border-right-style: none;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus.semi-input-sibling-modebtn + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus.semi-input-sibling-modebtn + .semi-input-clearbtn {\n border-right-style: none;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus ~ .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus ~ .semi-input-modebtn {\n box-sizing: border-box;\n background-color: var(--semi-color-fill-0);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus + .semi-input-clearbtn {\n border: 1px var(--semi-color-focus-border) solid;\n border-radius: 0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0;\n border-left-style: none;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus + .semi-input-clearbtn:not(:last-child), .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus + .semi-input-clearbtn:not(:last-child) {\n border-radius: 0;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus ~ .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus ~ .semi-input-modebtn {\n border: 1px var(--semi-color-focus-border) solid;\n border-radius: 0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0;\n border-left-style: none;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:focus ~ .semi-input-modebtn:not(:last-child), .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:focus ~ .semi-input-modebtn:not(:last-child) {\n border-radius: 0;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:active, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:active {\n background-color: var(--semi-color-fill-2);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:active + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input:active ~ .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:active + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input:active ~ .semi-input-modebtn {\n background-color: var(--semi-color-fill-2);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-clearbtn:hover, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-modebtn:hover, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input-clearbtn:hover, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input-modebtn:hover {\n background-color: var(--semi-color-fill-0);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-clearbtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-modebtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-clearbtn:hover:last-child, .semi-input-wrapper.semi-input-wrapper__with-prepend .semi-input-modebtn:hover:last-child, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input-clearbtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input-modebtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input-clearbtn:hover:last-child, .semi-input-wrapper.semi-input-wrapper__with-append .semi-input-modebtn:hover:last-child {\n border-radius: 0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error {\n border-color: transparent;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input {\n background-color: var(--semi-color-danger-light-default);\n border-color: var(--semi-color-danger-light-default);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:hover, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:hover {\n background-color: var(--semi-color-danger-light-hover);\n border-color: var(--semi-color-danger-light-hover);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:hover + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:hover + .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:hover + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:hover + .semi-input-modebtn {\n background-color: var(--semi-color-danger-light-hover);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:focus, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:focus {\n background-color: var(--semi-color-danger-light-default);\n border-color: var(--semi-color-danger);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:focus + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:focus + .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:focus + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:focus + .semi-input-modebtn {\n background-color: var(--semi-color-danger-light-default);\n border-color: var(--semi-color-danger);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:active, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:active {\n background-color: var(--semi-color-danger-light-active);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:active + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input:active + .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:active + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input:active + .semi-input-modebtn {\n background-color: var(--semi-color-danger-light-active);\n border-color: var(--semi-color-danger);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-clearbtn:hover, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-modebtn:hover, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-clearbtn:hover, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-modebtn:hover {\n background-color: var(--semi-color-danger-light-default);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-clearbtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-modebtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-clearbtn:hover:last-child, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-error .semi-input-modebtn:hover:last-child, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-clearbtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-modebtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-clearbtn:hover:last-child, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-error .semi-input-modebtn:hover:last-child {\n border-radius: 0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning {\n border-color: transparent;\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input {\n background-color: var(--semi-color-warning-light-default);\n border-color: var(--semi-color-warning-light-default);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:hover, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:hover {\n background-color: var(--semi-color-warning-light-hover);\n border-color: var(--semi-color-warning-light-hover);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:hover + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:hover + .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:hover + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:hover + .semi-input-modebtn {\n background-color: var(--semi-color-warning-light-hover);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:focus, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:focus {\n background-color: var(--semi-color-warning-light-default);\n border-color: var(--semi-color-warning);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:focus + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:focus + .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:focus + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:focus + .semi-input-modebtn {\n background-color: var(--semi-color-warning-light-default);\n border-color: var(--semi-color-warning);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:active, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:active {\n background-color: var(--semi-color-warning-light-active);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:active + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input:active + .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:active + .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input:active + .semi-input-modebtn {\n background-color: var(--semi-color-warning-light-active);\n border-color: var(--semi-color-warning);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-clearbtn:hover, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-modebtn:hover, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-clearbtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-modebtn, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-clearbtn:hover, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-modebtn:hover {\n background-color: var(--semi-color-warning-light-default);\n}\n.semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-clearbtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-modebtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-clearbtn:hover:last-child, .semi-input-wrapper.semi-input-wrapper__with-prepend.semi-input-wrapper-warning .semi-input-modebtn:hover:last-child, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-clearbtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-modebtn:last-child, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-clearbtn:hover:last-child, .semi-input-wrapper.semi-input-wrapper__with-append.semi-input-wrapper-warning .semi-input-modebtn:hover:last-child {\n border-radius: 0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0;\n}\n.semi-input-wrapper-disabled {\n cursor: not-allowed;\n color: var(--semi-color-disabled-text);\n background-color: var(--semi-color-disabled-fill);\n -webkit-text-fill-color: var(--semi-color-disabled-text);\n}\n.semi-input-wrapper-disabled:hover {\n background-color: var(--semi-color-disabled-fill);\n}\n.semi-input-wrapper-disabled .semi-input-append,\n.semi-input-wrapper-disabled .semi-input-prepend,\n.semi-input-wrapper-disabled .semi-input-suffix,\n.semi-input-wrapper-disabled .semi-input-prefix,\n.semi-input-wrapper-disabled .semi-icon {\n color: var(--semi-color-disabled-text);\n}\n\n.semi-input {\n border: none;\n outline: none;\n width: 100%;\n color: inherit;\n padding-left: 12px;\n padding-right: 12px;\n background-color: transparent;\n box-sizing: border-box;\n}\n.semi-input[type=password]::-ms-reveal, .semi-input[type=password]::-ms-clear {\n display: none;\n}\n.semi-input[type=search]::-webkit-search-cancel-button {\n display: none;\n}\n.semi-input::placeholder {\n color: var(--semi-color-text-2);\n}\n.semi-input-large {\n height: 38px;\n font-size: 16px;\n line-height: 22px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n line-height: 38px;\n}\n.semi-input-small {\n height: 22px;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n line-height: 22px;\n}\n.semi-input-default {\n height: 30px;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n line-height: 30px;\n}\n.semi-input-disabled {\n cursor: not-allowed;\n color: inherit;\n}\n.semi-input-inset-label {\n margin-right: 12px;\n font-weight: 600;\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n color: var(--semi-color-text-2);\n flex-shrink: 0;\n white-space: nowrap;\n}\n.semi-input-prefix, .semi-input-suffix {\n display: flex;\n justify-content: center;\n align-items: center;\n}\n.semi-input-prefix-text, .semi-input-suffix-text {\n margin: 0 12px;\n color: var(--semi-color-text-2);\n font-weight: 600;\n white-space: nowrap;\n}\n.semi-input-prefix-icon, .semi-input-suffix-icon {\n color: var(--semi-color-text-2);\n margin: 0 8px;\n}\n.semi-input-suffix {\n display: flex;\n justify-content: center;\n align-items: center;\n}\n.semi-input-clearbtn, .semi-input-modebtn {\n display: flex;\n align-items: center;\n height: 100%;\n justify-content: center;\n min-width: 32px;\n}\n.semi-input-clearbtn + .semi-input-suffix + .semi-input-suffix-text {\n margin-left: 0;\n}\n.semi-input-clearbtn + .semi-input-suffix + .semi-input-suffix-icon {\n margin-left: 0;\n}\n.semi-input-suffix-hidden {\n display: none;\n}\n.semi-input-prepend, .semi-input-append {\n height: 100%;\n display: flex;\n align-items: center;\n background-color: var(--semi-color-fill-0);\n color: var(--semi-color-text-2);\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n flex-shrink: 0;\n}\n.semi-input-prepend-icon, .semi-input-prepend-text, .semi-input-append-icon, .semi-input-append-text {\n padding: 0 12px;\n}\n.semi-input-append {\n border-radius: 0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0;\n border-left: 1px transparent solid;\n}\n.semi-input-prepend {\n border-radius: var(--semi-border-radius-small) 0 0 var(--semi-border-radius-small);\n border-right: 1px transparent solid;\n}\n.semi-input-disabled::placeholder {\n color: var(--semi-color-disabled-text);\n}\n.semi-input-group {\n display: inline-flex;\n align-items: center;\n align-content: center;\n flex-wrap: wrap;\n}\n.semi-input-group .semi-select,\n.semi-input-group .semi-tagInput,\n.semi-input-group .semi-cascader,\n.semi-input-group .semi-tree-select, .semi-input-group > .semi-input-wrapper {\n border-radius: 0;\n}\n.semi-input-group .semi-select:first-child,\n.semi-input-group .semi-tagInput:first-child,\n.semi-input-group .semi-cascader:first-child,\n.semi-input-group .semi-tree-select:first-child, .semi-input-group > .semi-input-wrapper:first-child {\n border-radius: var(--semi-border-radius-small) 0 0 var(--semi-border-radius-small);\n}\n.semi-input-group .semi-select:last-child,\n.semi-input-group .semi-tagInput:last-child,\n.semi-input-group .semi-cascader:last-child,\n.semi-input-group .semi-tree-select:last-child, .semi-input-group > .semi-input-wrapper:last-child {\n border-radius: 0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0;\n}\n.semi-input-group .semi-select:not(:last-child),\n.semi-input-group .semi-tagInput:not(:last-child),\n.semi-input-group .semi-cascader:not(:last-child),\n.semi-input-group .semi-tree-select:not(:last-child), .semi-input-group > .semi-input-wrapper:not(:last-child) {\n position: relative;\n}\n.semi-input-group .semi-select:not(:last-child)::after,\n.semi-input-group .semi-tagInput:not(:last-child)::after,\n.semi-input-group .semi-cascader:not(:last-child)::after,\n.semi-input-group .semi-tree-select:not(:last-child)::after, .semi-input-group > .semi-input-wrapper:not(:last-child)::after {\n content: \"\";\n background-color: var(--semi-color-border);\n width: 1px;\n position: absolute;\n right: -1px;\n top: 1px;\n bottom: 1px;\n}\n.semi-input-group .semi-select {\n overflow-y: visible;\n}\n.semi-input-group .semi-input-number,\n.semi-input-group .semi-datepicker,\n.semi-input-group .semi-timepicker,\n.semi-input-group .semi-autocomplete {\n border-radius: 0;\n}\n.semi-input-group .semi-input-number .semi-datepicker-range-input,\n.semi-input-group .semi-datepicker .semi-datepicker-range-input,\n.semi-input-group .semi-timepicker .semi-datepicker-range-input,\n.semi-input-group .semi-autocomplete .semi-datepicker-range-input {\n border-radius: 0;\n}\n.semi-input-group .semi-input-number:first-child .semi-input-wrapper,\n.semi-input-group .semi-input-number:first-child .semi-datepicker-range-input,\n.semi-input-group .semi-datepicker:first-child .semi-input-wrapper,\n.semi-input-group .semi-datepicker:first-child .semi-datepicker-range-input,\n.semi-input-group .semi-timepicker:first-child .semi-input-wrapper,\n.semi-input-group .semi-timepicker:first-child .semi-datepicker-range-input,\n.semi-input-group .semi-autocomplete:first-child .semi-input-wrapper,\n.semi-input-group .semi-autocomplete:first-child .semi-datepicker-range-input {\n border-radius: var(--semi-border-radius-small) 0 0 var(--semi-border-radius-small);\n}\n.semi-input-group .semi-input-number:last-child .semi-input-wrapper,\n.semi-input-group .semi-input-number:last-child .semi-datepicker-range-input,\n.semi-input-group .semi-datepicker:last-child .semi-input-wrapper,\n.semi-input-group .semi-datepicker:last-child .semi-datepicker-range-input,\n.semi-input-group .semi-timepicker:last-child .semi-input-wrapper,\n.semi-input-group .semi-timepicker:last-child .semi-datepicker-range-input,\n.semi-input-group .semi-autocomplete:last-child .semi-input-wrapper,\n.semi-input-group .semi-autocomplete:last-child .semi-datepicker-range-input {\n border-radius: 0 var(--semi-border-radius-small) var(--semi-border-radius-small) 0;\n}\n.semi-input-group .semi-input-number:not(:last-child),\n.semi-input-group .semi-datepicker:not(:last-child),\n.semi-input-group .semi-timepicker:not(:last-child),\n.semi-input-group .semi-autocomplete:not(:last-child) {\n position: relative;\n}\n.semi-input-group .semi-input-number:not(:last-child)::after,\n.semi-input-group .semi-datepicker:not(:last-child)::after,\n.semi-input-group .semi-timepicker:not(:last-child)::after,\n.semi-input-group .semi-autocomplete:not(:last-child)::after {\n content: \"\";\n background-color: var(--semi-color-border);\n width: 1px;\n position: absolute;\n right: -1px;\n top: 1px;\n bottom: 1px;\n}\n.semi-input-group-wrapper-with-top-label {\n margin-top: 16px;\n margin-bottom: 16px;\n}\n.semi-input-group-wrapper-with-top-label .semi-input-group {\n display: flex;\n}\n.semi-input-group-wrapper-with-top-label .semi-input-group .semi-form-field {\n margin-top: 0;\n margin-bottom: 0;\n}\n\n.semi-input-only_border {\n background: transparent;\n border-color: var(--semi-color-border);\n}\n.semi-input-only_border:hover {\n background: transparent;\n border-color: var(--semi-color-border);\n}\n.semi-input-only_border:focus-within {\n background: transparent;\n}\n\n.semi-input-borderless:not(:focus-within):not(:hover) {\n background-color: transparent;\n border-color: transparent;\n}\n.semi-input-borderless:focus-within:not(:active) {\n background-color: transparent;\n}\n.semi-input-borderless.semi-input-wrapper-error:not(:focus-within) {\n border-color: var(--semi-color-danger);\n}\n.semi-input-borderless.semi-input-wrapper-warning:not(:focus-within) {\n border-color: var(--semi-color-warning);\n}\n\n.semi-rtl .semi-input-wrapper,\n.semi-portal-rtl .semi-input-wrapper {\n direction: rtl;\n}\n.semi-rtl .semi-input-wrapper__with-prefix .semi-input,\n.semi-portal-rtl .semi-input-wrapper__with-prefix .semi-input {\n padding-left: auto;\n padding-right: 0;\n}\n.semi-rtl .semi-input-wrapper__with-suffix .semi-input,\n.semi-portal-rtl .semi-input-wrapper__with-suffix .semi-input {\n padding-right: auto;\n padding-left: 0;\n}\n.semi-rtl .semi-input,\n.semi-portal-rtl .semi-input {\n padding-left: 12px;\n padding-right: 12px;\n}\n.semi-rtl .semi-input-inset-label,\n.semi-portal-rtl .semi-input-inset-label {\n margin-right: auto;\n margin-left: 12px;\n}\n.semi-rtl .semi-input-clearbtn + .semi-rtl .semi-input-suffix + .semi-input-suffix-text,\n.semi-rtl .semi-input-clearbtn + .semi-portal-rtl .semi-input-suffix + .semi-input-suffix-text,\n.semi-portal-rtl .semi-input-clearbtn + .semi-rtl .semi-input-suffix + .semi-input-suffix-text,\n.semi-portal-rtl .semi-input-clearbtn + .semi-portal-rtl .semi-input-suffix + .semi-input-suffix-text {\n margin-left: auto;\n margin-right: 0;\n}\n.semi-rtl .semi-input-clearbtn + .semi-rtl .semi-input-suffix + .semi-input-suffix-icon,\n.semi-rtl .semi-input-clearbtn + .semi-portal-rtl .semi-input-suffix + .semi-input-suffix-icon,\n.semi-portal-rtl .semi-input-clearbtn + .semi-rtl .semi-input-suffix + .semi-input-suffix-icon,\n.semi-portal-rtl .semi-input-clearbtn + .semi-portal-rtl .semi-input-suffix + .semi-input-suffix-icon {\n margin-left: auto;\n margin-right: 0;\n}\n.semi-rtl .semi-input-append,\n.semi-portal-rtl .semi-input-append {\n border-left: 0;\n border-right: 1px transparent solid;\n}\n.semi-rtl .semi-input-prepend,\n.semi-portal-rtl .semi-input-prepend {\n border-right: 0;\n border-left: 1px transparent solid;\n}\n.semi-rtl .semi-input-group .semi-select:not(:last-child)::after,\n.semi-rtl .semi-input-group .semi-cascader:not(:last-child)::after,\n.semi-rtl .semi-input-group .semi-tree-select:not(:last-child)::after, .semi-rtl .semi-input-group > .semi-input-wrapper:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group .semi-select:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group .semi-cascader:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group .semi-tree-select:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group > .semi-input-wrapper:not(:last-child)::after {\n right: auto;\n left: -1px;\n}\n.semi-rtl .semi-input-group .semi-input-number:not(:last-child)::after,\n.semi-portal-rtl .semi-input-group .semi-input-number:not(:last-child)::after {\n right: auto;\n left: -1px;\n}\n.semi-rtl .semi-input-textarea-wrapper,\n.semi-portal-rtl .semi-input-textarea-wrapper {\n direction: rtl;\n}\n.semi-rtl .semi-input-textarea-counter,\n.semi-portal-rtl .semi-input-textarea-counter {\n text-align: left;\n}\n.semi-rtl .semi-input-textarea-showClear,\n.semi-portal-rtl .semi-input-textarea-showClear {\n padding-right: 0;\n padding-left: 36px;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-spin {\n position: relative;\n display: inline-block;\n width: 20px;\n height: 20px;\n}\n@keyframes semi-animation-rotate {\n from {\n transform: rotate(0);\n }\n to {\n transform: rotate(360deg);\n }\n}\n.semi-spin-wrapper {\n text-align: center;\n position: absolute;\n width: 100%;\n transform: translateY(-50%);\n top: 50%;\n color: var(--semi-color-primary);\n}\n.semi-spin-wrapper > svg {\n display: inline;\n animation: 600ms linear infinite semi-animation-rotate;\n animation-fill-mode: forwards;\n vertical-align: top;\n width: 20px;\n height: 20px;\n}\n.semi-spin-animate {\n display: inline-flex;\n animation: 1600ms linear infinite semi-animation-rotate;\n animation-fill-mode: forwards;\n}\n.semi-spin-children {\n opacity: 0.5;\n user-select: none;\n}\n.semi-spin-block {\n display: block;\n}\n.semi-spin-block::after {\n content: \"\";\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 1;\n}\n.semi-spin-block .semi-spin-wrapper {\n display: block;\n}\n.semi-spin-block.semi-spin {\n height: auto;\n width: auto;\n}\n.semi-spin-hidden::after {\n content: none;\n}\n.semi-spin-hidden > .semi-spin-children {\n opacity: 1;\n user-select: auto;\n}\n\n.semi-spin-small {\n width: 14px;\n height: 14px;\n}\n.semi-spin-small > .semi-spin-wrapper svg {\n width: 14px;\n height: 14px;\n}\n\n.semi-spin-middle {\n width: 20px;\n height: 20px;\n}\n.semi-spin-middle > .semi-spin-wrapper svg {\n width: 20px;\n height: 20px;\n}\n\n.semi-spin-large {\n width: 32px;\n height: 32px;\n}\n.semi-spin-large > .semi-spin-wrapper svg {\n width: 32px;\n height: 32px;\n}\n\n.semi-spin-container {\n overflow: hidden;\n}\n\n.semi-rtl .semi-spin,\n.semi-portal-rtl .semi-spin {\n direction: rtl;\n}\n.semi-rtl .semi-spin-container,\n.semi-portal-rtl .semi-spin-container {\n direction: rtl;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-overflow-list {\n display: flex;\n flex-wrap: nowrap;\n min-width: 0;\n}\n.semi-overflow-list-spacer {\n flex-shrink: 1;\n width: 1px;\n}\n.semi-overflow-list-scroll-wrapper {\n display: flex;\n flex: 1;\n flex-wrap: nowrap;\n overflow-x: scroll;\n}\n\n.semi-rtl .semi-overflow-list,\n.semi-portal-rtl .semi-overflow-list {\n direction: rtl;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-input-number {\n display: inline-flex;\n align-items: center;\n box-sizing: border-box;\n transition: background-color var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none), border var(--semi-transition_duration-none) var(--semi-transition_function-easeIn) var(--semi-transition_delay-none);\n transform: scale(var(--semi-transform_scale-none));\n}\n.semi-input-number-suffix-btns {\n display: inline-flex;\n flex-direction: column;\n margin-left: 4px;\n border: 1px solid var(--semi-color-border);\n border-radius: var(--semi-border-radius-small);\n background-color: var(--semi-color-bg-2);\n box-sizing: border-box;\n}\n.semi-input-number-suffix-btns > .semi-input-number-button {\n height: 50%;\n width: 14px;\n padding: 0;\n margin: 0;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n user-select: none;\n border-radius: 0;\n color: var(--semi-color-text-2);\n}\n.semi-input-number-suffix-btns > .semi-input-number-button-up:not(.semi-input-number-button-up-not-allowed):hover, .semi-input-number-suffix-btns > .semi-input-number-button-down:not(.semi-input-number-button-down-not-allowed):hover {\n cursor: pointer;\n background-color: var(--semi-color-fill-0);\n}\n.semi-input-number-suffix-btns > .semi-input-number-button-up:not(.semi-input-number-button-up-not-allowed):active, .semi-input-number-suffix-btns > .semi-input-number-button-down:not(.semi-input-number-button-down-not-allowed):active {\n cursor: pointer;\n background-color: var(--semi-color-fill-1);\n}\n.semi-input-number-suffix-btns > .semi-input-number-button-up.semi-input-number-button-up-disabled, .semi-input-number-suffix-btns > .semi-input-number-button-down.semi-input-number-button-down-disabled {\n background-color: var(--semi-color-disabled-fill);\n color: var(--semi-color-disabled-text);\n}\n.semi-input-number-suffix-btns > .semi-input-number-button-up.semi-input-number-button-up-not-allowed, .semi-input-number-suffix-btns > .semi-input-number-button-down.semi-input-number-button-down-not-allowed {\n cursor: not-allowed;\n}\n.semi-input-number-suffix-btns-inner-hover {\n border-color: var(--semi-color-fill-2);\n}\n.semi-input-number-suffix-btns-inner {\n margin-left: 8px;\n}\n.semi-input-number .semi-input-clearbtn + .semi-input-suffix {\n margin-left: -4px;\n}\n.semi-input-number .semi-input-clearbtn + .semi-input-suffix .semi-input-number-suffix-btns-inner {\n margin-left: 0;\n}\n.semi-input-number-size-default .semi-input-number-suffix-btns {\n height: 32px;\n}\n.semi-input-number-size-default .semi-input-number-suffix-btns-inner {\n height: 30px;\n}\n.semi-input-number-size-large .semi-input-number-suffix-btns {\n height: 40px;\n}\n.semi-input-number-size-large .semi-input-number-suffix-btns-inner {\n height: 38px;\n}\n.semi-input-number-size-small .semi-input-number-suffix-btns {\n height: 24px;\n}\n.semi-input-number-size-small .semi-input-number-suffix-btns-inner {\n height: 22px;\n}\n\n.semi-input-number:not(:focus-within):not(:hover) .semi-input-borderless + .semi-input-number-suffix-btns {\n opacity: 0;\n}\n\n.semi-rtl .semi-input-number,\n.semi-portal-rtl .semi-input-number {\n direction: rtl;\n}\n.semi-rtl .semi-input-number-suffix-btns,\n.semi-portal-rtl .semi-input-number-suffix-btns {\n margin-left: auto;\n margin-right: 4px;\n}\n.semi-rtl .semi-input-number-suffix-btns-inner,\n.semi-portal-rtl .semi-input-number-suffix-btns-inner {\n margin-left: auto;\n margin-right: 8px;\n}\n.semi-rtl .semi-input-number .semi-input-clearbtn + .semi-input-suffix,\n.semi-portal-rtl .semi-input-number .semi-input-clearbtn + .semi-input-suffix {\n margin-left: auto;\n margin-right: -4px;\n}\n.semi-rtl .semi-input-number .semi-input-clearbtn + .semi-input-suffix .semi-input-number-suffix-btns-inner,\n.semi-portal-rtl .semi-input-number .semi-input-clearbtn + .semi-input-suffix .semi-input-number-suffix-btns-inner {\n margin-left: auto;\n margin-right: 0;\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-timeline {\n margin: 0;\n padding: 8px;\n width: 100%;\n list-style: none;\n}\n.semi-timeline-item {\n position: relative;\n margin: 0;\n padding: 0 0 24px 0;\n list-style: none;\n}\n.semi-timeline-item-tail {\n position: absolute;\n top: 20px;\n left: 4px;\n height: calc(100% - 20px);\n border-left: 1px solid var(--semi-color-text-3);\n}\n.semi-timeline-item-head {\n position: absolute;\n top: 5px;\n width: 9px;\n height: 9px;\n border-radius: var(--semi-border-radius-circle);\n}\n.semi-timeline-item-head-ongoing {\n background-color: var(--semi-color-primary);\n}\n.semi-timeline-item-head-default {\n background-color: var(--semi-color-tertiary-light-active);\n}\n.semi-timeline-item-head-success {\n background-color: var(--semi-color-success);\n}\n.semi-timeline-item-head-warning {\n background-color: var(--semi-color-warning);\n}\n.semi-timeline-item-head-error {\n background-color: var(--semi-color-danger);\n}\n.semi-timeline-item-head-custom {\n position: absolute;\n display: flex;\n align-self: center;\n top: 10px;\n left: 5px;\n width: auto;\n height: auto;\n border: 0;\n border-radius: 0;\n transform: translate(-50%, -50%);\n}\n.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-ongoing {\n background-color: transparent;\n color: var(--semi-color-primary);\n}\n.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-success {\n background-color: transparent;\n color: var(--semi-color-success);\n}\n.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-warning {\n background-color: transparent;\n color: var(--semi-color-warning);\n}\n.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-error {\n background-color: transparent;\n color: var(--semi-color-danger);\n}\n.semi-timeline-item .semi-timeline-item-head-custom.semi-timeline-item-head-default {\n background-color: transparent;\n color: var(--semi-color-tertiary-light-active);\n}\n.semi-timeline-item-content {\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n position: relative;\n margin: 0 0 0 25px;\n word-break: break-word;\n color: var(--semi-color-text-0);\n}\n.semi-timeline-item-content-extra, .semi-timeline-item-content-time {\n font-size: 12px;\n line-height: 16px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n color: var(--semi-color-text-2);\n margin-top: 4px;\n}\n.semi-timeline-item:last-child > .semi-timeline-item-tail {\n border-left: none;\n}\n.semi-timeline-alternate .semi-timeline-item-tail, .semi-timeline-alternate .semi-timeline-item-head, .semi-timeline-alternate .semi-timeline-item-head-custom, .semi-timeline-right .semi-timeline-item-tail, .semi-timeline-right .semi-timeline-item-head, .semi-timeline-right .semi-timeline-item-head-custom, .semi-timeline-center .semi-timeline-item-tail, .semi-timeline-center .semi-timeline-item-head, .semi-timeline-center .semi-timeline-item-head-custom {\n left: 50%;\n}\n.semi-timeline-alternate .semi-timeline-item-head.semi-timeline-item-head-custom, .semi-timeline-right .semi-timeline-item-head.semi-timeline-item-head-custom, .semi-timeline-center .semi-timeline-item-head.semi-timeline-item-head-custom {\n margin-left: 0;\n}\n.semi-timeline-alternate .semi-timeline-item-head, .semi-timeline-right .semi-timeline-item-head, .semi-timeline-center .semi-timeline-item-head {\n margin-left: -4px;\n}\n.semi-timeline-alternate .semi-timeline-item-left .semi-timeline-item-content, .semi-timeline-right .semi-timeline-item-left .semi-timeline-item-content, .semi-timeline-center .semi-timeline-item-left .semi-timeline-item-content {\n left: calc(50% - 4px);\n width: calc(50% - 14px);\n text-align: left;\n}\n.semi-timeline-alternate .semi-timeline-item-right .semi-timeline-item-content, .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-content, .semi-timeline-center .semi-timeline-item-right .semi-timeline-item-content {\n width: calc(50% - 20px);\n margin: 0;\n text-align: right;\n}\n.semi-timeline-center .semi-timeline-item-content-time {\n position: absolute;\n top: -2px;\n margin-left: calc(-40px - 100%);\n width: 100%;\n text-align: right;\n}\n.semi-timeline-right .semi-timeline-item-right .semi-timeline-item-tail, .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head, .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head-custom {\n left: calc(100% - 9px);\n}\n.semi-timeline-right .semi-timeline-item-right .semi-timeline-item-content {\n width: calc(100% - 28px);\n}\n\n.semi-rtl .semi-timeline,\n.semi-portal-rtl .semi-timeline {\n direction: rtl;\n}\n.semi-rtl .semi-timeline-item-tail,\n.semi-portal-rtl .semi-timeline-item-tail {\n left: auto;\n right: 4px;\n border-left: 0;\n border-right: 1px solid var(--semi-color-text-3);\n}\n.semi-rtl .semi-timeline-item-head-custom,\n.semi-portal-rtl .semi-timeline-item-head-custom {\n left: auto;\n right: 5px;\n transform: translate(50%, -50%);\n}\n.semi-rtl .semi-timeline-item-content,\n.semi-portal-rtl .semi-timeline-item-content {\n margin: 0 25px 0 0;\n}\n.semi-rtl .semi-timeline-item:last-child .semi-timeline-item-tail,\n.semi-portal-rtl .semi-timeline-item:last-child .semi-timeline-item-tail {\n border-right: none;\n}\n.semi-rtl .semi-timeline-alternate .semi-timeline-item-tail, .semi-rtl .semi-timeline-alternate .semi-timeline-item-head, .semi-rtl .semi-timeline-alternate .semi-timeline-item-head-custom, .semi-rtl .semi-timeline-right .semi-timeline-item-tail, .semi-rtl .semi-timeline-right .semi-timeline-item-head, .semi-rtl .semi-timeline-right .semi-timeline-item-head-custom, .semi-rtl .semi-timeline-center .semi-timeline-item-tail, .semi-rtl .semi-timeline-center .semi-timeline-item-head, .semi-rtl .semi-timeline-center .semi-timeline-item-head-custom,\n.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-tail,\n.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-head,\n.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-head-custom,\n.semi-portal-rtl .semi-timeline-right .semi-timeline-item-tail,\n.semi-portal-rtl .semi-timeline-right .semi-timeline-item-head,\n.semi-portal-rtl .semi-timeline-right .semi-timeline-item-head-custom,\n.semi-portal-rtl .semi-timeline-center .semi-timeline-item-tail,\n.semi-portal-rtl .semi-timeline-center .semi-timeline-item-head,\n.semi-portal-rtl .semi-timeline-center .semi-timeline-item-head-custom {\n left: auto;\n right: 50%;\n}\n.semi-rtl .semi-timeline-alternate .semi-timeline-item-head, .semi-rtl .semi-timeline-right .semi-timeline-item-head, .semi-rtl .semi-timeline-center .semi-timeline-item-head,\n.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-head,\n.semi-portal-rtl .semi-timeline-right .semi-timeline-item-head,\n.semi-portal-rtl .semi-timeline-center .semi-timeline-item-head {\n margin-left: 0;\n margin-right: -4px;\n}\n.semi-rtl .semi-timeline-alternate .semi-timeline-item-left .semi-timeline-item-content, .semi-rtl .semi-timeline-right .semi-timeline-item-left .semi-timeline-item-content, .semi-rtl .semi-timeline-center .semi-timeline-item-left .semi-timeline-item-content,\n.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-left .semi-timeline-item-content,\n.semi-portal-rtl .semi-timeline-right .semi-timeline-item-left .semi-timeline-item-content,\n.semi-portal-rtl .semi-timeline-center .semi-timeline-item-left .semi-timeline-item-content {\n left: auto;\n right: calc(50% - 4px);\n text-align: right;\n}\n.semi-rtl .semi-timeline-alternate .semi-timeline-item-right .semi-timeline-item-content, .semi-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-content, .semi-rtl .semi-timeline-center .semi-timeline-item-right .semi-timeline-item-content,\n.semi-portal-rtl .semi-timeline-alternate .semi-timeline-item-right .semi-timeline-item-content,\n.semi-portal-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-content,\n.semi-portal-rtl .semi-timeline-center .semi-timeline-item-right .semi-timeline-item-content {\n text-align: left;\n}\n.semi-rtl .semi-timeline-center .semi-timeline-item-content-time,\n.semi-portal-rtl .semi-timeline-center .semi-timeline-item-content-time {\n margin-left: 0;\n margin-right: calc(-40px - 100%);\n text-align: left;\n}\n.semi-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-tail, .semi-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head, .semi-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head-custom,\n.semi-portal-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-tail,\n.semi-portal-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head,\n.semi-portal-rtl .semi-timeline-right .semi-timeline-item-right .semi-timeline-item-head-custom {\n left: 0;\n right: calc(100% - 9px);\n}","/* shadow */\n/* sizing */\n/* spacing */\n.semi-toast {\n pointer-events: none;\n}\n.semi-toast-wrapper {\n position: fixed;\n height: 0;\n top: 0;\n width: 100%;\n display: flex;\n justify-content: center;\n z-index: 1010;\n}\n.semi-toast-wrapper .semi-toast-innerWrapper {\n width: fit-content;\n height: fit-content;\n text-align: center;\n}\n.semi-toast-wrapper .semi-toast-innerWrapper-hover .semi-toast-zero-height-wrapper {\n perspective: unset;\n perspective-origin: center center;\n}\n.semi-toast-zero-height-wrapper {\n transition: all 300ms cubic-bezier(0.22, 0.57, 0.02, 1.2);\n perspective-origin: center 280px;\n perspective: 280px;\n height: 0;\n overflow: visible;\n}\n.semi-toast-content {\n pointer-events: all;\n box-shadow: var(--semi-shadow-elevated);\n font-size: 14px;\n line-height: 20px;\n font-family: \"Inter\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Hiragino Sans GB\", \"Microsoft YaHei\", \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n background-color: var(--semi-color-bg-3);\n border-radius: var(--semi-border-radius-medium);\n padding: 12px 8px 12px 8px;\n display: inline-flex;\n align-items: flex-start;\n justify-content: center;\n margin: 12px;\n font-weight: 600;\n color: var(--semi-color-text-0);\n}\n.semi-toast-content .semi-toast-close-button {\n margin-top: -2px;\n height: 20px;\n}\n.semi-toast-content .semi-toast-content-text {\n margin-left: 12px;\n margin-right: 12px;\n text-align: left;\n word-wrap: break-word;\n overflow-wrap: break-word;\n}\n.semi-toast-light.semi-toast-warning .semi-toast-content {\n background-color: var(--semi-color-warning-light-default);\n border: 1px solid var(--semi-color-warning);\n}\n.semi-toast-light.semi-toast-warning .semi-toast-icon-warning {\n color: var(--semi-color-warning);\n}\n.semi-toast-light.semi-toast-success .semi-toast-content {\n background-color: var(--semi-color-success-light-default);\n border: 1px solid var(--semi-color-success);\n}\n.semi-toast-light.semi-toast-success .semi-toast-icon-success {\n color: var(--semi-color-success);\n}\n.semi-toast-light.semi-toast-info .semi-toast-content {\n background-color: var(--semi-color-info-light-default);\n border: 1px solid var(--semi-color-info);\n}\n.semi-toast-light.semi-toast-info .semi-toast-icon-info {\n color: var(--semi-color-info);\n}\n.semi-toast-light.semi-toast-error .semi-toast-content {\n background-color: var(--semi-color-danger-light-default);\n border: 1px solid var(--semi-color-danger);\n}\n.semi-toast-light.semi-toast-error .semi-toast-icon-error {\n color: var(--semi-color-danger);\n}\n.semi-toast .semi-toast-icon-warning {\n color: var(--semi-color-warning);\n}\n.semi-toast .semi-toast-icon-success {\n color: var(--semi-color-success);\n}\n.semi-toast .semi-toast-icon-info {\n color: var(--semi-color-info);\n}\n.semi-toast .semi-toast-icon-error {\n color: var(--semi-color-danger);\n}\n.semi-toast-animation-show {\n animation: 300ms semi-toast-keyframe-toast-show cubic-bezier(0.22, 0.57, 0.02, 1.2) 0s;\n animation-fill-mode: forwards;\n}\n.semi-toast-animation-hide {\n animation: 300ms semi-toast-keyframe-toast-hide cubic-bezier(0.22, 0.57, 0.02, 1.2) 0s;\n animation-fill-mode: forwards;\n}\n@keyframes semi-toast-keyframe-toast-show {\n 0% {\n opacity: 0;\n transform: translateY(-100%);\n }\n 100% {\n opacity: 1;\n }\n}\n@keyframes semi-toast-keyframe-toast-hide {\n 0% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n transform: translateY(-100%);\n }\n}\n\n.semi-toast-rtl {\n direction: rtl;\n}\n.semi-toast-rtl .semi-toast-content .semi-toast-content-text {\n text-align: right;\n margin-left: 12px;\n margin-right: 12px;\n}","body {\r\n margin: 0;\r\n font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Roboto\", \"Oxygen\",\r\n \"Ubuntu\", \"Cantarell\", \"Fira Sans\", \"Droid Sans\", \"Helvetica Neue\",\r\n sans-serif;\r\n -webkit-font-smoothing: antialiased;\r\n -moz-osx-font-smoothing: grayscale;\r\n}\r\n\r\ncode {\r\n font-family: source-code-pro, Menlo, Monaco, Consolas, \"Courier New\",\r\n monospace;\r\n}\r\n\r\n::-webkit-scrollbar {\r\n width: 10px;\r\n height: 10px;\r\n}\r\n\r\n::-webkit-scrollbar-track {\r\n box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);\r\n -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);\r\n /* border-radius: 7px; */\r\n}\r\n\r\n::-webkit-scrollbar-thumb {\r\n border-radius: 7px;\r\n box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.1);\r\n -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.1);\r\n background-color: #c8c8c8;\r\n}\r\n\r\n.exception-box textarea {\r\n white-space: nowrap !important;\r\n}\r\n"],"names":[],"sourceRoot":""} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/static/js/main.78b3d71a.js b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/static/js/main.78b3d71a.js new file mode 100644 index 000000000..9fdfa8647 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Schedule/Dashboard/frontend/static/js/main.78b3d71a.js @@ -0,0 +1,3 @@ +/*! For license information please see main.78b3d71a.js.LICENSE.txt */ +!function () { var e = { 9312: function (e) { var t = .1, n = "function" === typeof Float32Array; function r(e, t) { return 1 - 3 * t + 3 * e } function o(e, t) { return 3 * t - 6 * e } function a(e) { return 3 * e } function i(e, t, n) { return ((r(t, n) * e + o(t, n)) * e + a(t)) * e } function l(e, t, n) { return 3 * r(t, n) * e * e + 2 * o(t, n) * e + a(t) } function s(e) { return e } e.exports = function (e, r, o, a) { if (!(0 <= e && e <= 1 && 0 <= o && o <= 1)) throw new Error("bezier x values must be in [0, 1] range"); if (e === r && o === a) return s; for (var u = n ? new Float32Array(11) : new Array(11), c = 0; c < 11; ++c)u[c] = i(c * t, e, o); function d(n) { for (var r = 0, a = 1; 10 !== a && u[a] <= n; ++a)r += t; --a; var s = r + (n - u[a]) / (u[a + 1] - u[a]) * t, c = l(s, e, o); return c >= .001 ? function (e, t, n, r) { for (var o = 0; o < 4; ++o) { var a = l(t, n, r); if (0 === a) return t; t -= (i(t, n, r) - e) / a } return t }(n, s, e, o) : 0 === c ? s : function (e, t, n, r, o) { var a, l, s = 0; do { (a = i(l = t + (n - t) / 2, r, o) - e) > 0 ? n = l : t = l } while (Math.abs(a) > 1e-7 && ++s < 10); return l }(n, r, r + t, e, o) } return function (e) { return 0 === e ? 0 : 1 === e ? 1 : i(d(e), r, a) } } }, 1694: function (e, t) { var n; !function () { "use strict"; var r = {}.hasOwnProperty; function o() { for (var e = [], t = 0; t < arguments.length; t++) { var n = arguments[t]; if (n) { var a = typeof n; if ("string" === a || "number" === a) e.push(n); else if (Array.isArray(n)) { if (n.length) { var i = o.apply(null, n); i && e.push(i) } } else if ("object" === a) { if (n.toString !== Object.prototype.toString && !n.toString.toString().includes("[native code]")) { e.push(n.toString()); continue } for (var l in n) r.call(n, l) && n[l] && e.push(l) } } } return e.join(" ") } e.exports ? (o.default = o, e.exports = o) : void 0 === (n = function () { return o }.apply(t, [])) || (e.exports = n) }() }, 8182: function (e, t, n) { "use strict"; function r(e) { var t, n, o = ""; if ("string" == typeof e || "number" == typeof e) o += e; else if ("object" == typeof e) if (Array.isArray(e)) for (t = 0; t < e.length; t++)e[t] && (n = r(e[t])) && (o && (o += " "), o += n); else for (t in e) e[t] && (o && (o += " "), o += t); return o } function o() { for (var e, t, n = 0, o = ""; n < arguments.length;)(e = arguments[n++]) && (t = r(e)) && (o && (o += " "), o += t); return o } n.r(t), n.d(t, { clsx: function () { return o } }), t.default = o }, 2404: function (e) { "use strict"; var t = function (e) { var t = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {}, n = t.target, r = void 0 === n ? document.body : n, o = document.createElement("textarea"), a = document.activeElement; o.value = e, o.setAttribute("readonly", ""), o.style.contain = "strict", o.style.position = "absolute", o.style.left = "-9999px", o.style.fontSize = "12pt"; var i = document.getSelection(), l = !1; i.rangeCount > 0 && (l = i.getRangeAt(0)), r.append(o), o.select(), o.selectionStart = 0, o.selectionEnd = e.length; var s = !1; try { s = document.execCommand("copy") } catch (u) { } return o.remove(), l && (i.removeAllRanges(), i.addRange(l)), a && a.focus(), s }; e.exports = t, e.exports.default = t }, 7892: function (e) { e.exports = function () { "use strict"; var e = 1e3, t = 6e4, n = 36e5, r = "millisecond", o = "second", a = "minute", i = "hour", l = "day", s = "week", u = "month", c = "quarter", d = "year", f = "date", p = "Invalid Date", h = /^(\d{4})[-/]?(\d{1,2})?[-/]?(\d{0,2})[Tt\s]*(\d{1,2})?:?(\d{1,2})?:?(\d{1,2})?[.:]?(\d+)?$/, v = /\[([^\]]+)]|Y{1,4}|M{1,4}|D{1,2}|d{1,4}|H{1,2}|h{1,2}|a|A|m{1,2}|s{1,2}|Z{1,2}|SSS/g, g = { name: "en", weekdays: "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"), months: "January_February_March_April_May_June_July_August_September_October_November_December".split("_"), ordinal: function (e) { var t = ["th", "st", "nd", "rd"], n = e % 100; return "[" + e + (t[(n - 20) % 10] || t[n] || t[0]) + "]" } }, y = function (e, t, n) { var r = String(e); return !r || r.length >= t ? e : "" + Array(t + 1 - r.length).join(n) + e }, m = { s: y, z: function (e) { var t = -e.utcOffset(), n = Math.abs(t), r = Math.floor(n / 60), o = n % 60; return (t <= 0 ? "+" : "-") + y(r, 2, "0") + ":" + y(o, 2, "0") }, m: function e(t, n) { if (t.date() < n.date()) return -e(n, t); var r = 12 * (n.year() - t.year()) + (n.month() - t.month()), o = t.clone().add(r, u), a = n - o < 0, i = t.clone().add(r + (a ? -1 : 1), u); return +(-(r + (n - o) / (a ? o - i : i - o)) || 0) }, a: function (e) { return e < 0 ? Math.ceil(e) || 0 : Math.floor(e) }, p: function (e) { return { M: u, y: d, w: s, d: l, D: f, h: i, m: a, s: o, ms: r, Q: c }[e] || String(e || "").toLowerCase().replace(/s$/, "") }, u: function (e) { return void 0 === e } }, b = "en", w = {}; w[b] = g; var C = "$isDayjsObject", O = function (e) { return e instanceof k || !(!e || !e[C]) }, S = function e(t, n, r) { var o; if (!t) return b; if ("string" == typeof t) { var a = t.toLowerCase(); w[a] && (o = a), n && (w[a] = n, o = a); var i = t.split("-"); if (!o && i.length > 1) return e(i[0]) } else { var l = t.name; w[l] = t, o = l } return !r && o && (b = o), o || !r && b }, x = function (e, t) { if (O(e)) return e.clone(); var n = "object" == typeof t ? t : {}; return n.date = e, n.args = arguments, new k(n) }, E = m; E.l = S, E.i = O, E.w = function (e, t) { return x(e, { locale: t.$L, utc: t.$u, x: t.$x, $offset: t.$offset }) }; var k = function () { function g(e) { this.$L = S(e.locale, null, !0), this.parse(e), this.$x = this.$x || e.x || {}, this[C] = !0 } var y = g.prototype; return y.parse = function (e) { this.$d = function (e) { var t = e.date, n = e.utc; if (null === t) return new Date(NaN); if (E.u(t)) return new Date; if (t instanceof Date) return new Date(t); if ("string" == typeof t && !/Z$/i.test(t)) { var r = t.match(h); if (r) { var o = r[2] - 1 || 0, a = (r[7] || "0").substring(0, 3); return n ? new Date(Date.UTC(r[1], o, r[3] || 1, r[4] || 0, r[5] || 0, r[6] || 0, a)) : new Date(r[1], o, r[3] || 1, r[4] || 0, r[5] || 0, r[6] || 0, a) } } return new Date(t) }(e), this.init() }, y.init = function () { var e = this.$d; this.$y = e.getFullYear(), this.$M = e.getMonth(), this.$D = e.getDate(), this.$W = e.getDay(), this.$H = e.getHours(), this.$m = e.getMinutes(), this.$s = e.getSeconds(), this.$ms = e.getMilliseconds() }, y.$utils = function () { return E }, y.isValid = function () { return !(this.$d.toString() === p) }, y.isSame = function (e, t) { var n = x(e); return this.startOf(t) <= n && n <= this.endOf(t) }, y.isAfter = function (e, t) { return x(e) < this.startOf(t) }, y.isBefore = function (e, t) { return this.endOf(t) < x(e) }, y.$g = function (e, t, n) { return E.u(e) ? this[t] : this.set(n, e) }, y.unix = function () { return Math.floor(this.valueOf() / 1e3) }, y.valueOf = function () { return this.$d.getTime() }, y.startOf = function (e, t) { var n = this, r = !!E.u(t) || t, c = E.p(e), p = function (e, t) { var o = E.w(n.$u ? Date.UTC(n.$y, t, e) : new Date(n.$y, t, e), n); return r ? o : o.endOf(l) }, h = function (e, t) { return E.w(n.toDate()[e].apply(n.toDate("s"), (r ? [0, 0, 0, 0] : [23, 59, 59, 999]).slice(t)), n) }, v = this.$W, g = this.$M, y = this.$D, m = "set" + (this.$u ? "UTC" : ""); switch (c) { case d: return r ? p(1, 0) : p(31, 11); case u: return r ? p(1, g) : p(0, g + 1); case s: var b = this.$locale().weekStart || 0, w = (v < b ? v + 7 : v) - b; return p(r ? y - w : y + (6 - w), g); case l: case f: return h(m + "Hours", 0); case i: return h(m + "Minutes", 1); case a: return h(m + "Seconds", 2); case o: return h(m + "Milliseconds", 3); default: return this.clone() } }, y.endOf = function (e) { return this.startOf(e, !1) }, y.$set = function (e, t) { var n, s = E.p(e), c = "set" + (this.$u ? "UTC" : ""), p = (n = {}, n[l] = c + "Date", n[f] = c + "Date", n[u] = c + "Month", n[d] = c + "FullYear", n[i] = c + "Hours", n[a] = c + "Minutes", n[o] = c + "Seconds", n[r] = c + "Milliseconds", n)[s], h = s === l ? this.$D + (t - this.$W) : t; if (s === u || s === d) { var v = this.clone().set(f, 1); v.$d[p](h), v.init(), this.$d = v.set(f, Math.min(this.$D, v.daysInMonth())).$d } else p && this.$d[p](h); return this.init(), this }, y.set = function (e, t) { return this.clone().$set(e, t) }, y.get = function (e) { return this[E.p(e)]() }, y.add = function (r, c) { var f, p = this; r = Number(r); var h = E.p(c), v = function (e) { var t = x(p); return E.w(t.date(t.date() + Math.round(e * r)), p) }; if (h === u) return this.set(u, this.$M + r); if (h === d) return this.set(d, this.$y + r); if (h === l) return v(1); if (h === s) return v(7); var g = (f = {}, f[a] = t, f[i] = n, f[o] = e, f)[h] || 1, y = this.$d.getTime() + r * g; return E.w(y, this) }, y.subtract = function (e, t) { return this.add(-1 * e, t) }, y.format = function (e) { var t = this, n = this.$locale(); if (!this.isValid()) return n.invalidDate || p; var r = e || "YYYY-MM-DDTHH:mm:ssZ", o = E.z(this), a = this.$H, i = this.$m, l = this.$M, s = n.weekdays, u = n.months, c = n.meridiem, d = function (e, n, o, a) { return e && (e[n] || e(t, r)) || o[n].slice(0, a) }, f = function (e) { return E.s(a % 12 || 12, e, "0") }, h = c || function (e, t, n) { var r = e < 12 ? "AM" : "PM"; return n ? r.toLowerCase() : r }; return r.replace(v, (function (e, r) { return r || function (e) { switch (e) { case "YY": return String(t.$y).slice(-2); case "YYYY": return E.s(t.$y, 4, "0"); case "M": return l + 1; case "MM": return E.s(l + 1, 2, "0"); case "MMM": return d(n.monthsShort, l, u, 3); case "MMMM": return d(u, l); case "D": return t.$D; case "DD": return E.s(t.$D, 2, "0"); case "d": return String(t.$W); case "dd": return d(n.weekdaysMin, t.$W, s, 2); case "ddd": return d(n.weekdaysShort, t.$W, s, 3); case "dddd": return s[t.$W]; case "H": return String(a); case "HH": return E.s(a, 2, "0"); case "h": return f(1); case "hh": return f(2); case "a": return h(a, i, !0); case "A": return h(a, i, !1); case "m": return String(i); case "mm": return E.s(i, 2, "0"); case "s": return String(t.$s); case "ss": return E.s(t.$s, 2, "0"); case "SSS": return E.s(t.$ms, 3, "0"); case "Z": return o }return null }(e) || o.replace(":", "") })) }, y.utcOffset = function () { return 15 * -Math.round(this.$d.getTimezoneOffset() / 15) }, y.diff = function (r, f, p) { var h, v = this, g = E.p(f), y = x(r), m = (y.utcOffset() - this.utcOffset()) * t, b = this - y, w = function () { return E.m(v, y) }; switch (g) { case d: h = w() / 12; break; case u: h = w(); break; case c: h = w() / 3; break; case s: h = (b - m) / 6048e5; break; case l: h = (b - m) / 864e5; break; case i: h = b / n; break; case a: h = b / t; break; case o: h = b / e; break; default: h = b }return p ? h : E.a(h) }, y.daysInMonth = function () { return this.endOf(u).$D }, y.$locale = function () { return w[this.$L] }, y.locale = function (e, t) { if (!e) return this.$L; var n = this.clone(), r = S(e, t, !0); return r && (n.$L = r), n }, y.clone = function () { return E.w(this.$d, this) }, y.toDate = function () { return new Date(this.valueOf()) }, y.toJSON = function () { return this.isValid() ? this.toISOString() : null }, y.toISOString = function () { return this.$d.toISOString() }, y.toString = function () { return this.$d.toUTCString() }, g }(), _ = k.prototype; return x.prototype = _, [["$ms", r], ["$s", o], ["$m", a], ["$H", i], ["$W", l], ["$M", u], ["$y", d], ["$D", f]].forEach((function (e) { _[e[1]] = function (t) { return this.$g(t, e[0], e[1]) } })), x.extend = function (e, t) { return e.$i || (e(t, k, x), e.$i = !0), x }, x.locale = S, x.isDayjs = O, x.unix = function (e) { return x(1e3 * e) }, x.en = w[b], x.Ls = w, x.p = {}, x }() }, 7490: function (e, t, n) { e.exports = function (e) { "use strict"; function t(e) { return e && "object" == typeof e && "default" in e ? e : { default: e } } var n = t(e), r = { name: "zh-cn", weekdays: "\u661f\u671f\u65e5_\u661f\u671f\u4e00_\u661f\u671f\u4e8c_\u661f\u671f\u4e09_\u661f\u671f\u56db_\u661f\u671f\u4e94_\u661f\u671f\u516d".split("_"), weekdaysShort: "\u5468\u65e5_\u5468\u4e00_\u5468\u4e8c_\u5468\u4e09_\u5468\u56db_\u5468\u4e94_\u5468\u516d".split("_"), weekdaysMin: "\u65e5_\u4e00_\u4e8c_\u4e09_\u56db_\u4e94_\u516d".split("_"), months: "\u4e00\u6708_\u4e8c\u6708_\u4e09\u6708_\u56db\u6708_\u4e94\u6708_\u516d\u6708_\u4e03\u6708_\u516b\u6708_\u4e5d\u6708_\u5341\u6708_\u5341\u4e00\u6708_\u5341\u4e8c\u6708".split("_"), monthsShort: "1\u6708_2\u6708_3\u6708_4\u6708_5\u6708_6\u6708_7\u6708_8\u6708_9\u6708_10\u6708_11\u6708_12\u6708".split("_"), ordinal: function (e, t) { return "W" === t ? e + "\u5468" : e + "\u65e5" }, weekStart: 1, yearStart: 4, formats: { LT: "HH:mm", LTS: "HH:mm:ss", L: "YYYY/MM/DD", LL: "YYYY\u5e74M\u6708D\u65e5", LLL: "YYYY\u5e74M\u6708D\u65e5Ah\u70b9mm\u5206", LLLL: "YYYY\u5e74M\u6708D\u65e5ddddAh\u70b9mm\u5206", l: "YYYY/M/D", ll: "YYYY\u5e74M\u6708D\u65e5", lll: "YYYY\u5e74M\u6708D\u65e5 HH:mm", llll: "YYYY\u5e74M\u6708D\u65e5dddd HH:mm" }, relativeTime: { future: "%s\u5185", past: "%s\u524d", s: "\u51e0\u79d2", m: "1 \u5206\u949f", mm: "%d \u5206\u949f", h: "1 \u5c0f\u65f6", hh: "%d \u5c0f\u65f6", d: "1 \u5929", dd: "%d \u5929", M: "1 \u4e2a\u6708", MM: "%d \u4e2a\u6708", y: "1 \u5e74", yy: "%d \u5e74" }, meridiem: function (e, t) { var n = 100 * e + t; return n < 600 ? "\u51cc\u6668" : n < 900 ? "\u65e9\u4e0a" : n < 1100 ? "\u4e0a\u5348" : n < 1300 ? "\u4e2d\u5348" : n < 1800 ? "\u4e0b\u5348" : "\u665a\u4e0a" } }; return n.default.locale(r, null, !0), r }(n(7892)) }, 130: function (e) { e.exports = function () { "use strict"; return function (e, t, n) { e = e || {}; var r = t.prototype, o = { future: "in %s", past: "%s ago", s: "a few seconds", m: "a minute", mm: "%d minutes", h: "an hour", hh: "%d hours", d: "a day", dd: "%d days", M: "a month", MM: "%d months", y: "a year", yy: "%d years" }; function a(e, t, n, o) { return r.fromToBase(e, t, n, o) } n.en.relativeTime = o, r.fromToBase = function (t, r, a, i, l) { for (var s, u, c, d = a.$locale().relativeTime || o, f = e.thresholds || [{ l: "s", r: 44, d: "second" }, { l: "m", r: 89 }, { l: "mm", r: 44, d: "minute" }, { l: "h", r: 89 }, { l: "hh", r: 21, d: "hour" }, { l: "d", r: 35 }, { l: "dd", r: 25, d: "day" }, { l: "M", r: 45 }, { l: "MM", r: 10, d: "month" }, { l: "y", r: 17 }, { l: "yy", d: "year" }], p = f.length, h = 0; h < p; h += 1) { var v = f[h]; v.d && (s = i ? n(t).diff(a, v.d, !0) : a.diff(t, v.d, !0)); var g = (e.rounding || Math.round)(Math.abs(s)); if (c = s > 0, g <= v.r || !v.r) { g <= 1 && h > 0 && (v = f[h - 1]); var y = d[v.l]; l && (g = l("" + g)), u = "string" == typeof y ? y.replace("%d", g) : y(g, r, v.l, c); break } } if (r) return u; var m = c ? d.future : d.past; return "function" == typeof m ? m(u) : m.replace("%s", u) }, r.to = function (e, t) { return a(e, t, this, !0) }, r.from = function (e, t) { return a(e, t, this) }; var i = function (e) { return e.$u ? n.utc() : n() }; r.toNow = function (e) { return this.to(i(this), e) }, r.fromNow = function (e) { return this.from(i(this), e) } } }() }, 3027: function (e) { e.exports = function () { "use strict"; var e = "minute", t = /[+-]\d\d(?::?\d\d)?/g, n = /([+-]|\d\d)/g; return function (r, o, a) { var i = o.prototype; a.utc = function (e) { return new o({ date: e, utc: !0, args: arguments }) }, i.utc = function (t) { var n = a(this.toDate(), { locale: this.$L, utc: !0 }); return t ? n.add(this.utcOffset(), e) : n }, i.local = function () { return a(this.toDate(), { locale: this.$L, utc: !1 }) }; var l = i.parse; i.parse = function (e) { e.utc && (this.$u = !0), this.$utils().u(e.$offset) || (this.$offset = e.$offset), l.call(this, e) }; var s = i.init; i.init = function () { if (this.$u) { var e = this.$d; this.$y = e.getUTCFullYear(), this.$M = e.getUTCMonth(), this.$D = e.getUTCDate(), this.$W = e.getUTCDay(), this.$H = e.getUTCHours(), this.$m = e.getUTCMinutes(), this.$s = e.getUTCSeconds(), this.$ms = e.getUTCMilliseconds() } else s.call(this) }; var u = i.utcOffset; i.utcOffset = function (r, o) { var a = this.$utils().u; if (a(r)) return this.$u ? 0 : a(this.$offset) ? u.call(this) : this.$offset; if ("string" == typeof r && (r = function (e) { void 0 === e && (e = ""); var r = e.match(t); if (!r) return null; var o = ("" + r[0]).match(n) || ["-", 0, 0], a = o[0], i = 60 * +o[1] + +o[2]; return 0 === i ? 0 : "+" === a ? i : -i }(r), null === r)) return this; var i = Math.abs(r) <= 16 ? 60 * r : r, l = this; if (o) return l.$offset = i, l.$u = 0 === r, l; if (0 !== r) { var s = this.$u ? this.toDate().getTimezoneOffset() : -1 * this.utcOffset(); (l = this.local().add(i + s, e)).$offset = i, l.$x.$localOffset = s } else l = this.utc(); return l }; var c = i.format; i.format = function (e) { var t = e || (this.$u ? "YYYY-MM-DDTHH:mm:ss[Z]" : ""); return c.call(this, t) }, i.valueOf = function () { var e = this.$utils().u(this.$offset) ? 0 : this.$offset + (this.$x.$localOffset || this.$d.getTimezoneOffset()); return this.$d.valueOf() - 6e4 * e }, i.isUTC = function () { return !!this.$u }, i.toISOString = function () { return this.toDate().toISOString() }, i.toString = function () { return this.toDate().toUTCString() }; var d = i.toDate; i.toDate = function (e) { return "s" === e && this.$offset ? a(this.format("YYYY-MM-DD HH:mm:ss:SSS")).toDate() : d.call(this) }; var f = i.diff; i.diff = function (e, t, n) { if (e && this.$u === e.$u) return f.call(this, e, t, n); var r = this.local(), o = a(e).local(); return f.call(r, o, t, n) } } }() }, 908: function (e, t, n) { var r = n(8136)(n(7009), "DataView"); e.exports = r }, 9676: function (e, t, n) { var r = n(5403), o = n(2747), a = n(6037), i = n(4154), l = n(7728); function s(e) { var t = -1, n = null == e ? 0 : e.length; for (this.clear(); ++t < n;) { var r = e[t]; this.set(r[0], r[1]) } } s.prototype.clear = r, s.prototype.delete = o, s.prototype.get = a, s.prototype.has = i, s.prototype.set = l, e.exports = s }, 8384: function (e, t, n) { var r = n(3894), o = n(8699), a = n(4957), i = n(7184), l = n(7109); function s(e) { var t = -1, n = null == e ? 0 : e.length; for (this.clear(); ++t < n;) { var r = e[t]; this.set(r[0], r[1]) } } s.prototype.clear = r, s.prototype.delete = o, s.prototype.get = a, s.prototype.has = i, s.prototype.set = l, e.exports = s }, 5797: function (e, t, n) { var r = n(8136)(n(7009), "Map"); e.exports = r }, 8059: function (e, t, n) { var r = n(4086), o = n(9255), a = n(9186), i = n(3423), l = n(3739); function s(e) { var t = -1, n = null == e ? 0 : e.length; for (this.clear(); ++t < n;) { var r = e[t]; this.set(r[0], r[1]) } } s.prototype.clear = r, s.prototype.delete = o, s.prototype.get = a, s.prototype.has = i, s.prototype.set = l, e.exports = s }, 8319: function (e, t, n) { var r = n(8136)(n(7009), "Promise"); e.exports = r }, 3924: function (e, t, n) { var r = n(8136)(n(7009), "Set"); e.exports = r }, 692: function (e, t, n) { var r = n(8059), o = n(5774), a = n(1596); function i(e) { var t = -1, n = null == e ? 0 : e.length; for (this.__data__ = new r; ++t < n;)this.add(e[t]) } i.prototype.add = i.prototype.push = o, i.prototype.has = a, e.exports = i }, 2854: function (e, t, n) { var r = n(8384), o = n(511), a = n(835), i = n(707), l = n(8832), s = n(5077); function u(e) { var t = this.__data__ = new r(e); this.size = t.size } u.prototype.clear = o, u.prototype.delete = a, u.prototype.get = i, u.prototype.has = l, u.prototype.set = s, e.exports = u }, 7197: function (e, t, n) { var r = n(7009).Symbol; e.exports = r }, 6219: function (e, t, n) { var r = n(7009).Uint8Array; e.exports = r }, 7091: function (e, t, n) { var r = n(8136)(n(7009), "WeakMap"); e.exports = r }, 3665: function (e) { e.exports = function (e, t, n) { switch (n.length) { case 0: return e.call(t); case 1: return e.call(t, n[0]); case 2: return e.call(t, n[0], n[1]); case 3: return e.call(t, n[0], n[1], n[2]) }return e.apply(t, n) } }, 4550: function (e) { e.exports = function (e, t) { for (var n = -1, r = null == e ? 0 : e.length; ++n < r && !1 !== t(e[n], n, e);); return e } }, 4903: function (e) { e.exports = function (e, t) { for (var n = -1, r = null == e ? 0 : e.length, o = 0, a = []; ++n < r;) { var i = e[n]; t(i, n, e) && (a[o++] = i) } return a } }, 9055: function (e, t, n) { var r = n(4842); e.exports = function (e, t) { return !!(null == e ? 0 : e.length) && r(e, t, 0) > -1 } }, 2683: function (e) { e.exports = function (e, t, n) { for (var r = -1, o = null == e ? 0 : e.length; ++r < o;)if (n(t, e[r])) return !0; return !1 } }, 7538: function (e, t, n) { var r = n(6478), o = n(4963), a = n(3629), i = n(5174), l = n(6800), s = n(9102), u = Object.prototype.hasOwnProperty; e.exports = function (e, t) { var n = a(e), c = !n && o(e), d = !n && !c && i(e), f = !n && !c && !d && s(e), p = n || c || d || f, h = p ? r(e.length, String) : [], v = h.length; for (var g in e) !t && !u.call(e, g) || p && ("length" == g || d && ("offset" == g || "parent" == g) || f && ("buffer" == g || "byteLength" == g || "byteOffset" == g) || l(g, v)) || h.push(g); return h } }, 8950: function (e) { e.exports = function (e, t) { for (var n = -1, r = null == e ? 0 : e.length, o = Array(r); ++n < r;)o[n] = t(e[n], n, e); return o } }, 1705: function (e) { e.exports = function (e, t) { for (var n = -1, r = t.length, o = e.length; ++n < r;)e[o + n] = t[n]; return e } }, 7897: function (e) { e.exports = function (e, t) { for (var n = -1, r = null == e ? 0 : e.length; ++n < r;)if (t(e[n], n, e)) return !0; return !1 } }, 405: function (e, t, n) { var r = n(9586)("length"); e.exports = r }, 4622: function (e) { e.exports = function (e) { return e.split("") } }, 8002: function (e, t, n) { var r = n(2526), o = n(9231); e.exports = function (e, t, n) { (void 0 !== n && !o(e[t], n) || void 0 === n && !(t in e)) && r(e, t, n) } }, 8463: function (e, t, n) { var r = n(2526), o = n(9231), a = Object.prototype.hasOwnProperty; e.exports = function (e, t, n) { var i = e[t]; a.call(e, t) && o(i, n) && (void 0 !== n || t in e) || r(e, t, n) } }, 7112: function (e, t, n) { var r = n(9231); e.exports = function (e, t) { for (var n = e.length; n--;)if (r(e[n][0], t)) return n; return -1 } }, 1855: function (e, t, n) { var r = n(4503), o = n(2742); e.exports = function (e, t) { return e && r(t, o(t), e) } }, 5076: function (e, t, n) { var r = n(4503), o = n(3961); e.exports = function (e, t) { return e && r(t, o(t), e) } }, 2526: function (e, t, n) { var r = n(8528); e.exports = function (e, t, n) { "__proto__" == t && r ? r(e, t, { configurable: !0, enumerable: !0, value: n, writable: !0 }) : e[t] = n } }, 1905: function (e, t, n) { var r = n(2854), o = n(4550), a = n(8463), i = n(1855), l = n(5076), s = n(4523), u = n(291), c = n(2455), d = n(7636), f = n(8248), p = n(5341), h = n(8383), v = n(9243), g = n(9759), y = n(548), m = n(3629), b = n(5174), w = n(103), C = n(8092), O = n(6995), S = n(2742), x = n(3961), E = "[object Arguments]", k = "[object Function]", _ = "[object Object]", P = {}; P[E] = P["[object Array]"] = P["[object ArrayBuffer]"] = P["[object DataView]"] = P["[object Boolean]"] = P["[object Date]"] = P["[object Float32Array]"] = P["[object Float64Array]"] = P["[object Int8Array]"] = P["[object Int16Array]"] = P["[object Int32Array]"] = P["[object Map]"] = P["[object Number]"] = P[_] = P["[object RegExp]"] = P["[object Set]"] = P["[object String]"] = P["[object Symbol]"] = P["[object Uint8Array]"] = P["[object Uint8ClampedArray]"] = P["[object Uint16Array]"] = P["[object Uint32Array]"] = !0, P["[object Error]"] = P[k] = P["[object WeakMap]"] = !1, e.exports = function e(t, n, T, j, R, I) { var N, D = 1 & n, M = 2 & n, L = 4 & n; if (T && (N = R ? T(t, j, R, I) : T(t)), void 0 !== N) return N; if (!C(t)) return t; var A = m(t); if (A) { if (N = v(t), !D) return u(t, N) } else { var F = h(t), z = F == k || "[object GeneratorFunction]" == F; if (b(t)) return s(t, D); if (F == _ || F == E || z && !R) { if (N = M || z ? {} : y(t), !D) return M ? d(t, l(N, t)) : c(t, i(N, t)) } else { if (!P[F]) return R ? t : {}; N = g(t, F, D) } } I || (I = new r); var H = I.get(t); if (H) return H; I.set(t, N), O(t) ? t.forEach((function (r) { N.add(e(r, n, T, r, t, I)) })) : w(t) && t.forEach((function (r, o) { N.set(o, e(r, n, T, o, t, I)) })); var B = A ? void 0 : (L ? M ? p : f : M ? x : S)(t); return o(B || t, (function (r, o) { B && (r = t[o = r]), a(N, o, e(r, n, T, o, t, I)) })), N } }, 5763: function (e, t, n) { var r = n(8092), o = Object.create, a = function () { function e() { } return function (t) { if (!r(t)) return {}; if (o) return o(t); e.prototype = t; var n = new e; return e.prototype = void 0, n } }(); e.exports = a }, 1468: function (e, t, n) { var r = n(692), o = n(9055), a = n(2683), i = n(8950), l = n(6194), s = n(75); e.exports = function (e, t, n, u) { var c = -1, d = o, f = !0, p = e.length, h = [], v = t.length; if (!p) return h; n && (t = i(t, l(n))), u ? (d = a, f = !1) : t.length >= 200 && (d = s, f = !1, t = new r(t)); e: for (; ++c < p;) { var g = e[c], y = null == n ? g : n(g); if (g = u || 0 !== g ? g : 0, f && y === y) { for (var m = v; m--;)if (t[m] === y) continue e; h.push(g) } else d(t, y, u) || h.push(g) } return h } }, 7927: function (e, t, n) { var r = n(5358), o = n(7056)(r); e.exports = o }, 7523: function (e, t, n) { var r = n(7927); e.exports = function (e, t) { var n = []; return r(e, (function (e, r, o) { t(e, r, o) && n.push(e) })), n } }, 2045: function (e) { e.exports = function (e, t, n, r) { for (var o = e.length, a = n + (r ? 1 : -1); r ? a-- : ++a < o;)if (t(e[a], a, e)) return a; return -1 } }, 5182: function (e, t, n) { var r = n(1705), o = n(3529); e.exports = function e(t, n, a, i, l) { var s = -1, u = t.length; for (a || (a = o), l || (l = []); ++s < u;) { var c = t[s]; n > 0 && a(c) ? n > 1 ? e(c, n - 1, a, i, l) : r(l, c) : i || (l[l.length] = c) } return l } }, 5099: function (e, t, n) { var r = n(372)(); e.exports = r }, 5358: function (e, t, n) { var r = n(5099), o = n(2742); e.exports = function (e, t) { return e && r(e, t, o) } }, 8667: function (e, t, n) { var r = n(3082), o = n(9793); e.exports = function (e, t) { for (var n = 0, a = (t = r(t, e)).length; null != e && n < a;)e = e[o(t[n++])]; return n && n == a ? e : void 0 } }, 1986: function (e, t, n) { var r = n(1705), o = n(3629); e.exports = function (e, t, n) { var a = t(e); return o(e) ? a : r(a, n(e)) } }, 9066: function (e, t, n) { var r = n(7197), o = n(1587), a = n(3581), i = r ? r.toStringTag : void 0; e.exports = function (e) { return null == e ? void 0 === e ? "[object Undefined]" : "[object Null]" : i && i in Object(e) ? o(e) : a(e) } }, 7852: function (e) { var t = Object.prototype.hasOwnProperty; e.exports = function (e, n) { return null != e && t.call(e, n) } }, 529: function (e) { e.exports = function (e, t) { return null != e && t in Object(e) } }, 4842: function (e, t, n) { var r = n(2045), o = n(505), a = n(7167); e.exports = function (e, t, n) { return t === t ? a(e, t, n) : r(e, o, n) } }, 4032: function (e) { e.exports = function (e, t, n, r) { for (var o = n - 1, a = e.length; ++o < a;)if (r(e[o], t)) return o; return -1 } }, 4906: function (e, t, n) { var r = n(9066), o = n(3141); e.exports = function (e) { return o(e) && "[object Arguments]" == r(e) } }, 1848: function (e, t, n) { var r = n(3355), o = n(3141); e.exports = function e(t, n, a, i, l) { return t === n || (null == t || null == n || !o(t) && !o(n) ? t !== t && n !== n : r(t, n, a, i, e, l)) } }, 3355: function (e, t, n) { var r = n(2854), o = n(5305), a = n(2206), i = n(8078), l = n(8383), s = n(3629), u = n(5174), c = n(9102), d = "[object Arguments]", f = "[object Array]", p = "[object Object]", h = Object.prototype.hasOwnProperty; e.exports = function (e, t, n, v, g, y) { var m = s(e), b = s(t), w = m ? f : l(e), C = b ? f : l(t), O = (w = w == d ? p : w) == p, S = (C = C == d ? p : C) == p, x = w == C; if (x && u(e)) { if (!u(t)) return !1; m = !0, O = !1 } if (x && !O) return y || (y = new r), m || c(e) ? o(e, t, n, v, g, y) : a(e, t, w, n, v, g, y); if (!(1 & n)) { var E = O && h.call(e, "__wrapped__"), k = S && h.call(t, "__wrapped__"); if (E || k) { var _ = E ? e.value() : e, P = k ? t.value() : t; return y || (y = new r), g(_, P, n, v, y) } } return !!x && (y || (y = new r), i(e, t, n, v, g, y)) } }, 3085: function (e, t, n) { var r = n(8383), o = n(3141); e.exports = function (e) { return o(e) && "[object Map]" == r(e) } }, 8856: function (e, t, n) { var r = n(2854), o = n(1848); e.exports = function (e, t, n, a) { var i = n.length, l = i, s = !a; if (null == e) return !l; for (e = Object(e); i--;) { var u = n[i]; if (s && u[2] ? u[1] !== e[u[0]] : !(u[0] in e)) return !1 } for (; ++i < l;) { var c = (u = n[i])[0], d = e[c], f = u[1]; if (s && u[2]) { if (void 0 === d && !(c in e)) return !1 } else { var p = new r; if (a) var h = a(d, f, c, e, t, p); if (!(void 0 === h ? o(f, d, 3, a, p) : h)) return !1 } } return !0 } }, 505: function (e) { e.exports = function (e) { return e !== e } }, 6703: function (e, t, n) { var r = n(4786), o = n(257), a = n(8092), i = n(7907), l = /^\[object .+?Constructor\]$/, s = Function.prototype, u = Object.prototype, c = s.toString, d = u.hasOwnProperty, f = RegExp("^" + c.call(d).replace(/[\\^$.*+?()[\]{}|]/g, "\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g, "$1.*?") + "$"); e.exports = function (e) { return !(!a(e) || o(e)) && (r(e) ? f : l).test(i(e)) } }, 7817: function (e, t, n) { var r = n(9066), o = n(3141); e.exports = function (e) { return o(e) && "[object RegExp]" == r(e) } }, 8680: function (e, t, n) { var r = n(8383), o = n(3141); e.exports = function (e) { return o(e) && "[object Set]" == r(e) } }, 8150: function (e, t, n) { var r = n(9066), o = n(4635), a = n(3141), i = {}; i["[object Float32Array]"] = i["[object Float64Array]"] = i["[object Int8Array]"] = i["[object Int16Array]"] = i["[object Int32Array]"] = i["[object Uint8Array]"] = i["[object Uint8ClampedArray]"] = i["[object Uint16Array]"] = i["[object Uint32Array]"] = !0, i["[object Arguments]"] = i["[object Array]"] = i["[object ArrayBuffer]"] = i["[object Boolean]"] = i["[object DataView]"] = i["[object Date]"] = i["[object Error]"] = i["[object Function]"] = i["[object Map]"] = i["[object Number]"] = i["[object Object]"] = i["[object RegExp]"] = i["[object Set]"] = i["[object String]"] = i["[object WeakMap]"] = !1, e.exports = function (e) { return a(e) && o(e.length) && !!i[r(e)] } }, 6025: function (e, t, n) { var r = n(7080), o = n(4322), a = n(2100), i = n(3629), l = n(38); e.exports = function (e) { return "function" == typeof e ? e : null == e ? a : "object" == typeof e ? i(e) ? o(e[0], e[1]) : r(e) : l(e) } }, 3654: function (e, t, n) { var r = n(2936), o = n(8836), a = Object.prototype.hasOwnProperty; e.exports = function (e) { if (!r(e)) return o(e); var t = []; for (var n in Object(e)) a.call(e, n) && "constructor" != n && t.push(n); return t } }, 8664: function (e, t, n) { var r = n(8092), o = n(2936), a = n(4221), i = Object.prototype.hasOwnProperty; e.exports = function (e) { if (!r(e)) return a(e); var t = o(e), n = []; for (var l in e) ("constructor" != l || !t && i.call(e, l)) && n.push(l); return n } }, 3849: function (e, t, n) { var r = n(7927), o = n(1473); e.exports = function (e, t) { var n = -1, a = o(e) ? Array(e.length) : []; return r(e, (function (e, r, o) { a[++n] = t(e, r, o) })), a } }, 7080: function (e, t, n) { var r = n(8856), o = n(9091), a = n(284); e.exports = function (e) { var t = o(e); return 1 == t.length && t[0][2] ? a(t[0][0], t[0][1]) : function (n) { return n === e || r(n, e, t) } } }, 4322: function (e, t, n) { var r = n(1848), o = n(6181), a = n(5658), i = n(5823), l = n(5072), s = n(284), u = n(9793); e.exports = function (e, t) { return i(e) && l(t) ? s(u(e), t) : function (n) { var i = o(n, e); return void 0 === i && i === t ? a(n, e) : r(t, i, 3) } } }, 4173: function (e, t, n) { var r = n(2854), o = n(8002), a = n(5099), i = n(9216), l = n(8092), s = n(3961), u = n(5906); e.exports = function e(t, n, c, d, f) { t !== n && a(n, (function (a, s) { if (f || (f = new r), l(a)) i(t, n, s, c, e, d, f); else { var p = d ? d(u(t, s), a, s + "", t, n, f) : void 0; void 0 === p && (p = a), o(t, s, p) } }), s) } }, 9216: function (e, t, n) { var r = n(8002), o = n(4523), a = n(613), i = n(291), l = n(548), s = n(4963), u = n(3629), c = n(6279), d = n(5174), f = n(4786), p = n(8092), h = n(3977), v = n(9102), g = n(5906), y = n(6576); e.exports = function (e, t, n, m, b, w, C) { var O = g(e, n), S = g(t, n), x = C.get(S); if (x) r(e, n, x); else { var E = w ? w(O, S, n + "", e, t, C) : void 0, k = void 0 === E; if (k) { var _ = u(S), P = !_ && d(S), T = !_ && !P && v(S); E = S, _ || P || T ? u(O) ? E = O : c(O) ? E = i(O) : P ? (k = !1, E = o(S, !0)) : T ? (k = !1, E = a(S, !0)) : E = [] : h(S) || s(S) ? (E = O, s(O) ? E = y(O) : p(O) && !f(O) || (E = l(S))) : k = !1 } k && (C.set(S, E), b(E, S, m, w, C), C.delete(S)), r(e, n, E) } } }, 4980: function (e, t, n) { var r = n(2591), o = n(5658); e.exports = function (e, t) { return r(e, t, (function (t, n) { return o(e, n) })) } }, 2591: function (e, t, n) { var r = n(8667), o = n(379), a = n(3082); e.exports = function (e, t, n) { for (var i = -1, l = t.length, s = {}; ++i < l;) { var u = t[i], c = r(e, u); n(c, u) && o(s, a(u, e), c) } return s } }, 9586: function (e) { e.exports = function (e) { return function (t) { return null == t ? void 0 : t[e] } } }, 4084: function (e, t, n) { var r = n(8667); e.exports = function (e) { return function (t) { return r(t, e) } } }, 2664: function (e, t, n) { var r = n(8950), o = n(4842), a = n(4032), i = n(6194), l = n(291), s = Array.prototype.splice; e.exports = function (e, t, n, u) { var c = u ? a : o, d = -1, f = t.length, p = e; for (e === t && (t = l(t)), n && (p = r(e, i(n))); ++d < f;)for (var h = 0, v = t[d], g = n ? n(v) : v; (h = c(p, g, h, u)) > -1;)p !== e && s.call(p, h, 1), s.call(e, h, 1); return e } }, 8794: function (e, t, n) { var r = n(2100), o = n(4262), a = n(9156); e.exports = function (e, t) { return a(o(e, t, r), e + "") } }, 379: function (e, t, n) { var r = n(8463), o = n(3082), a = n(6800), i = n(8092), l = n(9793); e.exports = function (e, t, n, s) { if (!i(e)) return e; for (var u = -1, c = (t = o(t, e)).length, d = c - 1, f = e; null != f && ++u < c;) { var p = l(t[u]), h = n; if ("__proto__" === p || "constructor" === p || "prototype" === p) return e; if (u != d) { var v = f[p]; void 0 === (h = s ? s(v, p, f) : void 0) && (h = i(v) ? v : a(t[u + 1]) ? [] : {}) } r(f, p, h), f = f[p] } return e } }, 7532: function (e, t, n) { var r = n(1547), o = n(8528), a = n(2100), i = o ? function (e, t) { return o(e, "toString", { configurable: !0, enumerable: !1, value: r(t), writable: !0 }) } : a; e.exports = i }, 2646: function (e) { e.exports = function (e, t, n) { var r = -1, o = e.length; t < 0 && (t = -t > o ? 0 : o + t), (n = n > o ? o : n) < 0 && (n += o), o = t > n ? 0 : n - t >>> 0, t >>>= 0; for (var a = Array(o); ++r < o;)a[r] = e[r + t]; return a } }, 9204: function (e, t, n) { var r = n(7927); e.exports = function (e, t) { var n; return r(e, (function (e, r, o) { return !(n = t(e, r, o)) })), !!n } }, 6478: function (e) { e.exports = function (e, t) { for (var n = -1, r = Array(e); ++n < e;)r[n] = t(n); return r } }, 2446: function (e, t, n) { var r = n(7197), o = n(8950), a = n(3629), i = n(152), l = r ? r.prototype : void 0, s = l ? l.toString : void 0; e.exports = function e(t) { if ("string" == typeof t) return t; if (a(t)) return o(t, e) + ""; if (i(t)) return s ? s.call(t) : ""; var n = t + ""; return "0" == n && 1 / t == -Infinity ? "-0" : n } }, 821: function (e, t, n) { var r = n(6050), o = /^\s+/; e.exports = function (e) { return e ? e.slice(0, r(e) + 1).replace(o, "") : e } }, 6194: function (e) { e.exports = function (e) { return function (t) { return e(t) } } }, 6555: function (e, t, n) { var r = n(3082), o = n(5727), a = n(8978), i = n(9793); e.exports = function (e, t) { return t = r(t, e), null == (e = a(e, t)) || delete e[i(o(t))] } }, 8019: function (e, t, n) { var r = n(8950); e.exports = function (e, t) { return r(t, (function (t) { return e[t] })) } }, 75: function (e) { e.exports = function (e, t) { return e.has(t) } }, 3410: function (e, t, n) { var r = n(2100); e.exports = function (e) { return "function" == typeof e ? e : r } }, 3082: function (e, t, n) { var r = n(3629), o = n(5823), a = n(170), i = n(3518); e.exports = function (e, t) { return r(e) ? e : o(e, t) ? [e] : a(i(e)) } }, 9813: function (e, t, n) { var r = n(2646); e.exports = function (e, t, n) { var o = e.length; return n = void 0 === n ? o : n, !t && n >= o ? e : r(e, t, n) } }, 7010: function (e, t, n) { var r = n(6219); e.exports = function (e) { var t = new e.constructor(e.byteLength); return new r(t).set(new r(e)), t } }, 4523: function (e, t, n) { e = n.nmd(e); var r = n(7009), o = t && !t.nodeType && t, a = o && e && !e.nodeType && e, i = a && a.exports === o ? r.Buffer : void 0, l = i ? i.allocUnsafe : void 0; e.exports = function (e, t) { if (t) return e.slice(); var n = e.length, r = l ? l(n) : new e.constructor(n); return e.copy(r), r } }, 1022: function (e, t, n) { var r = n(7010); e.exports = function (e, t) { var n = t ? r(e.buffer) : e.buffer; return new e.constructor(n, e.byteOffset, e.byteLength) } }, 8503: function (e) { var t = /\w*$/; e.exports = function (e) { var n = new e.constructor(e.source, t.exec(e)); return n.lastIndex = e.lastIndex, n } }, 4720: function (e, t, n) { var r = n(7197), o = r ? r.prototype : void 0, a = o ? o.valueOf : void 0; e.exports = function (e) { return a ? Object(a.call(e)) : {} } }, 613: function (e, t, n) { var r = n(7010); e.exports = function (e, t) { var n = t ? r(e.buffer) : e.buffer; return new e.constructor(n, e.byteOffset, e.length) } }, 291: function (e) { e.exports = function (e, t) { var n = -1, r = e.length; for (t || (t = Array(r)); ++n < r;)t[n] = e[n]; return t } }, 4503: function (e, t, n) { var r = n(8463), o = n(2526); e.exports = function (e, t, n, a) { var i = !n; n || (n = {}); for (var l = -1, s = t.length; ++l < s;) { var u = t[l], c = a ? a(n[u], e[u], u, n, e) : void 0; void 0 === c && (c = e[u]), i ? o(n, u, c) : r(n, u, c) } return n } }, 2455: function (e, t, n) { var r = n(4503), o = n(5918); e.exports = function (e, t) { return r(e, o(e), t) } }, 7636: function (e, t, n) { var r = n(4503), o = n(8487); e.exports = function (e, t) { return r(e, o(e), t) } }, 5525: function (e, t, n) { var r = n(7009)["__core-js_shared__"]; e.exports = r }, 9934: function (e, t, n) { var r = n(8794), o = n(3195); e.exports = function (e) { return r((function (t, n) { var r = -1, a = n.length, i = a > 1 ? n[a - 1] : void 0, l = a > 2 ? n[2] : void 0; for (i = e.length > 3 && "function" == typeof i ? (a--, i) : void 0, l && o(n[0], n[1], l) && (i = a < 3 ? void 0 : i, a = 1), t = Object(t); ++r < a;) { var s = n[r]; s && e(t, s, r, i) } return t })) } }, 7056: function (e, t, n) { var r = n(1473); e.exports = function (e, t) { return function (n, o) { if (null == n) return n; if (!r(n)) return e(n, o); for (var a = n.length, i = t ? a : -1, l = Object(n); (t ? i-- : ++i < a) && !1 !== o(l[i], i, l);); return n } } }, 372: function (e) { e.exports = function (e) { return function (t, n, r) { for (var o = -1, a = Object(t), i = r(t), l = i.length; l--;) { var s = i[e ? l : ++o]; if (!1 === n(a[s], s, a)) break } return t } } }, 5481: function (e, t, n) { var r = n(6025), o = n(1473), a = n(2742); e.exports = function (e) { return function (t, n, i) { var l = Object(t); if (!o(t)) { var s = r(n, 3); t = a(t), n = function (e) { return s(l[e], e, l) } } var u = e(t, n, i); return u > -1 ? l[s ? t[u] : u] : void 0 } } }, 6013: function (e, t, n) { var r = n(3977); e.exports = function (e) { return r(e) ? void 0 : e } }, 8528: function (e, t, n) { var r = n(8136), o = function () { try { var e = r(Object, "defineProperty"); return e({}, "", {}), e } catch (t) { } }(); e.exports = o }, 5305: function (e, t, n) { var r = n(692), o = n(7897), a = n(75); e.exports = function (e, t, n, i, l, s) { var u = 1 & n, c = e.length, d = t.length; if (c != d && !(u && d > c)) return !1; var f = s.get(e), p = s.get(t); if (f && p) return f == t && p == e; var h = -1, v = !0, g = 2 & n ? new r : void 0; for (s.set(e, t), s.set(t, e); ++h < c;) { var y = e[h], m = t[h]; if (i) var b = u ? i(m, y, h, t, e, s) : i(y, m, h, e, t, s); if (void 0 !== b) { if (b) continue; v = !1; break } if (g) { if (!o(t, (function (e, t) { if (!a(g, t) && (y === e || l(y, e, n, i, s))) return g.push(t) }))) { v = !1; break } } else if (y !== m && !l(y, m, n, i, s)) { v = !1; break } } return s.delete(e), s.delete(t), v } }, 2206: function (e, t, n) { var r = n(7197), o = n(6219), a = n(9231), i = n(5305), l = n(234), s = n(2230), u = r ? r.prototype : void 0, c = u ? u.valueOf : void 0; e.exports = function (e, t, n, r, u, d, f) { switch (n) { case "[object DataView]": if (e.byteLength != t.byteLength || e.byteOffset != t.byteOffset) return !1; e = e.buffer, t = t.buffer; case "[object ArrayBuffer]": return !(e.byteLength != t.byteLength || !d(new o(e), new o(t))); case "[object Boolean]": case "[object Date]": case "[object Number]": return a(+e, +t); case "[object Error]": return e.name == t.name && e.message == t.message; case "[object RegExp]": case "[object String]": return e == t + ""; case "[object Map]": var p = l; case "[object Set]": var h = 1 & r; if (p || (p = s), e.size != t.size && !h) return !1; var v = f.get(e); if (v) return v == t; r |= 2, f.set(e, t); var g = i(p(e), p(t), r, u, d, f); return f.delete(e), g; case "[object Symbol]": if (c) return c.call(e) == c.call(t) }return !1 } }, 8078: function (e, t, n) { var r = n(8248), o = Object.prototype.hasOwnProperty; e.exports = function (e, t, n, a, i, l) { var s = 1 & n, u = r(e), c = u.length; if (c != r(t).length && !s) return !1; for (var d = c; d--;) { var f = u[d]; if (!(s ? f in t : o.call(t, f))) return !1 } var p = l.get(e), h = l.get(t); if (p && h) return p == t && h == e; var v = !0; l.set(e, t), l.set(t, e); for (var g = s; ++d < c;) { var y = e[f = u[d]], m = t[f]; if (a) var b = s ? a(m, y, f, t, e, l) : a(y, m, f, e, t, l); if (!(void 0 === b ? y === m || i(y, m, n, a, l) : b)) { v = !1; break } g || (g = "constructor" == f) } if (v && !g) { var w = e.constructor, C = t.constructor; w == C || !("constructor" in e) || !("constructor" in t) || "function" == typeof w && w instanceof w && "function" == typeof C && C instanceof C || (v = !1) } return l.delete(e), l.delete(t), v } }, 7038: function (e, t, n) { var r = n(5506), o = n(4262), a = n(9156); e.exports = function (e) { return a(o(e, void 0, r), e + "") } }, 1032: function (e, t, n) { var r = "object" == typeof n.g && n.g && n.g.Object === Object && n.g; e.exports = r }, 8248: function (e, t, n) { var r = n(1986), o = n(5918), a = n(2742); e.exports = function (e) { return r(e, a, o) } }, 5341: function (e, t, n) { var r = n(1986), o = n(8487), a = n(3961); e.exports = function (e) { return r(e, a, o) } }, 2799: function (e, t, n) { var r = n(5964); e.exports = function (e, t) { var n = e.__data__; return r(t) ? n["string" == typeof t ? "string" : "hash"] : n.map } }, 9091: function (e, t, n) { var r = n(5072), o = n(2742); e.exports = function (e) { for (var t = o(e), n = t.length; n--;) { var a = t[n], i = e[a]; t[n] = [a, i, r(i)] } return t } }, 8136: function (e, t, n) { var r = n(6703), o = n(40); e.exports = function (e, t) { var n = o(e, t); return r(n) ? n : void 0 } }, 1137: function (e, t, n) { var r = n(2709)(Object.getPrototypeOf, Object); e.exports = r }, 1587: function (e, t, n) { var r = n(7197), o = Object.prototype, a = o.hasOwnProperty, i = o.toString, l = r ? r.toStringTag : void 0; e.exports = function (e) { var t = a.call(e, l), n = e[l]; try { e[l] = void 0; var r = !0 } catch (s) { } var o = i.call(e); return r && (t ? e[l] = n : delete e[l]), o } }, 5918: function (e, t, n) { var r = n(4903), o = n(8174), a = Object.prototype.propertyIsEnumerable, i = Object.getOwnPropertySymbols, l = i ? function (e) { return null == e ? [] : (e = Object(e), r(i(e), (function (t) { return a.call(e, t) }))) } : o; e.exports = l }, 8487: function (e, t, n) { var r = n(1705), o = n(1137), a = n(5918), i = n(8174), l = Object.getOwnPropertySymbols ? function (e) { for (var t = []; e;)r(t, a(e)), e = o(e); return t } : i; e.exports = l }, 8383: function (e, t, n) { var r = n(908), o = n(5797), a = n(8319), i = n(3924), l = n(7091), s = n(9066), u = n(7907), c = "[object Map]", d = "[object Promise]", f = "[object Set]", p = "[object WeakMap]", h = "[object DataView]", v = u(r), g = u(o), y = u(a), m = u(i), b = u(l), w = s; (r && w(new r(new ArrayBuffer(1))) != h || o && w(new o) != c || a && w(a.resolve()) != d || i && w(new i) != f || l && w(new l) != p) && (w = function (e) { var t = s(e), n = "[object Object]" == t ? e.constructor : void 0, r = n ? u(n) : ""; if (r) switch (r) { case v: return h; case g: return c; case y: return d; case m: return f; case b: return p }return t }), e.exports = w }, 40: function (e) { e.exports = function (e, t) { return null == e ? void 0 : e[t] } }, 6417: function (e, t, n) { var r = n(3082), o = n(4963), a = n(3629), i = n(6800), l = n(4635), s = n(9793); e.exports = function (e, t, n) { for (var u = -1, c = (t = r(t, e)).length, d = !1; ++u < c;) { var f = s(t[u]); if (!(d = null != e && n(e, f))) break; e = e[f] } return d || ++u != c ? d : !!(c = null == e ? 0 : e.length) && l(c) && i(f, c) && (a(e) || o(e)) } }, 7302: function (e) { var t = RegExp("[\\u200d\\ud800-\\udfff\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\ufe0e\\ufe0f]"); e.exports = function (e) { return t.test(e) } }, 5403: function (e, t, n) { var r = n(9620); e.exports = function () { this.__data__ = r ? r(null) : {}, this.size = 0 } }, 2747: function (e) { e.exports = function (e) { var t = this.has(e) && delete this.__data__[e]; return this.size -= t ? 1 : 0, t } }, 6037: function (e, t, n) { var r = n(9620), o = Object.prototype.hasOwnProperty; e.exports = function (e) { var t = this.__data__; if (r) { var n = t[e]; return "__lodash_hash_undefined__" === n ? void 0 : n } return o.call(t, e) ? t[e] : void 0 } }, 4154: function (e, t, n) { var r = n(9620), o = Object.prototype.hasOwnProperty; e.exports = function (e) { var t = this.__data__; return r ? void 0 !== t[e] : o.call(t, e) } }, 7728: function (e, t, n) { var r = n(9620); e.exports = function (e, t) { var n = this.__data__; return this.size += this.has(e) ? 0 : 1, n[e] = r && void 0 === t ? "__lodash_hash_undefined__" : t, this } }, 9243: function (e) { var t = Object.prototype.hasOwnProperty; e.exports = function (e) { var n = e.length, r = new e.constructor(n); return n && "string" == typeof e[0] && t.call(e, "index") && (r.index = e.index, r.input = e.input), r } }, 9759: function (e, t, n) { var r = n(7010), o = n(1022), a = n(8503), i = n(4720), l = n(613); e.exports = function (e, t, n) { var s = e.constructor; switch (t) { case "[object ArrayBuffer]": return r(e); case "[object Boolean]": case "[object Date]": return new s(+e); case "[object DataView]": return o(e, n); case "[object Float32Array]": case "[object Float64Array]": case "[object Int8Array]": case "[object Int16Array]": case "[object Int32Array]": case "[object Uint8Array]": case "[object Uint8ClampedArray]": case "[object Uint16Array]": case "[object Uint32Array]": return l(e, n); case "[object Map]": case "[object Set]": return new s; case "[object Number]": case "[object String]": return new s(e); case "[object RegExp]": return a(e); case "[object Symbol]": return i(e) } } }, 548: function (e, t, n) { var r = n(5763), o = n(1137), a = n(2936); e.exports = function (e) { return "function" != typeof e.constructor || a(e) ? {} : r(o(e)) } }, 3529: function (e, t, n) { var r = n(7197), o = n(4963), a = n(3629), i = r ? r.isConcatSpreadable : void 0; e.exports = function (e) { return a(e) || o(e) || !!(i && e && e[i]) } }, 6800: function (e) { var t = /^(?:0|[1-9]\d*)$/; e.exports = function (e, n) { var r = typeof e; return !!(n = null == n ? 9007199254740991 : n) && ("number" == r || "symbol" != r && t.test(e)) && e > -1 && e % 1 == 0 && e < n } }, 3195: function (e, t, n) { var r = n(9231), o = n(1473), a = n(6800), i = n(8092); e.exports = function (e, t, n) { if (!i(n)) return !1; var l = typeof t; return !!("number" == l ? o(n) && a(t, n.length) : "string" == l && t in n) && r(n[t], e) } }, 5823: function (e, t, n) { var r = n(3629), o = n(152), a = /\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/, i = /^\w*$/; e.exports = function (e, t) { if (r(e)) return !1; var n = typeof e; return !("number" != n && "symbol" != n && "boolean" != n && null != e && !o(e)) || (i.test(e) || !a.test(e) || null != t && e in Object(t)) } }, 5964: function (e) { e.exports = function (e) { var t = typeof e; return "string" == t || "number" == t || "symbol" == t || "boolean" == t ? "__proto__" !== e : null === e } }, 257: function (e, t, n) { var r = n(5525), o = function () { var e = /[^.]+$/.exec(r && r.keys && r.keys.IE_PROTO || ""); return e ? "Symbol(src)_1." + e : "" }(); e.exports = function (e) { return !!o && o in e } }, 2936: function (e) { var t = Object.prototype; e.exports = function (e) { var n = e && e.constructor; return e === ("function" == typeof n && n.prototype || t) } }, 5072: function (e, t, n) { var r = n(8092); e.exports = function (e) { return e === e && !r(e) } }, 3894: function (e) { e.exports = function () { this.__data__ = [], this.size = 0 } }, 8699: function (e, t, n) { var r = n(7112), o = Array.prototype.splice; e.exports = function (e) { var t = this.__data__, n = r(t, e); return !(n < 0) && (n == t.length - 1 ? t.pop() : o.call(t, n, 1), --this.size, !0) } }, 4957: function (e, t, n) { var r = n(7112); e.exports = function (e) { var t = this.__data__, n = r(t, e); return n < 0 ? void 0 : t[n][1] } }, 7184: function (e, t, n) { var r = n(7112); e.exports = function (e) { return r(this.__data__, e) > -1 } }, 7109: function (e, t, n) { var r = n(7112); e.exports = function (e, t) { var n = this.__data__, o = r(n, e); return o < 0 ? (++this.size, n.push([e, t])) : n[o][1] = t, this } }, 4086: function (e, t, n) { var r = n(9676), o = n(8384), a = n(5797); e.exports = function () { this.size = 0, this.__data__ = { hash: new r, map: new (a || o), string: new r } } }, 9255: function (e, t, n) { var r = n(2799); e.exports = function (e) { var t = r(this, e).delete(e); return this.size -= t ? 1 : 0, t } }, 9186: function (e, t, n) { var r = n(2799); e.exports = function (e) { return r(this, e).get(e) } }, 3423: function (e, t, n) { var r = n(2799); e.exports = function (e) { return r(this, e).has(e) } }, 3739: function (e, t, n) { var r = n(2799); e.exports = function (e, t) { var n = r(this, e), o = n.size; return n.set(e, t), this.size += n.size == o ? 0 : 1, this } }, 234: function (e) { e.exports = function (e) { var t = -1, n = Array(e.size); return e.forEach((function (e, r) { n[++t] = [r, e] })), n } }, 284: function (e) { e.exports = function (e, t) { return function (n) { return null != n && (n[e] === t && (void 0 !== t || e in Object(n))) } } }, 4634: function (e, t, n) { var r = n(9151); e.exports = function (e) { var t = r(e, (function (e) { return 500 === n.size && n.clear(), e })), n = t.cache; return t } }, 9620: function (e, t, n) { var r = n(8136)(Object, "create"); e.exports = r }, 8836: function (e, t, n) { var r = n(2709)(Object.keys, Object); e.exports = r }, 4221: function (e) { e.exports = function (e) { var t = []; if (null != e) for (var n in Object(e)) t.push(n); return t } }, 9494: function (e, t, n) { e = n.nmd(e); var r = n(1032), o = t && !t.nodeType && t, a = o && e && !e.nodeType && e, i = a && a.exports === o && r.process, l = function () { try { var e = a && a.require && a.require("util").types; return e || i && i.binding && i.binding("util") } catch (t) { } }(); e.exports = l }, 3581: function (e) { var t = Object.prototype.toString; e.exports = function (e) { return t.call(e) } }, 2709: function (e) { e.exports = function (e, t) { return function (n) { return e(t(n)) } } }, 4262: function (e, t, n) { var r = n(3665), o = Math.max; e.exports = function (e, t, n) { return t = o(void 0 === t ? e.length - 1 : t, 0), function () { for (var a = arguments, i = -1, l = o(a.length - t, 0), s = Array(l); ++i < l;)s[i] = a[t + i]; i = -1; for (var u = Array(t + 1); ++i < t;)u[i] = a[i]; return u[t] = n(s), r(e, this, u) } } }, 8978: function (e, t, n) { var r = n(8667), o = n(2646); e.exports = function (e, t) { return t.length < 2 ? e : r(e, o(t, 0, -1)) } }, 7009: function (e, t, n) { var r = n(1032), o = "object" == typeof self && self && self.Object === Object && self, a = r || o || Function("return this")(); e.exports = a }, 5906: function (e) { e.exports = function (e, t) { if (("constructor" !== t || "function" !== typeof e[t]) && "__proto__" != t) return e[t] } }, 5774: function (e) { e.exports = function (e) { return this.__data__.set(e, "__lodash_hash_undefined__"), this } }, 1596: function (e) { e.exports = function (e) { return this.__data__.has(e) } }, 2230: function (e) { e.exports = function (e) { var t = -1, n = Array(e.size); return e.forEach((function (e) { n[++t] = e })), n } }, 9156: function (e, t, n) { var r = n(7532), o = n(3197)(r); e.exports = o }, 3197: function (e) { var t = Date.now; e.exports = function (e) { var n = 0, r = 0; return function () { var o = t(), a = 16 - (o - r); if (r = o, a > 0) { if (++n >= 800) return arguments[0] } else n = 0; return e.apply(void 0, arguments) } } }, 511: function (e, t, n) { var r = n(8384); e.exports = function () { this.__data__ = new r, this.size = 0 } }, 835: function (e) { e.exports = function (e) { var t = this.__data__, n = t.delete(e); return this.size = t.size, n } }, 707: function (e) { e.exports = function (e) { return this.__data__.get(e) } }, 8832: function (e) { e.exports = function (e) { return this.__data__.has(e) } }, 5077: function (e, t, n) { var r = n(8384), o = n(5797), a = n(8059); e.exports = function (e, t) { var n = this.__data__; if (n instanceof r) { var i = n.__data__; if (!o || i.length < 199) return i.push([e, t]), this.size = ++n.size, this; n = this.__data__ = new a(i) } return n.set(e, t), this.size = n.size, this } }, 7167: function (e) { e.exports = function (e, t, n) { for (var r = n - 1, o = e.length; ++r < o;)if (e[r] === t) return r; return -1 } }, 4651: function (e, t, n) { var r = n(405), o = n(7302), a = n(3007); e.exports = function (e) { return o(e) ? a(e) : r(e) } }, 7580: function (e, t, n) { var r = n(4622), o = n(7302), a = n(2110); e.exports = function (e) { return o(e) ? a(e) : r(e) } }, 170: function (e, t, n) { var r = n(4634), o = /[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g, a = /\\(\\)?/g, i = r((function (e) { var t = []; return 46 === e.charCodeAt(0) && t.push(""), e.replace(o, (function (e, n, r, o) { t.push(r ? o.replace(a, "$1") : n || e) })), t })); e.exports = i }, 9793: function (e, t, n) { var r = n(152); e.exports = function (e) { if ("string" == typeof e || r(e)) return e; var t = e + ""; return "0" == t && 1 / e == -Infinity ? "-0" : t } }, 7907: function (e) { var t = Function.prototype.toString; e.exports = function (e) { if (null != e) { try { return t.call(e) } catch (n) { } try { return e + "" } catch (n) { } } return "" } }, 6050: function (e) { var t = /\s/; e.exports = function (e) { for (var n = e.length; n-- && t.test(e.charAt(n));); return n } }, 3007: function (e) { var t = "\\ud800-\\udfff", n = "[" + t + "]", r = "[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]", o = "\\ud83c[\\udffb-\\udfff]", a = "[^" + t + "]", i = "(?:\\ud83c[\\udde6-\\uddff]){2}", l = "[\\ud800-\\udbff][\\udc00-\\udfff]", s = "(?:" + r + "|" + o + ")" + "?", u = "[\\ufe0e\\ufe0f]?", c = u + s + ("(?:\\u200d(?:" + [a, i, l].join("|") + ")" + u + s + ")*"), d = "(?:" + [a + r + "?", r, i, l, n].join("|") + ")", f = RegExp(o + "(?=" + o + ")|" + d + c, "g"); e.exports = function (e) { for (var t = f.lastIndex = 0; f.test(e);)++t; return t } }, 2110: function (e) { var t = "\\ud800-\\udfff", n = "[" + t + "]", r = "[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]", o = "\\ud83c[\\udffb-\\udfff]", a = "[^" + t + "]", i = "(?:\\ud83c[\\udde6-\\uddff]){2}", l = "[\\ud800-\\udbff][\\udc00-\\udfff]", s = "(?:" + r + "|" + o + ")" + "?", u = "[\\ufe0e\\ufe0f]?", c = u + s + ("(?:\\u200d(?:" + [a, i, l].join("|") + ")" + u + s + ")*"), d = "(?:" + [a + r + "?", r, i, l, n].join("|") + ")", f = RegExp(o + "(?=" + o + ")|" + d + c, "g"); e.exports = function (e) { return e.match(f) || [] } }, 8787: function (e, t, n) { var r = n(1905); e.exports = function (e) { return r(e, 4) } }, 93: function (e, t, n) { var r = n(1905); e.exports = function (e, t) { return r(e, 5, t = "function" == typeof t ? t : void 0) } }, 1547: function (e) { e.exports = function (e) { return function () { return e } } }, 8573: function (e, t, n) { var r = n(8092), o = n(72), a = n(2582), i = Math.max, l = Math.min; e.exports = function (e, t, n) { var s, u, c, d, f, p, h = 0, v = !1, g = !1, y = !0; if ("function" != typeof e) throw new TypeError("Expected a function"); function m(t) { var n = s, r = u; return s = u = void 0, h = t, d = e.apply(r, n) } function b(e) { return h = e, f = setTimeout(C, t), v ? m(e) : d } function w(e) { var n = e - p; return void 0 === p || n >= t || n < 0 || g && e - h >= c } function C() { var e = o(); if (w(e)) return O(e); f = setTimeout(C, function (e) { var n = t - (e - p); return g ? l(n, c - (e - h)) : n }(e)) } function O(e) { return f = void 0, y && s ? m(e) : (s = u = void 0, d) } function S() { var e = o(), n = w(e); if (s = arguments, u = this, p = e, n) { if (void 0 === f) return b(p); if (g) return clearTimeout(f), f = setTimeout(C, t), m(p) } return void 0 === f && (f = setTimeout(C, t)), d } return t = a(t) || 0, r(n) && (v = !!n.leading, c = (g = "maxWait" in n) ? i(a(n.maxWait) || 0, t) : c, y = "trailing" in n ? !!n.trailing : y), S.cancel = function () { void 0 !== f && clearTimeout(f), h = 0, s = p = u = f = void 0 }, S.flush = function () { return void 0 === f ? d : O(o()) }, S } }, 1180: function (e, t, n) { var r = n(1468), o = n(5182), a = n(8794), i = n(6279), l = a((function (e, t) { return i(e) ? r(e, o(t, 1, i, !0)) : [] })); e.exports = l }, 9430: function (e, t, n) { e.exports = n(6514) }, 9231: function (e) { e.exports = function (e, t) { return e === t || e !== e && t !== t } }, 86: function (e, t, n) { var r = n(4903), o = n(7523), a = n(6025), i = n(3629); e.exports = function (e, t) { return (i(e) ? r : o)(e, a(t, 3)) } }, 1211: function (e, t, n) { var r = n(5481)(n(1475)); e.exports = r }, 1475: function (e, t, n) { var r = n(2045), o = n(6025), a = n(9753), i = Math.max; e.exports = function (e, t, n) { var l = null == e ? 0 : e.length; if (!l) return -1; var s = null == n ? 0 : a(n); return s < 0 && (s = i(l + s, 0)), r(e, o(t, 3), s) } }, 8409: function (e, t, n) { var r = n(2045), o = n(6025), a = n(9753), i = Math.max, l = Math.min; e.exports = function (e, t, n) { var s = null == e ? 0 : e.length; if (!s) return -1; var u = s - 1; return void 0 !== n && (u = a(n), u = n < 0 ? i(s + u, 0) : l(u, s - 1)), r(e, o(t, 3), u, !0) } }, 5506: function (e, t, n) { var r = n(5182); e.exports = function (e) { return (null == e ? 0 : e.length) ? r(e, 1) : [] } }, 3613: function (e, t, n) { var r = n(5182); e.exports = function (e) { return (null == e ? 0 : e.length) ? r(e, Infinity) : [] } }, 6514: function (e, t, n) { var r = n(4550), o = n(7927), a = n(3410), i = n(3629); e.exports = function (e, t) { return (i(e) ? r : o)(e, a(t)) } }, 6181: function (e, t, n) { var r = n(8667); e.exports = function (e, t, n) { var o = null == e ? void 0 : r(e, t); return void 0 === o ? n : o } }, 7805: function (e, t, n) { var r = n(7852), o = n(6417); e.exports = function (e, t) { return null != e && o(e, t, r) } }, 5658: function (e, t, n) { var r = n(529), o = n(6417); e.exports = function (e, t) { return null != e && o(e, t, r) } }, 2100: function (e) { e.exports = function (e) { return e } }, 806: function (e, t, n) { var r = n(4842), o = n(1473), a = n(6769), i = n(9753), l = n(2063), s = Math.max; e.exports = function (e, t, n, u) { e = o(e) ? e : l(e), n = n && !u ? i(n) : 0; var c = e.length; return n < 0 && (n = s(c + n, 0)), a(e) ? n <= c && e.indexOf(t, n) > -1 : !!c && r(e, t, n) > -1 } }, 4963: function (e, t, n) { var r = n(4906), o = n(3141), a = Object.prototype, i = a.hasOwnProperty, l = a.propertyIsEnumerable, s = r(function () { return arguments }()) ? r : function (e) { return o(e) && i.call(e, "callee") && !l.call(e, "callee") }; e.exports = s }, 3629: function (e) { var t = Array.isArray; e.exports = t }, 1473: function (e, t, n) { var r = n(4786), o = n(4635); e.exports = function (e) { return null != e && o(e.length) && !r(e) } }, 6279: function (e, t, n) { var r = n(1473), o = n(3141); e.exports = function (e) { return o(e) && r(e) } }, 5127: function (e, t, n) { var r = n(9066), o = n(3141); e.exports = function (e) { return !0 === e || !1 === e || o(e) && "[object Boolean]" == r(e) } }, 5174: function (e, t, n) { e = n.nmd(e); var r = n(7009), o = n(9488), a = t && !t.nodeType && t, i = a && e && !e.nodeType && e, l = i && i.exports === a ? r.Buffer : void 0, s = (l ? l.isBuffer : void 0) || o; e.exports = s }, 6364: function (e, t, n) { var r = n(3654), o = n(8383), a = n(4963), i = n(3629), l = n(1473), s = n(5174), u = n(2936), c = n(9102), d = Object.prototype.hasOwnProperty; e.exports = function (e) { if (null == e) return !0; if (l(e) && (i(e) || "string" == typeof e || "function" == typeof e.splice || s(e) || c(e) || a(e))) return !e.length; var t = o(e); if ("[object Map]" == t || "[object Set]" == t) return !e.size; if (u(e)) return !r(e).length; for (var n in e) if (d.call(e, n)) return !1; return !0 } }, 8111: function (e, t, n) { var r = n(1848); e.exports = function (e, t) { return r(e, t) } }, 290: function (e, t, n) { var r = n(1848); e.exports = function (e, t, n) { var o = (n = "function" == typeof n ? n : void 0) ? n(e, t) : void 0; return void 0 === o ? r(e, t, void 0, n) : !!o } }, 4786: function (e, t, n) { var r = n(9066), o = n(8092); e.exports = function (e) { if (!o(e)) return !1; var t = r(e); return "[object Function]" == t || "[object GeneratorFunction]" == t || "[object AsyncFunction]" == t || "[object Proxy]" == t } }, 4635: function (e) { e.exports = function (e) { return "number" == typeof e && e > -1 && e % 1 == 0 && e <= 9007199254740991 } }, 103: function (e, t, n) { var r = n(3085), o = n(6194), a = n(9494), i = a && a.isMap, l = i ? o(i) : r; e.exports = l }, 2066: function (e, t, n) { var r = n(298); e.exports = function (e) { return r(e) && e != +e } }, 8016: function (e) { e.exports = function (e) { return null === e } }, 298: function (e, t, n) { var r = n(9066), o = n(3141); e.exports = function (e) { return "number" == typeof e || o(e) && "[object Number]" == r(e) } }, 8092: function (e) { e.exports = function (e) { var t = typeof e; return null != e && ("object" == t || "function" == t) } }, 3141: function (e) { e.exports = function (e) { return null != e && "object" == typeof e } }, 3977: function (e, t, n) { var r = n(9066), o = n(1137), a = n(3141), i = Function.prototype, l = Object.prototype, s = i.toString, u = l.hasOwnProperty, c = s.call(Object); e.exports = function (e) { if (!a(e) || "[object Object]" != r(e)) return !1; var t = o(e); if (null === t) return !0; var n = u.call(t, "constructor") && t.constructor; return "function" == typeof n && n instanceof n && s.call(n) == c } }, 5625: function (e, t, n) { var r = n(7817), o = n(6194), a = n(9494), i = a && a.isRegExp, l = i ? o(i) : r; e.exports = l }, 6995: function (e, t, n) { var r = n(8680), o = n(6194), a = n(9494), i = a && a.isSet, l = i ? o(i) : r; e.exports = l }, 6769: function (e, t, n) { var r = n(9066), o = n(3629), a = n(3141); e.exports = function (e) { return "string" == typeof e || !o(e) && a(e) && "[object String]" == r(e) } }, 152: function (e, t, n) { var r = n(9066), o = n(3141); e.exports = function (e) { return "symbol" == typeof e || o(e) && "[object Symbol]" == r(e) } }, 9102: function (e, t, n) { var r = n(8150), o = n(6194), a = n(9494), i = a && a.isTypedArray, l = i ? o(i) : r; e.exports = l }, 2530: function (e) { e.exports = function (e) { return void 0 === e } }, 2742: function (e, t, n) { var r = n(7538), o = n(3654), a = n(1473); e.exports = function (e) { return a(e) ? r(e) : o(e) } }, 3961: function (e, t, n) { var r = n(7538), o = n(8664), a = n(1473); e.exports = function (e) { return a(e) ? r(e, !0) : o(e) } }, 5727: function (e) { e.exports = function (e) { var t = null == e ? 0 : e.length; return t ? e[t - 1] : void 0 } }, 2034: function (e, t, n) { var r = n(8950), o = n(6025), a = n(3849), i = n(3629); e.exports = function (e, t) { return (i(e) ? r : a)(e, o(t, 3)) } }, 9151: function (e, t, n) { var r = n(8059); function o(e, t) { if ("function" != typeof e || null != t && "function" != typeof t) throw new TypeError("Expected a function"); var n = function n() { var r = arguments, o = t ? t.apply(this, r) : r[0], a = n.cache; if (a.has(o)) return a.get(o); var i = e.apply(this, r); return n.cache = a.set(o, i) || a, i }; return n.cache = new (o.Cache || r), n } o.Cache = r, e.exports = o }, 9286: function (e, t, n) { var r = n(4173), o = n(9934)((function (e, t, n) { r(e, t, n) })); e.exports = o }, 9694: function (e) { e.exports = function () { } }, 72: function (e, t, n) { var r = n(7009); e.exports = function () { return r.Date.now() } }, 4242: function (e, t, n) { var r = n(8950), o = n(1905), a = n(6555), i = n(3082), l = n(4503), s = n(6013), u = n(7038), c = n(5341), d = u((function (e, t) { var n = {}; if (null == e) return n; var u = !1; t = r(t, (function (t) { return t = i(t, e), u || (u = t.length > 1), t })), l(e, c(e), n), u && (n = o(n, 7, s)); for (var d = t.length; d--;)a(n, t[d]); return n })); e.exports = d }, 6460: function (e, t, n) { var r = n(4980), o = n(7038)((function (e, t) { return null == e ? {} : r(e, t) })); e.exports = o }, 38: function (e, t, n) { var r = n(9586), o = n(4084), a = n(5823), i = n(9793); e.exports = function (e) { return a(e) ? r(i(e)) : o(e) } }, 4475: function (e, t, n) { var r = n(8794)(n(566)); e.exports = r }, 566: function (e, t, n) { var r = n(2664); e.exports = function (e, t) { return e && e.length && t && t.length ? r(e, t) : e } }, 4485: function (e, t, n) { var r = n(379); e.exports = function (e, t, n) { return null == e ? e : r(e, t, n) } }, 9467: function (e, t, n) { var r = n(3654), o = n(8383), a = n(1473), i = n(6769), l = n(4651); e.exports = function (e) { if (null == e) return 0; if (a(e)) return i(e) ? l(e) : e.length; var t = o(e); return "[object Map]" == t || "[object Set]" == t ? e.size : r(e).length } }, 7299: function (e, t, n) { var r = n(2646), o = n(3195), a = n(9753); e.exports = function (e, t, n) { var i = null == e ? 0 : e.length; return i ? (n && "number" != typeof n && o(e, t, n) ? (t = 0, n = i) : (t = null == t ? 0 : a(t), n = void 0 === n ? i : a(n)), r(e, t, n)) : [] } }, 4064: function (e, t, n) { var r = n(7897), o = n(6025), a = n(9204), i = n(3629), l = n(3195); e.exports = function (e, t, n) { var s = i(e) ? r : a; return n && l(e, t, n) && (t = void 0), s(e, o(t, 3)) } }, 4965: function (e, t, n) { var r = n(2446), o = n(9813), a = n(7302), i = n(3195), l = n(5625), s = n(7580), u = n(3518); e.exports = function (e, t, n) { return n && "number" != typeof n && i(e, t, n) && (t = n = void 0), (n = void 0 === n ? 4294967295 : n >>> 0) ? (e = u(e)) && ("string" == typeof t || null != t && !l(t)) && !(t = r(t)) && a(e) ? o(s(e), 0, n) : e.split(t, n) : [] } }, 8174: function (e) { e.exports = function () { return [] } }, 9488: function (e) { e.exports = function () { return !1 } }, 5047: function (e) { e.exports = function () { return !0 } }, 3038: function (e, t, n) { var r = n(8573), o = n(8092); e.exports = function (e, t, n) { var a = !0, i = !0; if ("function" != typeof e) throw new TypeError("Expected a function"); return o(n) && (a = "leading" in n ? !!n.leading : a, i = "trailing" in n ? !!n.trailing : i), r(e, t, { leading: a, maxWait: t, trailing: i }) } }, 778: function (e, t, n) { var r = n(6478), o = n(3410), a = n(9753), i = 4294967295, l = Math.min; e.exports = function (e, t) { if ((e = a(e)) < 1 || e > 9007199254740991) return []; var n = i, s = l(e, i); t = o(t), e -= i; for (var u = r(s, t); ++n < e;)t(n); return u } }, 1495: function (e, t, n) { var r = n(2582), o = 1 / 0; e.exports = function (e) { return e ? (e = r(e)) === o || e === -1 / 0 ? 17976931348623157e292 * (e < 0 ? -1 : 1) : e === e ? e : 0 : 0 === e ? e : 0 } }, 9753: function (e, t, n) { var r = n(1495); e.exports = function (e) { var t = r(e), n = t % 1; return t === t ? n ? t - n : t : 0 } }, 2582: function (e, t, n) { var r = n(821), o = n(8092), a = n(152), i = /^[-+]0x[0-9a-f]+$/i, l = /^0b[01]+$/i, s = /^0o[0-7]+$/i, u = parseInt; e.exports = function (e) { if ("number" == typeof e) return e; if (a(e)) return NaN; if (o(e)) { var t = "function" == typeof e.valueOf ? e.valueOf() : e; e = o(t) ? t + "" : t } if ("string" != typeof e) return 0 === e ? e : +e; e = r(e); var n = l.test(e); return n || s.test(e) ? u(e.slice(2), n ? 2 : 8) : i.test(e) ? NaN : +e } }, 168: function (e, t, n) { var r = n(8950), o = n(291), a = n(3629), i = n(152), l = n(170), s = n(9793), u = n(3518); e.exports = function (e) { return a(e) ? r(e, s) : i(e) ? [e] : o(l(u(e))) } }, 6576: function (e, t, n) { var r = n(4503), o = n(3961); e.exports = function (e) { return r(e, o(e)) } }, 3518: function (e, t, n) { var r = n(2446); e.exports = function (e) { return null == e ? "" : r(e) } }, 3986: function (e, t, n) { var r = n(6555); e.exports = function (e, t) { return null == e || r(e, t) } }, 2063: function (e, t, n) { var r = n(8019), o = n(2742); e.exports = function (e) { return null == e ? [] : r(e, o(e)) } }, 1761: function (e, t, n) { var r = n(1468), o = n(8794), a = n(6279), i = o((function (e, t) { return a(e) ? r(e, t) : [] })); e.exports = i }, 888: function (e, t, n) { "use strict"; var r = n(9047); function o() { } function a() { } a.resetWarningCache = o, e.exports = function () { function e(e, t, n, o, a, i) { if (i !== r) { var l = new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types"); throw l.name = "Invariant Violation", l } } function t() { return e } e.isRequired = e; var n = { array: e, bigint: e, bool: e, func: e, number: e, object: e, string: e, symbol: e, any: e, arrayOf: t, element: e, elementType: e, instanceOf: t, node: e, objectOf: t, oneOf: t, oneOfType: t, shape: t, exact: t, checkPropTypes: a, resetWarningCache: o }; return n.PropTypes = n, n } }, 2007: function (e, t, n) { e.exports = n(888)() }, 9047: function (e) { "use strict"; e.exports = "SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED" }, 4463: function (e, t, n) { "use strict"; var r = n(2791), o = n(5296); function a(e) { for (var t = "https://reactjs.org/docs/error-decoder.html?invariant=" + e, n = 1; n < arguments.length; n++)t += "&args[]=" + encodeURIComponent(arguments[n]); return "Minified React error #" + e + "; visit " + t + " for the full message or use the non-minified dev environment for full errors and additional helpful warnings." } var i = new Set, l = {}; function s(e, t) { u(e, t), u(e + "Capture", t) } function u(e, t) { for (l[e] = t, e = 0; e < t.length; e++)i.add(t[e]) } var c = !("undefined" === typeof window || "undefined" === typeof window.document || "undefined" === typeof window.document.createElement), d = Object.prototype.hasOwnProperty, f = /^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/, p = {}, h = {}; function v(e, t, n, r, o, a, i) { this.acceptsBooleans = 2 === t || 3 === t || 4 === t, this.attributeName = r, this.attributeNamespace = o, this.mustUseProperty = n, this.propertyName = e, this.type = t, this.sanitizeURL = a, this.removeEmptyString = i } var g = {}; "children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach((function (e) { g[e] = new v(e, 0, !1, e, null, !1, !1) })), [["acceptCharset", "accept-charset"], ["className", "class"], ["htmlFor", "for"], ["httpEquiv", "http-equiv"]].forEach((function (e) { var t = e[0]; g[t] = new v(t, 1, !1, e[1], null, !1, !1) })), ["contentEditable", "draggable", "spellCheck", "value"].forEach((function (e) { g[e] = new v(e, 2, !1, e.toLowerCase(), null, !1, !1) })), ["autoReverse", "externalResourcesRequired", "focusable", "preserveAlpha"].forEach((function (e) { g[e] = new v(e, 2, !1, e, null, !1, !1) })), "allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach((function (e) { g[e] = new v(e, 3, !1, e.toLowerCase(), null, !1, !1) })), ["checked", "multiple", "muted", "selected"].forEach((function (e) { g[e] = new v(e, 3, !0, e, null, !1, !1) })), ["capture", "download"].forEach((function (e) { g[e] = new v(e, 4, !1, e, null, !1, !1) })), ["cols", "rows", "size", "span"].forEach((function (e) { g[e] = new v(e, 6, !1, e, null, !1, !1) })), ["rowSpan", "start"].forEach((function (e) { g[e] = new v(e, 5, !1, e.toLowerCase(), null, !1, !1) })); var y = /[\-:]([a-z])/g; function m(e) { return e[1].toUpperCase() } function b(e, t, n, r) { var o = g.hasOwnProperty(t) ? g[t] : null; (null !== o ? 0 !== o.type : r || !(2 < t.length) || "o" !== t[0] && "O" !== t[0] || "n" !== t[1] && "N" !== t[1]) && (function (e, t, n, r) { if (null === t || "undefined" === typeof t || function (e, t, n, r) { if (null !== n && 0 === n.type) return !1; switch (typeof t) { case "function": case "symbol": return !0; case "boolean": return !r && (null !== n ? !n.acceptsBooleans : "data-" !== (e = e.toLowerCase().slice(0, 5)) && "aria-" !== e); default: return !1 } }(e, t, n, r)) return !0; if (r) return !1; if (null !== n) switch (n.type) { case 3: return !t; case 4: return !1 === t; case 5: return isNaN(t); case 6: return isNaN(t) || 1 > t }return !1 }(t, n, o, r) && (n = null), r || null === o ? function (e) { return !!d.call(h, e) || !d.call(p, e) && (f.test(e) ? h[e] = !0 : (p[e] = !0, !1)) }(t) && (null === n ? e.removeAttribute(t) : e.setAttribute(t, "" + n)) : o.mustUseProperty ? e[o.propertyName] = null === n ? 3 !== o.type && "" : n : (t = o.attributeName, r = o.attributeNamespace, null === n ? e.removeAttribute(t) : (n = 3 === (o = o.type) || 4 === o && !0 === n ? "" : "" + n, r ? e.setAttributeNS(r, t, n) : e.setAttribute(t, n)))) } "accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach((function (e) { var t = e.replace(y, m); g[t] = new v(t, 1, !1, e, null, !1, !1) })), "xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach((function (e) { var t = e.replace(y, m); g[t] = new v(t, 1, !1, e, "http://www.w3.org/1999/xlink", !1, !1) })), ["xml:base", "xml:lang", "xml:space"].forEach((function (e) { var t = e.replace(y, m); g[t] = new v(t, 1, !1, e, "http://www.w3.org/XML/1998/namespace", !1, !1) })), ["tabIndex", "crossOrigin"].forEach((function (e) { g[e] = new v(e, 1, !1, e.toLowerCase(), null, !1, !1) })), g.xlinkHref = new v("xlinkHref", 1, !1, "xlink:href", "http://www.w3.org/1999/xlink", !0, !1), ["src", "href", "action", "formAction"].forEach((function (e) { g[e] = new v(e, 1, !1, e.toLowerCase(), null, !0, !0) })); var w = r.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, C = Symbol.for("react.element"), O = Symbol.for("react.portal"), S = Symbol.for("react.fragment"), x = Symbol.for("react.strict_mode"), E = Symbol.for("react.profiler"), k = Symbol.for("react.provider"), _ = Symbol.for("react.context"), P = Symbol.for("react.forward_ref"), T = Symbol.for("react.suspense"), j = Symbol.for("react.suspense_list"), R = Symbol.for("react.memo"), I = Symbol.for("react.lazy"); Symbol.for("react.scope"), Symbol.for("react.debug_trace_mode"); var N = Symbol.for("react.offscreen"); Symbol.for("react.legacy_hidden"), Symbol.for("react.cache"), Symbol.for("react.tracing_marker"); var D = Symbol.iterator; function M(e) { return null === e || "object" !== typeof e ? null : "function" === typeof (e = D && e[D] || e["@@iterator"]) ? e : null } var L, A = Object.assign; function F(e) { if (void 0 === L) try { throw Error() } catch (n) { var t = n.stack.trim().match(/\n( *(at )?)/); L = t && t[1] || "" } return "\n" + L + e } var z = !1; function H(e, t) { if (!e || z) return ""; z = !0; var n = Error.prepareStackTrace; Error.prepareStackTrace = void 0; try { if (t) if (t = function () { throw Error() }, Object.defineProperty(t.prototype, "props", { set: function () { throw Error() } }), "object" === typeof Reflect && Reflect.construct) { try { Reflect.construct(t, []) } catch (u) { var r = u } Reflect.construct(e, [], t) } else { try { t.call() } catch (u) { r = u } e.call(t.prototype) } else { try { throw Error() } catch (u) { r = u } e() } } catch (u) { if (u && r && "string" === typeof u.stack) { for (var o = u.stack.split("\n"), a = r.stack.split("\n"), i = o.length - 1, l = a.length - 1; 1 <= i && 0 <= l && o[i] !== a[l];)l--; for (; 1 <= i && 0 <= l; i--, l--)if (o[i] !== a[l]) { if (1 !== i || 1 !== l) do { if (i--, 0 > --l || o[i] !== a[l]) { var s = "\n" + o[i].replace(" at new ", " at "); return e.displayName && s.includes("") && (s = s.replace("", e.displayName)), s } } while (1 <= i && 0 <= l); break } } } finally { z = !1, Error.prepareStackTrace = n } return (e = e ? e.displayName || e.name : "") ? F(e) : "" } function B(e) { switch (e.tag) { case 5: return F(e.type); case 16: return F("Lazy"); case 13: return F("Suspense"); case 19: return F("SuspenseList"); case 0: case 2: case 15: return e = H(e.type, !1); case 11: return e = H(e.type.render, !1); case 1: return e = H(e.type, !0); default: return "" } } function K(e) { if (null == e) return null; if ("function" === typeof e) return e.displayName || e.name || null; if ("string" === typeof e) return e; switch (e) { case S: return "Fragment"; case O: return "Portal"; case E: return "Profiler"; case x: return "StrictMode"; case T: return "Suspense"; case j: return "SuspenseList" }if ("object" === typeof e) switch (e.$$typeof) { case _: return (e.displayName || "Context") + ".Consumer"; case k: return (e._context.displayName || "Context") + ".Provider"; case P: var t = e.render; return (e = e.displayName) || (e = "" !== (e = t.displayName || t.name || "") ? "ForwardRef(" + e + ")" : "ForwardRef"), e; case R: return null !== (t = e.displayName || null) ? t : K(e.type) || "Memo"; case I: t = e._payload, e = e._init; try { return K(e(t)) } catch (n) { } }return null } function U(e) { var t = e.type; switch (e.tag) { case 24: return "Cache"; case 9: return (t.displayName || "Context") + ".Consumer"; case 10: return (t._context.displayName || "Context") + ".Provider"; case 18: return "DehydratedFragment"; case 11: return e = (e = t.render).displayName || e.name || "", t.displayName || ("" !== e ? "ForwardRef(" + e + ")" : "ForwardRef"); case 7: return "Fragment"; case 5: return t; case 4: return "Portal"; case 3: return "Root"; case 6: return "Text"; case 16: return K(t); case 8: return t === x ? "StrictMode" : "Mode"; case 22: return "Offscreen"; case 12: return "Profiler"; case 21: return "Scope"; case 13: return "Suspense"; case 19: return "SuspenseList"; case 25: return "TracingMarker"; case 1: case 0: case 17: case 2: case 14: case 15: if ("function" === typeof t) return t.displayName || t.name || null; if ("string" === typeof t) return t }return null } function V(e) { switch (typeof e) { case "boolean": case "number": case "string": case "undefined": case "object": return e; default: return "" } } function W(e) { var t = e.type; return (e = e.nodeName) && "input" === e.toLowerCase() && ("checkbox" === t || "radio" === t) } function G(e) { e._valueTracker || (e._valueTracker = function (e) { var t = W(e) ? "checked" : "value", n = Object.getOwnPropertyDescriptor(e.constructor.prototype, t), r = "" + e[t]; if (!e.hasOwnProperty(t) && "undefined" !== typeof n && "function" === typeof n.get && "function" === typeof n.set) { var o = n.get, a = n.set; return Object.defineProperty(e, t, { configurable: !0, get: function () { return o.call(this) }, set: function (e) { r = "" + e, a.call(this, e) } }), Object.defineProperty(e, t, { enumerable: n.enumerable }), { getValue: function () { return r }, setValue: function (e) { r = "" + e }, stopTracking: function () { e._valueTracker = null, delete e[t] } } } }(e)) } function $(e) { if (!e) return !1; var t = e._valueTracker; if (!t) return !0; var n = t.getValue(), r = ""; return e && (r = W(e) ? e.checked ? "true" : "false" : e.value), (e = r) !== n && (t.setValue(e), !0) } function Y(e) { if ("undefined" === typeof (e = e || ("undefined" !== typeof document ? document : void 0))) return null; try { return e.activeElement || e.body } catch (t) { return e.body } } function X(e, t) { var n = t.checked; return A({}, t, { defaultChecked: void 0, defaultValue: void 0, value: void 0, checked: null != n ? n : e._wrapperState.initialChecked }) } function Z(e, t) { var n = null == t.defaultValue ? "" : t.defaultValue, r = null != t.checked ? t.checked : t.defaultChecked; n = V(null != t.value ? t.value : n), e._wrapperState = { initialChecked: r, initialValue: n, controlled: "checkbox" === t.type || "radio" === t.type ? null != t.checked : null != t.value } } function q(e, t) { null != (t = t.checked) && b(e, "checked", t, !1) } function Q(e, t) { q(e, t); var n = V(t.value), r = t.type; if (null != n) "number" === r ? (0 === n && "" === e.value || e.value != n) && (e.value = "" + n) : e.value !== "" + n && (e.value = "" + n); else if ("submit" === r || "reset" === r) return void e.removeAttribute("value"); t.hasOwnProperty("value") ? ee(e, t.type, n) : t.hasOwnProperty("defaultValue") && ee(e, t.type, V(t.defaultValue)), null == t.checked && null != t.defaultChecked && (e.defaultChecked = !!t.defaultChecked) } function J(e, t, n) { if (t.hasOwnProperty("value") || t.hasOwnProperty("defaultValue")) { var r = t.type; if (!("submit" !== r && "reset" !== r || void 0 !== t.value && null !== t.value)) return; t = "" + e._wrapperState.initialValue, n || t === e.value || (e.value = t), e.defaultValue = t } "" !== (n = e.name) && (e.name = ""), e.defaultChecked = !!e._wrapperState.initialChecked, "" !== n && (e.name = n) } function ee(e, t, n) { "number" === t && Y(e.ownerDocument) === e || (null == n ? e.defaultValue = "" + e._wrapperState.initialValue : e.defaultValue !== "" + n && (e.defaultValue = "" + n)) } var te = Array.isArray; function ne(e, t, n, r) { if (e = e.options, t) { t = {}; for (var o = 0; o < n.length; o++)t["$" + n[o]] = !0; for (n = 0; n < e.length; n++)o = t.hasOwnProperty("$" + e[n].value), e[n].selected !== o && (e[n].selected = o), o && r && (e[n].defaultSelected = !0) } else { for (n = "" + V(n), t = null, o = 0; o < e.length; o++) { if (e[o].value === n) return e[o].selected = !0, void (r && (e[o].defaultSelected = !0)); null !== t || e[o].disabled || (t = e[o]) } null !== t && (t.selected = !0) } } function re(e, t) { if (null != t.dangerouslySetInnerHTML) throw Error(a(91)); return A({}, t, { value: void 0, defaultValue: void 0, children: "" + e._wrapperState.initialValue }) } function oe(e, t) { var n = t.value; if (null == n) { if (n = t.children, t = t.defaultValue, null != n) { if (null != t) throw Error(a(92)); if (te(n)) { if (1 < n.length) throw Error(a(93)); n = n[0] } t = n } null == t && (t = ""), n = t } e._wrapperState = { initialValue: V(n) } } function ae(e, t) { var n = V(t.value), r = V(t.defaultValue); null != n && ((n = "" + n) !== e.value && (e.value = n), null == t.defaultValue && e.defaultValue !== n && (e.defaultValue = n)), null != r && (e.defaultValue = "" + r) } function ie(e) { var t = e.textContent; t === e._wrapperState.initialValue && "" !== t && null !== t && (e.value = t) } function le(e) { switch (e) { case "svg": return "http://www.w3.org/2000/svg"; case "math": return "http://www.w3.org/1998/Math/MathML"; default: return "http://www.w3.org/1999/xhtml" } } function se(e, t) { return null == e || "http://www.w3.org/1999/xhtml" === e ? le(t) : "http://www.w3.org/2000/svg" === e && "foreignObject" === t ? "http://www.w3.org/1999/xhtml" : e } var ue, ce, de = (ce = function (e, t) { if ("http://www.w3.org/2000/svg" !== e.namespaceURI || "innerHTML" in e) e.innerHTML = t; else { for ((ue = ue || document.createElement("div")).innerHTML = "" + t.valueOf().toString() + "", t = ue.firstChild; e.firstChild;)e.removeChild(e.firstChild); for (; t.firstChild;)e.appendChild(t.firstChild) } }, "undefined" !== typeof MSApp && MSApp.execUnsafeLocalFunction ? function (e, t, n, r) { MSApp.execUnsafeLocalFunction((function () { return ce(e, t) })) } : ce); function fe(e, t) { if (t) { var n = e.firstChild; if (n && n === e.lastChild && 3 === n.nodeType) return void (n.nodeValue = t) } e.textContent = t } var pe = { animationIterationCount: !0, aspectRatio: !0, borderImageOutset: !0, borderImageSlice: !0, borderImageWidth: !0, boxFlex: !0, boxFlexGroup: !0, boxOrdinalGroup: !0, columnCount: !0, columns: !0, flex: !0, flexGrow: !0, flexPositive: !0, flexShrink: !0, flexNegative: !0, flexOrder: !0, gridArea: !0, gridRow: !0, gridRowEnd: !0, gridRowSpan: !0, gridRowStart: !0, gridColumn: !0, gridColumnEnd: !0, gridColumnSpan: !0, gridColumnStart: !0, fontWeight: !0, lineClamp: !0, lineHeight: !0, opacity: !0, order: !0, orphans: !0, tabSize: !0, widows: !0, zIndex: !0, zoom: !0, fillOpacity: !0, floodOpacity: !0, stopOpacity: !0, strokeDasharray: !0, strokeDashoffset: !0, strokeMiterlimit: !0, strokeOpacity: !0, strokeWidth: !0 }, he = ["Webkit", "ms", "Moz", "O"]; function ve(e, t, n) { return null == t || "boolean" === typeof t || "" === t ? "" : n || "number" !== typeof t || 0 === t || pe.hasOwnProperty(e) && pe[e] ? ("" + t).trim() : t + "px" } function ge(e, t) { for (var n in e = e.style, t) if (t.hasOwnProperty(n)) { var r = 0 === n.indexOf("--"), o = ve(n, t[n], r); "float" === n && (n = "cssFloat"), r ? e.setProperty(n, o) : e[n] = o } } Object.keys(pe).forEach((function (e) { he.forEach((function (t) { t = t + e.charAt(0).toUpperCase() + e.substring(1), pe[t] = pe[e] })) })); var ye = A({ menuitem: !0 }, { area: !0, base: !0, br: !0, col: !0, embed: !0, hr: !0, img: !0, input: !0, keygen: !0, link: !0, meta: !0, param: !0, source: !0, track: !0, wbr: !0 }); function me(e, t) { if (t) { if (ye[e] && (null != t.children || null != t.dangerouslySetInnerHTML)) throw Error(a(137, e)); if (null != t.dangerouslySetInnerHTML) { if (null != t.children) throw Error(a(60)); if ("object" !== typeof t.dangerouslySetInnerHTML || !("__html" in t.dangerouslySetInnerHTML)) throw Error(a(61)) } if (null != t.style && "object" !== typeof t.style) throw Error(a(62)) } } function be(e, t) { if (-1 === e.indexOf("-")) return "string" === typeof t.is; switch (e) { case "annotation-xml": case "color-profile": case "font-face": case "font-face-src": case "font-face-uri": case "font-face-format": case "font-face-name": case "missing-glyph": return !1; default: return !0 } } var we = null; function Ce(e) { return (e = e.target || e.srcElement || window).correspondingUseElement && (e = e.correspondingUseElement), 3 === e.nodeType ? e.parentNode : e } var Oe = null, Se = null, xe = null; function Ee(e) { if (e = wo(e)) { if ("function" !== typeof Oe) throw Error(a(280)); var t = e.stateNode; t && (t = Oo(t), Oe(e.stateNode, e.type, t)) } } function ke(e) { Se ? xe ? xe.push(e) : xe = [e] : Se = e } function _e() { if (Se) { var e = Se, t = xe; if (xe = Se = null, Ee(e), t) for (e = 0; e < t.length; e++)Ee(t[e]) } } function Pe(e, t) { return e(t) } function Te() { } var je = !1; function Re(e, t, n) { if (je) return e(t, n); je = !0; try { return Pe(e, t, n) } finally { je = !1, (null !== Se || null !== xe) && (Te(), _e()) } } function Ie(e, t) { var n = e.stateNode; if (null === n) return null; var r = Oo(n); if (null === r) return null; n = r[t]; e: switch (t) { case "onClick": case "onClickCapture": case "onDoubleClick": case "onDoubleClickCapture": case "onMouseDown": case "onMouseDownCapture": case "onMouseMove": case "onMouseMoveCapture": case "onMouseUp": case "onMouseUpCapture": case "onMouseEnter": (r = !r.disabled) || (r = !("button" === (e = e.type) || "input" === e || "select" === e || "textarea" === e)), e = !r; break e; default: e = !1 }if (e) return null; if (n && "function" !== typeof n) throw Error(a(231, t, typeof n)); return n } var Ne = !1; if (c) try { var De = {}; Object.defineProperty(De, "passive", { get: function () { Ne = !0 } }), window.addEventListener("test", De, De), window.removeEventListener("test", De, De) } catch (ce) { Ne = !1 } function Me(e, t, n, r, o, a, i, l, s) { var u = Array.prototype.slice.call(arguments, 3); try { t.apply(n, u) } catch (c) { this.onError(c) } } var Le = !1, Ae = null, Fe = !1, ze = null, He = { onError: function (e) { Le = !0, Ae = e } }; function Be(e, t, n, r, o, a, i, l, s) { Le = !1, Ae = null, Me.apply(He, arguments) } function Ke(e) { var t = e, n = e; if (e.alternate) for (; t.return;)t = t.return; else { e = t; do { 0 !== (4098 & (t = e).flags) && (n = t.return), e = t.return } while (e) } return 3 === t.tag ? n : null } function Ue(e) { if (13 === e.tag) { var t = e.memoizedState; if (null === t && (null !== (e = e.alternate) && (t = e.memoizedState)), null !== t) return t.dehydrated } return null } function Ve(e) { if (Ke(e) !== e) throw Error(a(188)) } function We(e) { return null !== (e = function (e) { var t = e.alternate; if (!t) { if (null === (t = Ke(e))) throw Error(a(188)); return t !== e ? null : e } for (var n = e, r = t; ;) { var o = n.return; if (null === o) break; var i = o.alternate; if (null === i) { if (null !== (r = o.return)) { n = r; continue } break } if (o.child === i.child) { for (i = o.child; i;) { if (i === n) return Ve(o), e; if (i === r) return Ve(o), t; i = i.sibling } throw Error(a(188)) } if (n.return !== r.return) n = o, r = i; else { for (var l = !1, s = o.child; s;) { if (s === n) { l = !0, n = o, r = i; break } if (s === r) { l = !0, r = o, n = i; break } s = s.sibling } if (!l) { for (s = i.child; s;) { if (s === n) { l = !0, n = i, r = o; break } if (s === r) { l = !0, r = i, n = o; break } s = s.sibling } if (!l) throw Error(a(189)) } } if (n.alternate !== r) throw Error(a(190)) } if (3 !== n.tag) throw Error(a(188)); return n.stateNode.current === n ? e : t }(e)) ? Ge(e) : null } function Ge(e) { if (5 === e.tag || 6 === e.tag) return e; for (e = e.child; null !== e;) { var t = Ge(e); if (null !== t) return t; e = e.sibling } return null } var $e = o.unstable_scheduleCallback, Ye = o.unstable_cancelCallback, Xe = o.unstable_shouldYield, Ze = o.unstable_requestPaint, qe = o.unstable_now, Qe = o.unstable_getCurrentPriorityLevel, Je = o.unstable_ImmediatePriority, et = o.unstable_UserBlockingPriority, tt = o.unstable_NormalPriority, nt = o.unstable_LowPriority, rt = o.unstable_IdlePriority, ot = null, at = null; var it = Math.clz32 ? Math.clz32 : function (e) { return e >>>= 0, 0 === e ? 32 : 31 - (lt(e) / st | 0) | 0 }, lt = Math.log, st = Math.LN2; var ut = 64, ct = 4194304; function dt(e) { switch (e & -e) { case 1: return 1; case 2: return 2; case 4: return 4; case 8: return 8; case 16: return 16; case 32: return 32; case 64: case 128: case 256: case 512: case 1024: case 2048: case 4096: case 8192: case 16384: case 32768: case 65536: case 131072: case 262144: case 524288: case 1048576: case 2097152: return 4194240 & e; case 4194304: case 8388608: case 16777216: case 33554432: case 67108864: return 130023424 & e; case 134217728: return 134217728; case 268435456: return 268435456; case 536870912: return 536870912; case 1073741824: return 1073741824; default: return e } } function ft(e, t) { var n = e.pendingLanes; if (0 === n) return 0; var r = 0, o = e.suspendedLanes, a = e.pingedLanes, i = 268435455 & n; if (0 !== i) { var l = i & ~o; 0 !== l ? r = dt(l) : 0 !== (a &= i) && (r = dt(a)) } else 0 !== (i = n & ~o) ? r = dt(i) : 0 !== a && (r = dt(a)); if (0 === r) return 0; if (0 !== t && t !== r && 0 === (t & o) && ((o = r & -r) >= (a = t & -t) || 16 === o && 0 !== (4194240 & a))) return t; if (0 !== (4 & r) && (r |= 16 & n), 0 !== (t = e.entangledLanes)) for (e = e.entanglements, t &= r; 0 < t;)o = 1 << (n = 31 - it(t)), r |= e[n], t &= ~o; return r } function pt(e, t) { switch (e) { case 1: case 2: case 4: return t + 250; case 8: case 16: case 32: case 64: case 128: case 256: case 512: case 1024: case 2048: case 4096: case 8192: case 16384: case 32768: case 65536: case 131072: case 262144: case 524288: case 1048576: case 2097152: return t + 5e3; default: return -1 } } function ht(e) { return 0 !== (e = -1073741825 & e.pendingLanes) ? e : 1073741824 & e ? 1073741824 : 0 } function vt() { var e = ut; return 0 === (4194240 & (ut <<= 1)) && (ut = 64), e } function gt(e) { for (var t = [], n = 0; 31 > n; n++)t.push(e); return t } function yt(e, t, n) { e.pendingLanes |= t, 536870912 !== t && (e.suspendedLanes = 0, e.pingedLanes = 0), (e = e.eventTimes)[t = 31 - it(t)] = n } function mt(e, t) { var n = e.entangledLanes |= t; for (e = e.entanglements; n;) { var r = 31 - it(n), o = 1 << r; o & t | e[r] & t && (e[r] |= t), n &= ~o } } var bt = 0; function wt(e) { return 1 < (e &= -e) ? 4 < e ? 0 !== (268435455 & e) ? 16 : 536870912 : 4 : 1 } var Ct, Ot, St, xt, Et, kt = !1, _t = [], Pt = null, Tt = null, jt = null, Rt = new Map, It = new Map, Nt = [], Dt = "mousedown mouseup touchcancel touchend touchstart auxclick dblclick pointercancel pointerdown pointerup dragend dragstart drop compositionend compositionstart keydown keypress keyup input textInput copy cut paste click change contextmenu reset submit".split(" "); function Mt(e, t) { switch (e) { case "focusin": case "focusout": Pt = null; break; case "dragenter": case "dragleave": Tt = null; break; case "mouseover": case "mouseout": jt = null; break; case "pointerover": case "pointerout": Rt.delete(t.pointerId); break; case "gotpointercapture": case "lostpointercapture": It.delete(t.pointerId) } } function Lt(e, t, n, r, o, a) { return null === e || e.nativeEvent !== a ? (e = { blockedOn: t, domEventName: n, eventSystemFlags: r, nativeEvent: a, targetContainers: [o] }, null !== t && (null !== (t = wo(t)) && Ot(t)), e) : (e.eventSystemFlags |= r, t = e.targetContainers, null !== o && -1 === t.indexOf(o) && t.push(o), e) } function At(e) { var t = bo(e.target); if (null !== t) { var n = Ke(t); if (null !== n) if (13 === (t = n.tag)) { if (null !== (t = Ue(n))) return e.blockedOn = t, void Et(e.priority, (function () { St(n) })) } else if (3 === t && n.stateNode.current.memoizedState.isDehydrated) return void (e.blockedOn = 3 === n.tag ? n.stateNode.containerInfo : null) } e.blockedOn = null } function Ft(e) { if (null !== e.blockedOn) return !1; for (var t = e.targetContainers; 0 < t.length;) { var n = Xt(e.domEventName, e.eventSystemFlags, t[0], e.nativeEvent); if (null !== n) return null !== (t = wo(n)) && Ot(t), e.blockedOn = n, !1; var r = new (n = e.nativeEvent).constructor(n.type, n); we = r, n.target.dispatchEvent(r), we = null, t.shift() } return !0 } function zt(e, t, n) { Ft(e) && n.delete(t) } function Ht() { kt = !1, null !== Pt && Ft(Pt) && (Pt = null), null !== Tt && Ft(Tt) && (Tt = null), null !== jt && Ft(jt) && (jt = null), Rt.forEach(zt), It.forEach(zt) } function Bt(e, t) { e.blockedOn === t && (e.blockedOn = null, kt || (kt = !0, o.unstable_scheduleCallback(o.unstable_NormalPriority, Ht))) } function Kt(e) { function t(t) { return Bt(t, e) } if (0 < _t.length) { Bt(_t[0], e); for (var n = 1; n < _t.length; n++) { var r = _t[n]; r.blockedOn === e && (r.blockedOn = null) } } for (null !== Pt && Bt(Pt, e), null !== Tt && Bt(Tt, e), null !== jt && Bt(jt, e), Rt.forEach(t), It.forEach(t), n = 0; n < Nt.length; n++)(r = Nt[n]).blockedOn === e && (r.blockedOn = null); for (; 0 < Nt.length && null === (n = Nt[0]).blockedOn;)At(n), null === n.blockedOn && Nt.shift() } var Ut = w.ReactCurrentBatchConfig, Vt = !0; function Wt(e, t, n, r) { var o = bt, a = Ut.transition; Ut.transition = null; try { bt = 1, $t(e, t, n, r) } finally { bt = o, Ut.transition = a } } function Gt(e, t, n, r) { var o = bt, a = Ut.transition; Ut.transition = null; try { bt = 4, $t(e, t, n, r) } finally { bt = o, Ut.transition = a } } function $t(e, t, n, r) { if (Vt) { var o = Xt(e, t, n, r); if (null === o) Vr(e, t, r, Yt, n), Mt(e, r); else if (function (e, t, n, r, o) { switch (t) { case "focusin": return Pt = Lt(Pt, e, t, n, r, o), !0; case "dragenter": return Tt = Lt(Tt, e, t, n, r, o), !0; case "mouseover": return jt = Lt(jt, e, t, n, r, o), !0; case "pointerover": var a = o.pointerId; return Rt.set(a, Lt(Rt.get(a) || null, e, t, n, r, o)), !0; case "gotpointercapture": return a = o.pointerId, It.set(a, Lt(It.get(a) || null, e, t, n, r, o)), !0 }return !1 }(o, e, t, n, r)) r.stopPropagation(); else if (Mt(e, r), 4 & t && -1 < Dt.indexOf(e)) { for (; null !== o;) { var a = wo(o); if (null !== a && Ct(a), null === (a = Xt(e, t, n, r)) && Vr(e, t, r, Yt, n), a === o) break; o = a } null !== o && r.stopPropagation() } else Vr(e, t, r, null, n) } } var Yt = null; function Xt(e, t, n, r) { if (Yt = null, null !== (e = bo(e = Ce(r)))) if (null === (t = Ke(e))) e = null; else if (13 === (n = t.tag)) { if (null !== (e = Ue(t))) return e; e = null } else if (3 === n) { if (t.stateNode.current.memoizedState.isDehydrated) return 3 === t.tag ? t.stateNode.containerInfo : null; e = null } else t !== e && (e = null); return Yt = e, null } function Zt(e) { switch (e) { case "cancel": case "click": case "close": case "contextmenu": case "copy": case "cut": case "auxclick": case "dblclick": case "dragend": case "dragstart": case "drop": case "focusin": case "focusout": case "input": case "invalid": case "keydown": case "keypress": case "keyup": case "mousedown": case "mouseup": case "paste": case "pause": case "play": case "pointercancel": case "pointerdown": case "pointerup": case "ratechange": case "reset": case "resize": case "seeked": case "submit": case "touchcancel": case "touchend": case "touchstart": case "volumechange": case "change": case "selectionchange": case "textInput": case "compositionstart": case "compositionend": case "compositionupdate": case "beforeblur": case "afterblur": case "beforeinput": case "blur": case "fullscreenchange": case "focus": case "hashchange": case "popstate": case "select": case "selectstart": return 1; case "drag": case "dragenter": case "dragexit": case "dragleave": case "dragover": case "mousemove": case "mouseout": case "mouseover": case "pointermove": case "pointerout": case "pointerover": case "scroll": case "toggle": case "touchmove": case "wheel": case "mouseenter": case "mouseleave": case "pointerenter": case "pointerleave": return 4; case "message": switch (Qe()) { case Je: return 1; case et: return 4; case tt: case nt: return 16; case rt: return 536870912; default: return 16 }default: return 16 } } var qt = null, Qt = null, Jt = null; function en() { if (Jt) return Jt; var e, t, n = Qt, r = n.length, o = "value" in qt ? qt.value : qt.textContent, a = o.length; for (e = 0; e < r && n[e] === o[e]; e++); var i = r - e; for (t = 1; t <= i && n[r - t] === o[a - t]; t++); return Jt = o.slice(e, 1 < t ? 1 - t : void 0) } function tn(e) { var t = e.keyCode; return "charCode" in e ? 0 === (e = e.charCode) && 13 === t && (e = 13) : e = t, 10 === e && (e = 13), 32 <= e || 13 === e ? e : 0 } function nn() { return !0 } function rn() { return !1 } function on(e) { function t(t, n, r, o, a) { for (var i in this._reactName = t, this._targetInst = r, this.type = n, this.nativeEvent = o, this.target = a, this.currentTarget = null, e) e.hasOwnProperty(i) && (t = e[i], this[i] = t ? t(o) : o[i]); return this.isDefaultPrevented = (null != o.defaultPrevented ? o.defaultPrevented : !1 === o.returnValue) ? nn : rn, this.isPropagationStopped = rn, this } return A(t.prototype, { preventDefault: function () { this.defaultPrevented = !0; var e = this.nativeEvent; e && (e.preventDefault ? e.preventDefault() : "unknown" !== typeof e.returnValue && (e.returnValue = !1), this.isDefaultPrevented = nn) }, stopPropagation: function () { var e = this.nativeEvent; e && (e.stopPropagation ? e.stopPropagation() : "unknown" !== typeof e.cancelBubble && (e.cancelBubble = !0), this.isPropagationStopped = nn) }, persist: function () { }, isPersistent: nn }), t } var an, ln, sn, un = { eventPhase: 0, bubbles: 0, cancelable: 0, timeStamp: function (e) { return e.timeStamp || Date.now() }, defaultPrevented: 0, isTrusted: 0 }, cn = on(un), dn = A({}, un, { view: 0, detail: 0 }), fn = on(dn), pn = A({}, dn, { screenX: 0, screenY: 0, clientX: 0, clientY: 0, pageX: 0, pageY: 0, ctrlKey: 0, shiftKey: 0, altKey: 0, metaKey: 0, getModifierState: En, button: 0, buttons: 0, relatedTarget: function (e) { return void 0 === e.relatedTarget ? e.fromElement === e.srcElement ? e.toElement : e.fromElement : e.relatedTarget }, movementX: function (e) { return "movementX" in e ? e.movementX : (e !== sn && (sn && "mousemove" === e.type ? (an = e.screenX - sn.screenX, ln = e.screenY - sn.screenY) : ln = an = 0, sn = e), an) }, movementY: function (e) { return "movementY" in e ? e.movementY : ln } }), hn = on(pn), vn = on(A({}, pn, { dataTransfer: 0 })), gn = on(A({}, dn, { relatedTarget: 0 })), yn = on(A({}, un, { animationName: 0, elapsedTime: 0, pseudoElement: 0 })), mn = A({}, un, { clipboardData: function (e) { return "clipboardData" in e ? e.clipboardData : window.clipboardData } }), bn = on(mn), wn = on(A({}, un, { data: 0 })), Cn = { Esc: "Escape", Spacebar: " ", Left: "ArrowLeft", Up: "ArrowUp", Right: "ArrowRight", Down: "ArrowDown", Del: "Delete", Win: "OS", Menu: "ContextMenu", Apps: "ContextMenu", Scroll: "ScrollLock", MozPrintableKey: "Unidentified" }, On = { 8: "Backspace", 9: "Tab", 12: "Clear", 13: "Enter", 16: "Shift", 17: "Control", 18: "Alt", 19: "Pause", 20: "CapsLock", 27: "Escape", 32: " ", 33: "PageUp", 34: "PageDown", 35: "End", 36: "Home", 37: "ArrowLeft", 38: "ArrowUp", 39: "ArrowRight", 40: "ArrowDown", 45: "Insert", 46: "Delete", 112: "F1", 113: "F2", 114: "F3", 115: "F4", 116: "F5", 117: "F6", 118: "F7", 119: "F8", 120: "F9", 121: "F10", 122: "F11", 123: "F12", 144: "NumLock", 145: "ScrollLock", 224: "Meta" }, Sn = { Alt: "altKey", Control: "ctrlKey", Meta: "metaKey", Shift: "shiftKey" }; function xn(e) { var t = this.nativeEvent; return t.getModifierState ? t.getModifierState(e) : !!(e = Sn[e]) && !!t[e] } function En() { return xn } var kn = A({}, dn, { key: function (e) { if (e.key) { var t = Cn[e.key] || e.key; if ("Unidentified" !== t) return t } return "keypress" === e.type ? 13 === (e = tn(e)) ? "Enter" : String.fromCharCode(e) : "keydown" === e.type || "keyup" === e.type ? On[e.keyCode] || "Unidentified" : "" }, code: 0, location: 0, ctrlKey: 0, shiftKey: 0, altKey: 0, metaKey: 0, repeat: 0, locale: 0, getModifierState: En, charCode: function (e) { return "keypress" === e.type ? tn(e) : 0 }, keyCode: function (e) { return "keydown" === e.type || "keyup" === e.type ? e.keyCode : 0 }, which: function (e) { return "keypress" === e.type ? tn(e) : "keydown" === e.type || "keyup" === e.type ? e.keyCode : 0 } }), _n = on(kn), Pn = on(A({}, pn, { pointerId: 0, width: 0, height: 0, pressure: 0, tangentialPressure: 0, tiltX: 0, tiltY: 0, twist: 0, pointerType: 0, isPrimary: 0 })), Tn = on(A({}, dn, { touches: 0, targetTouches: 0, changedTouches: 0, altKey: 0, metaKey: 0, ctrlKey: 0, shiftKey: 0, getModifierState: En })), jn = on(A({}, un, { propertyName: 0, elapsedTime: 0, pseudoElement: 0 })), Rn = A({}, pn, { deltaX: function (e) { return "deltaX" in e ? e.deltaX : "wheelDeltaX" in e ? -e.wheelDeltaX : 0 }, deltaY: function (e) { return "deltaY" in e ? e.deltaY : "wheelDeltaY" in e ? -e.wheelDeltaY : "wheelDelta" in e ? -e.wheelDelta : 0 }, deltaZ: 0, deltaMode: 0 }), In = on(Rn), Nn = [9, 13, 27, 32], Dn = c && "CompositionEvent" in window, Mn = null; c && "documentMode" in document && (Mn = document.documentMode); var Ln = c && "TextEvent" in window && !Mn, An = c && (!Dn || Mn && 8 < Mn && 11 >= Mn), Fn = String.fromCharCode(32), zn = !1; function Hn(e, t) { switch (e) { case "keyup": return -1 !== Nn.indexOf(t.keyCode); case "keydown": return 229 !== t.keyCode; case "keypress": case "mousedown": case "focusout": return !0; default: return !1 } } function Bn(e) { return "object" === typeof (e = e.detail) && "data" in e ? e.data : null } var Kn = !1; var Un = { color: !0, date: !0, datetime: !0, "datetime-local": !0, email: !0, month: !0, number: !0, password: !0, range: !0, search: !0, tel: !0, text: !0, time: !0, url: !0, week: !0 }; function Vn(e) { var t = e && e.nodeName && e.nodeName.toLowerCase(); return "input" === t ? !!Un[e.type] : "textarea" === t } function Wn(e, t, n, r) { ke(r), 0 < (t = Gr(t, "onChange")).length && (n = new cn("onChange", "change", null, n, r), e.push({ event: n, listeners: t })) } var Gn = null, $n = null; function Yn(e) { Fr(e, 0) } function Xn(e) { if ($(Co(e))) return e } function Zn(e, t) { if ("change" === e) return t } var qn = !1; if (c) { var Qn; if (c) { var Jn = "oninput" in document; if (!Jn) { var er = document.createElement("div"); er.setAttribute("oninput", "return;"), Jn = "function" === typeof er.oninput } Qn = Jn } else Qn = !1; qn = Qn && (!document.documentMode || 9 < document.documentMode) } function tr() { Gn && (Gn.detachEvent("onpropertychange", nr), $n = Gn = null) } function nr(e) { if ("value" === e.propertyName && Xn($n)) { var t = []; Wn(t, $n, e, Ce(e)), Re(Yn, t) } } function rr(e, t, n) { "focusin" === e ? (tr(), $n = n, (Gn = t).attachEvent("onpropertychange", nr)) : "focusout" === e && tr() } function or(e) { if ("selectionchange" === e || "keyup" === e || "keydown" === e) return Xn($n) } function ar(e, t) { if ("click" === e) return Xn(t) } function ir(e, t) { if ("input" === e || "change" === e) return Xn(t) } var lr = "function" === typeof Object.is ? Object.is : function (e, t) { return e === t && (0 !== e || 1 / e === 1 / t) || e !== e && t !== t }; function sr(e, t) { if (lr(e, t)) return !0; if ("object" !== typeof e || null === e || "object" !== typeof t || null === t) return !1; var n = Object.keys(e), r = Object.keys(t); if (n.length !== r.length) return !1; for (r = 0; r < n.length; r++) { var o = n[r]; if (!d.call(t, o) || !lr(e[o], t[o])) return !1 } return !0 } function ur(e) { for (; e && e.firstChild;)e = e.firstChild; return e } function cr(e, t) { var n, r = ur(e); for (e = 0; r;) { if (3 === r.nodeType) { if (n = e + r.textContent.length, e <= t && n >= t) return { node: r, offset: t - e }; e = n } e: { for (; r;) { if (r.nextSibling) { r = r.nextSibling; break e } r = r.parentNode } r = void 0 } r = ur(r) } } function dr(e, t) { return !(!e || !t) && (e === t || (!e || 3 !== e.nodeType) && (t && 3 === t.nodeType ? dr(e, t.parentNode) : "contains" in e ? e.contains(t) : !!e.compareDocumentPosition && !!(16 & e.compareDocumentPosition(t)))) } function fr() { for (var e = window, t = Y(); t instanceof e.HTMLIFrameElement;) { try { var n = "string" === typeof t.contentWindow.location.href } catch (r) { n = !1 } if (!n) break; t = Y((e = t.contentWindow).document) } return t } function pr(e) { var t = e && e.nodeName && e.nodeName.toLowerCase(); return t && ("input" === t && ("text" === e.type || "search" === e.type || "tel" === e.type || "url" === e.type || "password" === e.type) || "textarea" === t || "true" === e.contentEditable) } function hr(e) { var t = fr(), n = e.focusedElem, r = e.selectionRange; if (t !== n && n && n.ownerDocument && dr(n.ownerDocument.documentElement, n)) { if (null !== r && pr(n)) if (t = r.start, void 0 === (e = r.end) && (e = t), "selectionStart" in n) n.selectionStart = t, n.selectionEnd = Math.min(e, n.value.length); else if ((e = (t = n.ownerDocument || document) && t.defaultView || window).getSelection) { e = e.getSelection(); var o = n.textContent.length, a = Math.min(r.start, o); r = void 0 === r.end ? a : Math.min(r.end, o), !e.extend && a > r && (o = r, r = a, a = o), o = cr(n, a); var i = cr(n, r); o && i && (1 !== e.rangeCount || e.anchorNode !== o.node || e.anchorOffset !== o.offset || e.focusNode !== i.node || e.focusOffset !== i.offset) && ((t = t.createRange()).setStart(o.node, o.offset), e.removeAllRanges(), a > r ? (e.addRange(t), e.extend(i.node, i.offset)) : (t.setEnd(i.node, i.offset), e.addRange(t))) } for (t = [], e = n; e = e.parentNode;)1 === e.nodeType && t.push({ element: e, left: e.scrollLeft, top: e.scrollTop }); for ("function" === typeof n.focus && n.focus(), n = 0; n < t.length; n++)(e = t[n]).element.scrollLeft = e.left, e.element.scrollTop = e.top } } var vr = c && "documentMode" in document && 11 >= document.documentMode, gr = null, yr = null, mr = null, br = !1; function wr(e, t, n) { var r = n.window === n ? n.document : 9 === n.nodeType ? n : n.ownerDocument; br || null == gr || gr !== Y(r) || ("selectionStart" in (r = gr) && pr(r) ? r = { start: r.selectionStart, end: r.selectionEnd } : r = { anchorNode: (r = (r.ownerDocument && r.ownerDocument.defaultView || window).getSelection()).anchorNode, anchorOffset: r.anchorOffset, focusNode: r.focusNode, focusOffset: r.focusOffset }, mr && sr(mr, r) || (mr = r, 0 < (r = Gr(yr, "onSelect")).length && (t = new cn("onSelect", "select", null, t, n), e.push({ event: t, listeners: r }), t.target = gr))) } function Cr(e, t) { var n = {}; return n[e.toLowerCase()] = t.toLowerCase(), n["Webkit" + e] = "webkit" + t, n["Moz" + e] = "moz" + t, n } var Or = { animationend: Cr("Animation", "AnimationEnd"), animationiteration: Cr("Animation", "AnimationIteration"), animationstart: Cr("Animation", "AnimationStart"), transitionend: Cr("Transition", "TransitionEnd") }, Sr = {}, xr = {}; function Er(e) { if (Sr[e]) return Sr[e]; if (!Or[e]) return e; var t, n = Or[e]; for (t in n) if (n.hasOwnProperty(t) && t in xr) return Sr[e] = n[t]; return e } c && (xr = document.createElement("div").style, "AnimationEvent" in window || (delete Or.animationend.animation, delete Or.animationiteration.animation, delete Or.animationstart.animation), "TransitionEvent" in window || delete Or.transitionend.transition); var kr = Er("animationend"), _r = Er("animationiteration"), Pr = Er("animationstart"), Tr = Er("transitionend"), jr = new Map, Rr = "abort auxClick cancel canPlay canPlayThrough click close contextMenu copy cut drag dragEnd dragEnter dragExit dragLeave dragOver dragStart drop durationChange emptied encrypted ended error gotPointerCapture input invalid keyDown keyPress keyUp load loadedData loadedMetadata loadStart lostPointerCapture mouseDown mouseMove mouseOut mouseOver mouseUp paste pause play playing pointerCancel pointerDown pointerMove pointerOut pointerOver pointerUp progress rateChange reset resize seeked seeking stalled submit suspend timeUpdate touchCancel touchEnd touchStart volumeChange scroll toggle touchMove waiting wheel".split(" "); function Ir(e, t) { jr.set(e, t), s(t, [e]) } for (var Nr = 0; Nr < Rr.length; Nr++) { var Dr = Rr[Nr]; Ir(Dr.toLowerCase(), "on" + (Dr[0].toUpperCase() + Dr.slice(1))) } Ir(kr, "onAnimationEnd"), Ir(_r, "onAnimationIteration"), Ir(Pr, "onAnimationStart"), Ir("dblclick", "onDoubleClick"), Ir("focusin", "onFocus"), Ir("focusout", "onBlur"), Ir(Tr, "onTransitionEnd"), u("onMouseEnter", ["mouseout", "mouseover"]), u("onMouseLeave", ["mouseout", "mouseover"]), u("onPointerEnter", ["pointerout", "pointerover"]), u("onPointerLeave", ["pointerout", "pointerover"]), s("onChange", "change click focusin focusout input keydown keyup selectionchange".split(" ")), s("onSelect", "focusout contextmenu dragend focusin keydown keyup mousedown mouseup selectionchange".split(" ")), s("onBeforeInput", ["compositionend", "keypress", "textInput", "paste"]), s("onCompositionEnd", "compositionend focusout keydown keypress keyup mousedown".split(" ")), s("onCompositionStart", "compositionstart focusout keydown keypress keyup mousedown".split(" ")), s("onCompositionUpdate", "compositionupdate focusout keydown keypress keyup mousedown".split(" ")); var Mr = "abort canplay canplaythrough durationchange emptied encrypted ended error loadeddata loadedmetadata loadstart pause play playing progress ratechange resize seeked seeking stalled suspend timeupdate volumechange waiting".split(" "), Lr = new Set("cancel close invalid load scroll toggle".split(" ").concat(Mr)); function Ar(e, t, n) { var r = e.type || "unknown-event"; e.currentTarget = n, function (e, t, n, r, o, i, l, s, u) { if (Be.apply(this, arguments), Le) { if (!Le) throw Error(a(198)); var c = Ae; Le = !1, Ae = null, Fe || (Fe = !0, ze = c) } }(r, t, void 0, e), e.currentTarget = null } function Fr(e, t) { t = 0 !== (4 & t); for (var n = 0; n < e.length; n++) { var r = e[n], o = r.event; r = r.listeners; e: { var a = void 0; if (t) for (var i = r.length - 1; 0 <= i; i--) { var l = r[i], s = l.instance, u = l.currentTarget; if (l = l.listener, s !== a && o.isPropagationStopped()) break e; Ar(o, l, u), a = s } else for (i = 0; i < r.length; i++) { if (s = (l = r[i]).instance, u = l.currentTarget, l = l.listener, s !== a && o.isPropagationStopped()) break e; Ar(o, l, u), a = s } } } if (Fe) throw e = ze, Fe = !1, ze = null, e } function zr(e, t) { var n = t[go]; void 0 === n && (n = t[go] = new Set); var r = e + "__bubble"; n.has(r) || (Ur(t, e, 2, !1), n.add(r)) } function Hr(e, t, n) { var r = 0; t && (r |= 4), Ur(n, e, r, t) } var Br = "_reactListening" + Math.random().toString(36).slice(2); function Kr(e) { if (!e[Br]) { e[Br] = !0, i.forEach((function (t) { "selectionchange" !== t && (Lr.has(t) || Hr(t, !1, e), Hr(t, !0, e)) })); var t = 9 === e.nodeType ? e : e.ownerDocument; null === t || t[Br] || (t[Br] = !0, Hr("selectionchange", !1, t)) } } function Ur(e, t, n, r) { switch (Zt(t)) { case 1: var o = Wt; break; case 4: o = Gt; break; default: o = $t }n = o.bind(null, t, n, e), o = void 0, !Ne || "touchstart" !== t && "touchmove" !== t && "wheel" !== t || (o = !0), r ? void 0 !== o ? e.addEventListener(t, n, { capture: !0, passive: o }) : e.addEventListener(t, n, !0) : void 0 !== o ? e.addEventListener(t, n, { passive: o }) : e.addEventListener(t, n, !1) } function Vr(e, t, n, r, o) { var a = r; if (0 === (1 & t) && 0 === (2 & t) && null !== r) e: for (; ;) { if (null === r) return; var i = r.tag; if (3 === i || 4 === i) { var l = r.stateNode.containerInfo; if (l === o || 8 === l.nodeType && l.parentNode === o) break; if (4 === i) for (i = r.return; null !== i;) { var s = i.tag; if ((3 === s || 4 === s) && ((s = i.stateNode.containerInfo) === o || 8 === s.nodeType && s.parentNode === o)) return; i = i.return } for (; null !== l;) { if (null === (i = bo(l))) return; if (5 === (s = i.tag) || 6 === s) { r = a = i; continue e } l = l.parentNode } } r = r.return } Re((function () { var r = a, o = Ce(n), i = []; e: { var l = jr.get(e); if (void 0 !== l) { var s = cn, u = e; switch (e) { case "keypress": if (0 === tn(n)) break e; case "keydown": case "keyup": s = _n; break; case "focusin": u = "focus", s = gn; break; case "focusout": u = "blur", s = gn; break; case "beforeblur": case "afterblur": s = gn; break; case "click": if (2 === n.button) break e; case "auxclick": case "dblclick": case "mousedown": case "mousemove": case "mouseup": case "mouseout": case "mouseover": case "contextmenu": s = hn; break; case "drag": case "dragend": case "dragenter": case "dragexit": case "dragleave": case "dragover": case "dragstart": case "drop": s = vn; break; case "touchcancel": case "touchend": case "touchmove": case "touchstart": s = Tn; break; case kr: case _r: case Pr: s = yn; break; case Tr: s = jn; break; case "scroll": s = fn; break; case "wheel": s = In; break; case "copy": case "cut": case "paste": s = bn; break; case "gotpointercapture": case "lostpointercapture": case "pointercancel": case "pointerdown": case "pointermove": case "pointerout": case "pointerover": case "pointerup": s = Pn }var c = 0 !== (4 & t), d = !c && "scroll" === e, f = c ? null !== l ? l + "Capture" : null : l; c = []; for (var p, h = r; null !== h;) { var v = (p = h).stateNode; if (5 === p.tag && null !== v && (p = v, null !== f && (null != (v = Ie(h, f)) && c.push(Wr(h, v, p)))), d) break; h = h.return } 0 < c.length && (l = new s(l, u, null, n, o), i.push({ event: l, listeners: c })) } } if (0 === (7 & t)) { if (s = "mouseout" === e || "pointerout" === e, (!(l = "mouseover" === e || "pointerover" === e) || n === we || !(u = n.relatedTarget || n.fromElement) || !bo(u) && !u[vo]) && (s || l) && (l = o.window === o ? o : (l = o.ownerDocument) ? l.defaultView || l.parentWindow : window, s ? (s = r, null !== (u = (u = n.relatedTarget || n.toElement) ? bo(u) : null) && (u !== (d = Ke(u)) || 5 !== u.tag && 6 !== u.tag) && (u = null)) : (s = null, u = r), s !== u)) { if (c = hn, v = "onMouseLeave", f = "onMouseEnter", h = "mouse", "pointerout" !== e && "pointerover" !== e || (c = Pn, v = "onPointerLeave", f = "onPointerEnter", h = "pointer"), d = null == s ? l : Co(s), p = null == u ? l : Co(u), (l = new c(v, h + "leave", s, n, o)).target = d, l.relatedTarget = p, v = null, bo(o) === r && ((c = new c(f, h + "enter", u, n, o)).target = p, c.relatedTarget = d, v = c), d = v, s && u) e: { for (f = u, h = 0, p = c = s; p; p = $r(p))h++; for (p = 0, v = f; v; v = $r(v))p++; for (; 0 < h - p;)c = $r(c), h--; for (; 0 < p - h;)f = $r(f), p--; for (; h--;) { if (c === f || null !== f && c === f.alternate) break e; c = $r(c), f = $r(f) } c = null } else c = null; null !== s && Yr(i, l, s, c, !1), null !== u && null !== d && Yr(i, d, u, c, !0) } if ("select" === (s = (l = r ? Co(r) : window).nodeName && l.nodeName.toLowerCase()) || "input" === s && "file" === l.type) var g = Zn; else if (Vn(l)) if (qn) g = ir; else { g = or; var y = rr } else (s = l.nodeName) && "input" === s.toLowerCase() && ("checkbox" === l.type || "radio" === l.type) && (g = ar); switch (g && (g = g(e, r)) ? Wn(i, g, n, o) : (y && y(e, l, r), "focusout" === e && (y = l._wrapperState) && y.controlled && "number" === l.type && ee(l, "number", l.value)), y = r ? Co(r) : window, e) { case "focusin": (Vn(y) || "true" === y.contentEditable) && (gr = y, yr = r, mr = null); break; case "focusout": mr = yr = gr = null; break; case "mousedown": br = !0; break; case "contextmenu": case "mouseup": case "dragend": br = !1, wr(i, n, o); break; case "selectionchange": if (vr) break; case "keydown": case "keyup": wr(i, n, o) }var m; if (Dn) e: { switch (e) { case "compositionstart": var b = "onCompositionStart"; break e; case "compositionend": b = "onCompositionEnd"; break e; case "compositionupdate": b = "onCompositionUpdate"; break e }b = void 0 } else Kn ? Hn(e, n) && (b = "onCompositionEnd") : "keydown" === e && 229 === n.keyCode && (b = "onCompositionStart"); b && (An && "ko" !== n.locale && (Kn || "onCompositionStart" !== b ? "onCompositionEnd" === b && Kn && (m = en()) : (Qt = "value" in (qt = o) ? qt.value : qt.textContent, Kn = !0)), 0 < (y = Gr(r, b)).length && (b = new wn(b, e, null, n, o), i.push({ event: b, listeners: y }), m ? b.data = m : null !== (m = Bn(n)) && (b.data = m))), (m = Ln ? function (e, t) { switch (e) { case "compositionend": return Bn(t); case "keypress": return 32 !== t.which ? null : (zn = !0, Fn); case "textInput": return (e = t.data) === Fn && zn ? null : e; default: return null } }(e, n) : function (e, t) { if (Kn) return "compositionend" === e || !Dn && Hn(e, t) ? (e = en(), Jt = Qt = qt = null, Kn = !1, e) : null; switch (e) { case "paste": default: return null; case "keypress": if (!(t.ctrlKey || t.altKey || t.metaKey) || t.ctrlKey && t.altKey) { if (t.char && 1 < t.char.length) return t.char; if (t.which) return String.fromCharCode(t.which) } return null; case "compositionend": return An && "ko" !== t.locale ? null : t.data } }(e, n)) && (0 < (r = Gr(r, "onBeforeInput")).length && (o = new wn("onBeforeInput", "beforeinput", null, n, o), i.push({ event: o, listeners: r }), o.data = m)) } Fr(i, t) })) } function Wr(e, t, n) { return { instance: e, listener: t, currentTarget: n } } function Gr(e, t) { for (var n = t + "Capture", r = []; null !== e;) { var o = e, a = o.stateNode; 5 === o.tag && null !== a && (o = a, null != (a = Ie(e, n)) && r.unshift(Wr(e, a, o)), null != (a = Ie(e, t)) && r.push(Wr(e, a, o))), e = e.return } return r } function $r(e) { if (null === e) return null; do { e = e.return } while (e && 5 !== e.tag); return e || null } function Yr(e, t, n, r, o) { for (var a = t._reactName, i = []; null !== n && n !== r;) { var l = n, s = l.alternate, u = l.stateNode; if (null !== s && s === r) break; 5 === l.tag && null !== u && (l = u, o ? null != (s = Ie(n, a)) && i.unshift(Wr(n, s, l)) : o || null != (s = Ie(n, a)) && i.push(Wr(n, s, l))), n = n.return } 0 !== i.length && e.push({ event: t, listeners: i }) } var Xr = /\r\n?/g, Zr = /\u0000|\uFFFD/g; function qr(e) { return ("string" === typeof e ? e : "" + e).replace(Xr, "\n").replace(Zr, "") } function Qr(e, t, n) { if (t = qr(t), qr(e) !== t && n) throw Error(a(425)) } function Jr() { } var eo = null, to = null; function no(e, t) { return "textarea" === e || "noscript" === e || "string" === typeof t.children || "number" === typeof t.children || "object" === typeof t.dangerouslySetInnerHTML && null !== t.dangerouslySetInnerHTML && null != t.dangerouslySetInnerHTML.__html } var ro = "function" === typeof setTimeout ? setTimeout : void 0, oo = "function" === typeof clearTimeout ? clearTimeout : void 0, ao = "function" === typeof Promise ? Promise : void 0, io = "function" === typeof queueMicrotask ? queueMicrotask : "undefined" !== typeof ao ? function (e) { return ao.resolve(null).then(e).catch(lo) } : ro; function lo(e) { setTimeout((function () { throw e })) } function so(e, t) { var n = t, r = 0; do { var o = n.nextSibling; if (e.removeChild(n), o && 8 === o.nodeType) if ("/$" === (n = o.data)) { if (0 === r) return e.removeChild(o), void Kt(t); r-- } else "$" !== n && "$?" !== n && "$!" !== n || r++; n = o } while (n); Kt(t) } function uo(e) { for (; null != e; e = e.nextSibling) { var t = e.nodeType; if (1 === t || 3 === t) break; if (8 === t) { if ("$" === (t = e.data) || "$!" === t || "$?" === t) break; if ("/$" === t) return null } } return e } function co(e) { e = e.previousSibling; for (var t = 0; e;) { if (8 === e.nodeType) { var n = e.data; if ("$" === n || "$!" === n || "$?" === n) { if (0 === t) return e; t-- } else "/$" === n && t++ } e = e.previousSibling } return null } var fo = Math.random().toString(36).slice(2), po = "__reactFiber$" + fo, ho = "__reactProps$" + fo, vo = "__reactContainer$" + fo, go = "__reactEvents$" + fo, yo = "__reactListeners$" + fo, mo = "__reactHandles$" + fo; function bo(e) { var t = e[po]; if (t) return t; for (var n = e.parentNode; n;) { if (t = n[vo] || n[po]) { if (n = t.alternate, null !== t.child || null !== n && null !== n.child) for (e = co(e); null !== e;) { if (n = e[po]) return n; e = co(e) } return t } n = (e = n).parentNode } return null } function wo(e) { return !(e = e[po] || e[vo]) || 5 !== e.tag && 6 !== e.tag && 13 !== e.tag && 3 !== e.tag ? null : e } function Co(e) { if (5 === e.tag || 6 === e.tag) return e.stateNode; throw Error(a(33)) } function Oo(e) { return e[ho] || null } var So = [], xo = -1; function Eo(e) { return { current: e } } function ko(e) { 0 > xo || (e.current = So[xo], So[xo] = null, xo--) } function _o(e, t) { xo++, So[xo] = e.current, e.current = t } var Po = {}, To = Eo(Po), jo = Eo(!1), Ro = Po; function Io(e, t) { var n = e.type.contextTypes; if (!n) return Po; var r = e.stateNode; if (r && r.__reactInternalMemoizedUnmaskedChildContext === t) return r.__reactInternalMemoizedMaskedChildContext; var o, a = {}; for (o in n) a[o] = t[o]; return r && ((e = e.stateNode).__reactInternalMemoizedUnmaskedChildContext = t, e.__reactInternalMemoizedMaskedChildContext = a), a } function No(e) { return null !== (e = e.childContextTypes) && void 0 !== e } function Do() { ko(jo), ko(To) } function Mo(e, t, n) { if (To.current !== Po) throw Error(a(168)); _o(To, t), _o(jo, n) } function Lo(e, t, n) { var r = e.stateNode; if (t = t.childContextTypes, "function" !== typeof r.getChildContext) return n; for (var o in r = r.getChildContext()) if (!(o in t)) throw Error(a(108, U(e) || "Unknown", o)); return A({}, n, r) } function Ao(e) { return e = (e = e.stateNode) && e.__reactInternalMemoizedMergedChildContext || Po, Ro = To.current, _o(To, e), _o(jo, jo.current), !0 } function Fo(e, t, n) { var r = e.stateNode; if (!r) throw Error(a(169)); n ? (e = Lo(e, t, Ro), r.__reactInternalMemoizedMergedChildContext = e, ko(jo), ko(To), _o(To, e)) : ko(jo), _o(jo, n) } var zo = null, Ho = !1, Bo = !1; function Ko(e) { null === zo ? zo = [e] : zo.push(e) } function Uo() { if (!Bo && null !== zo) { Bo = !0; var e = 0, t = bt; try { var n = zo; for (bt = 1; e < n.length; e++) { var r = n[e]; do { r = r(!0) } while (null !== r) } zo = null, Ho = !1 } catch (o) { throw null !== zo && (zo = zo.slice(e + 1)), $e(Je, Uo), o } finally { bt = t, Bo = !1 } } return null } var Vo = [], Wo = 0, Go = null, $o = 0, Yo = [], Xo = 0, Zo = null, qo = 1, Qo = ""; function Jo(e, t) { Vo[Wo++] = $o, Vo[Wo++] = Go, Go = e, $o = t } function ea(e, t, n) { Yo[Xo++] = qo, Yo[Xo++] = Qo, Yo[Xo++] = Zo, Zo = e; var r = qo; e = Qo; var o = 32 - it(r) - 1; r &= ~(1 << o), n += 1; var a = 32 - it(t) + o; if (30 < a) { var i = o - o % 5; a = (r & (1 << i) - 1).toString(32), r >>= i, o -= i, qo = 1 << 32 - it(t) + o | n << o | r, Qo = a + e } else qo = 1 << a | n << o | r, Qo = e } function ta(e) { null !== e.return && (Jo(e, 1), ea(e, 1, 0)) } function na(e) { for (; e === Go;)Go = Vo[--Wo], Vo[Wo] = null, $o = Vo[--Wo], Vo[Wo] = null; for (; e === Zo;)Zo = Yo[--Xo], Yo[Xo] = null, Qo = Yo[--Xo], Yo[Xo] = null, qo = Yo[--Xo], Yo[Xo] = null } var ra = null, oa = null, aa = !1, ia = null; function la(e, t) { var n = ju(5, null, null, 0); n.elementType = "DELETED", n.stateNode = t, n.return = e, null === (t = e.deletions) ? (e.deletions = [n], e.flags |= 16) : t.push(n) } function sa(e, t) { switch (e.tag) { case 5: var n = e.type; return null !== (t = 1 !== t.nodeType || n.toLowerCase() !== t.nodeName.toLowerCase() ? null : t) && (e.stateNode = t, ra = e, oa = uo(t.firstChild), !0); case 6: return null !== (t = "" === e.pendingProps || 3 !== t.nodeType ? null : t) && (e.stateNode = t, ra = e, oa = null, !0); case 13: return null !== (t = 8 !== t.nodeType ? null : t) && (n = null !== Zo ? { id: qo, overflow: Qo } : null, e.memoizedState = { dehydrated: t, treeContext: n, retryLane: 1073741824 }, (n = ju(18, null, null, 0)).stateNode = t, n.return = e, e.child = n, ra = e, oa = null, !0); default: return !1 } } function ua(e) { return 0 !== (1 & e.mode) && 0 === (128 & e.flags) } function ca(e) { if (aa) { var t = oa; if (t) { var n = t; if (!sa(e, t)) { if (ua(e)) throw Error(a(418)); t = uo(n.nextSibling); var r = ra; t && sa(e, t) ? la(r, n) : (e.flags = -4097 & e.flags | 2, aa = !1, ra = e) } } else { if (ua(e)) throw Error(a(418)); e.flags = -4097 & e.flags | 2, aa = !1, ra = e } } } function da(e) { for (e = e.return; null !== e && 5 !== e.tag && 3 !== e.tag && 13 !== e.tag;)e = e.return; ra = e } function fa(e) { if (e !== ra) return !1; if (!aa) return da(e), aa = !0, !1; var t; if ((t = 3 !== e.tag) && !(t = 5 !== e.tag) && (t = "head" !== (t = e.type) && "body" !== t && !no(e.type, e.memoizedProps)), t && (t = oa)) { if (ua(e)) throw pa(), Error(a(418)); for (; t;)la(e, t), t = uo(t.nextSibling) } if (da(e), 13 === e.tag) { if (!(e = null !== (e = e.memoizedState) ? e.dehydrated : null)) throw Error(a(317)); e: { for (e = e.nextSibling, t = 0; e;) { if (8 === e.nodeType) { var n = e.data; if ("/$" === n) { if (0 === t) { oa = uo(e.nextSibling); break e } t-- } else "$" !== n && "$!" !== n && "$?" !== n || t++ } e = e.nextSibling } oa = null } } else oa = ra ? uo(e.stateNode.nextSibling) : null; return !0 } function pa() { for (var e = oa; e;)e = uo(e.nextSibling) } function ha() { oa = ra = null, aa = !1 } function va(e) { null === ia ? ia = [e] : ia.push(e) } var ga = w.ReactCurrentBatchConfig; function ya(e, t, n) { if (null !== (e = n.ref) && "function" !== typeof e && "object" !== typeof e) { if (n._owner) { if (n = n._owner) { if (1 !== n.tag) throw Error(a(309)); var r = n.stateNode } if (!r) throw Error(a(147, e)); var o = r, i = "" + e; return null !== t && null !== t.ref && "function" === typeof t.ref && t.ref._stringRef === i ? t.ref : (t = function (e) { var t = o.refs; null === e ? delete t[i] : t[i] = e }, t._stringRef = i, t) } if ("string" !== typeof e) throw Error(a(284)); if (!n._owner) throw Error(a(290, e)) } return e } function ma(e, t) { throw e = Object.prototype.toString.call(t), Error(a(31, "[object Object]" === e ? "object with keys {" + Object.keys(t).join(", ") + "}" : e)) } function ba(e) { return (0, e._init)(e._payload) } function wa(e) { function t(t, n) { if (e) { var r = t.deletions; null === r ? (t.deletions = [n], t.flags |= 16) : r.push(n) } } function n(n, r) { if (!e) return null; for (; null !== r;)t(n, r), r = r.sibling; return null } function r(e, t) { for (e = new Map; null !== t;)null !== t.key ? e.set(t.key, t) : e.set(t.index, t), t = t.sibling; return e } function o(e, t) { return (e = Iu(e, t)).index = 0, e.sibling = null, e } function i(t, n, r) { return t.index = r, e ? null !== (r = t.alternate) ? (r = r.index) < n ? (t.flags |= 2, n) : r : (t.flags |= 2, n) : (t.flags |= 1048576, n) } function l(t) { return e && null === t.alternate && (t.flags |= 2), t } function s(e, t, n, r) { return null === t || 6 !== t.tag ? ((t = Lu(n, e.mode, r)).return = e, t) : ((t = o(t, n)).return = e, t) } function u(e, t, n, r) { var a = n.type; return a === S ? d(e, t, n.props.children, r, n.key) : null !== t && (t.elementType === a || "object" === typeof a && null !== a && a.$$typeof === I && ba(a) === t.type) ? ((r = o(t, n.props)).ref = ya(e, t, n), r.return = e, r) : ((r = Nu(n.type, n.key, n.props, null, e.mode, r)).ref = ya(e, t, n), r.return = e, r) } function c(e, t, n, r) { return null === t || 4 !== t.tag || t.stateNode.containerInfo !== n.containerInfo || t.stateNode.implementation !== n.implementation ? ((t = Au(n, e.mode, r)).return = e, t) : ((t = o(t, n.children || [])).return = e, t) } function d(e, t, n, r, a) { return null === t || 7 !== t.tag ? ((t = Du(n, e.mode, r, a)).return = e, t) : ((t = o(t, n)).return = e, t) } function f(e, t, n) { if ("string" === typeof t && "" !== t || "number" === typeof t) return (t = Lu("" + t, e.mode, n)).return = e, t; if ("object" === typeof t && null !== t) { switch (t.$$typeof) { case C: return (n = Nu(t.type, t.key, t.props, null, e.mode, n)).ref = ya(e, null, t), n.return = e, n; case O: return (t = Au(t, e.mode, n)).return = e, t; case I: return f(e, (0, t._init)(t._payload), n) }if (te(t) || M(t)) return (t = Du(t, e.mode, n, null)).return = e, t; ma(e, t) } return null } function p(e, t, n, r) { var o = null !== t ? t.key : null; if ("string" === typeof n && "" !== n || "number" === typeof n) return null !== o ? null : s(e, t, "" + n, r); if ("object" === typeof n && null !== n) { switch (n.$$typeof) { case C: return n.key === o ? u(e, t, n, r) : null; case O: return n.key === o ? c(e, t, n, r) : null; case I: return p(e, t, (o = n._init)(n._payload), r) }if (te(n) || M(n)) return null !== o ? null : d(e, t, n, r, null); ma(e, n) } return null } function h(e, t, n, r, o) { if ("string" === typeof r && "" !== r || "number" === typeof r) return s(t, e = e.get(n) || null, "" + r, o); if ("object" === typeof r && null !== r) { switch (r.$$typeof) { case C: return u(t, e = e.get(null === r.key ? n : r.key) || null, r, o); case O: return c(t, e = e.get(null === r.key ? n : r.key) || null, r, o); case I: return h(e, t, n, (0, r._init)(r._payload), o) }if (te(r) || M(r)) return d(t, e = e.get(n) || null, r, o, null); ma(t, r) } return null } function v(o, a, l, s) { for (var u = null, c = null, d = a, v = a = 0, g = null; null !== d && v < l.length; v++) { d.index > v ? (g = d, d = null) : g = d.sibling; var y = p(o, d, l[v], s); if (null === y) { null === d && (d = g); break } e && d && null === y.alternate && t(o, d), a = i(y, a, v), null === c ? u = y : c.sibling = y, c = y, d = g } if (v === l.length) return n(o, d), aa && Jo(o, v), u; if (null === d) { for (; v < l.length; v++)null !== (d = f(o, l[v], s)) && (a = i(d, a, v), null === c ? u = d : c.sibling = d, c = d); return aa && Jo(o, v), u } for (d = r(o, d); v < l.length; v++)null !== (g = h(d, o, v, l[v], s)) && (e && null !== g.alternate && d.delete(null === g.key ? v : g.key), a = i(g, a, v), null === c ? u = g : c.sibling = g, c = g); return e && d.forEach((function (e) { return t(o, e) })), aa && Jo(o, v), u } function g(o, l, s, u) { var c = M(s); if ("function" !== typeof c) throw Error(a(150)); if (null == (s = c.call(s))) throw Error(a(151)); for (var d = c = null, v = l, g = l = 0, y = null, m = s.next(); null !== v && !m.done; g++, m = s.next()) { v.index > g ? (y = v, v = null) : y = v.sibling; var b = p(o, v, m.value, u); if (null === b) { null === v && (v = y); break } e && v && null === b.alternate && t(o, v), l = i(b, l, g), null === d ? c = b : d.sibling = b, d = b, v = y } if (m.done) return n(o, v), aa && Jo(o, g), c; if (null === v) { for (; !m.done; g++, m = s.next())null !== (m = f(o, m.value, u)) && (l = i(m, l, g), null === d ? c = m : d.sibling = m, d = m); return aa && Jo(o, g), c } for (v = r(o, v); !m.done; g++, m = s.next())null !== (m = h(v, o, g, m.value, u)) && (e && null !== m.alternate && v.delete(null === m.key ? g : m.key), l = i(m, l, g), null === d ? c = m : d.sibling = m, d = m); return e && v.forEach((function (e) { return t(o, e) })), aa && Jo(o, g), c } return function e(r, a, i, s) { if ("object" === typeof i && null !== i && i.type === S && null === i.key && (i = i.props.children), "object" === typeof i && null !== i) { switch (i.$$typeof) { case C: e: { for (var u = i.key, c = a; null !== c;) { if (c.key === u) { if ((u = i.type) === S) { if (7 === c.tag) { n(r, c.sibling), (a = o(c, i.props.children)).return = r, r = a; break e } } else if (c.elementType === u || "object" === typeof u && null !== u && u.$$typeof === I && ba(u) === c.type) { n(r, c.sibling), (a = o(c, i.props)).ref = ya(r, c, i), a.return = r, r = a; break e } n(r, c); break } t(r, c), c = c.sibling } i.type === S ? ((a = Du(i.props.children, r.mode, s, i.key)).return = r, r = a) : ((s = Nu(i.type, i.key, i.props, null, r.mode, s)).ref = ya(r, a, i), s.return = r, r = s) } return l(r); case O: e: { for (c = i.key; null !== a;) { if (a.key === c) { if (4 === a.tag && a.stateNode.containerInfo === i.containerInfo && a.stateNode.implementation === i.implementation) { n(r, a.sibling), (a = o(a, i.children || [])).return = r, r = a; break e } n(r, a); break } t(r, a), a = a.sibling } (a = Au(i, r.mode, s)).return = r, r = a } return l(r); case I: return e(r, a, (c = i._init)(i._payload), s) }if (te(i)) return v(r, a, i, s); if (M(i)) return g(r, a, i, s); ma(r, i) } return "string" === typeof i && "" !== i || "number" === typeof i ? (i = "" + i, null !== a && 6 === a.tag ? (n(r, a.sibling), (a = o(a, i)).return = r, r = a) : (n(r, a), (a = Lu(i, r.mode, s)).return = r, r = a), l(r)) : n(r, a) } } var Ca = wa(!0), Oa = wa(!1), Sa = Eo(null), xa = null, Ea = null, ka = null; function _a() { ka = Ea = xa = null } function Pa(e) { var t = Sa.current; ko(Sa), e._currentValue = t } function Ta(e, t, n) { for (; null !== e;) { var r = e.alternate; if ((e.childLanes & t) !== t ? (e.childLanes |= t, null !== r && (r.childLanes |= t)) : null !== r && (r.childLanes & t) !== t && (r.childLanes |= t), e === n) break; e = e.return } } function ja(e, t) { xa = e, ka = Ea = null, null !== (e = e.dependencies) && null !== e.firstContext && (0 !== (e.lanes & t) && (bl = !0), e.firstContext = null) } function Ra(e) { var t = e._currentValue; if (ka !== e) if (e = { context: e, memoizedValue: t, next: null }, null === Ea) { if (null === xa) throw Error(a(308)); Ea = e, xa.dependencies = { lanes: 0, firstContext: e } } else Ea = Ea.next = e; return t } var Ia = null; function Na(e) { null === Ia ? Ia = [e] : Ia.push(e) } function Da(e, t, n, r) { var o = t.interleaved; return null === o ? (n.next = n, Na(t)) : (n.next = o.next, o.next = n), t.interleaved = n, Ma(e, r) } function Ma(e, t) { e.lanes |= t; var n = e.alternate; for (null !== n && (n.lanes |= t), n = e, e = e.return; null !== e;)e.childLanes |= t, null !== (n = e.alternate) && (n.childLanes |= t), n = e, e = e.return; return 3 === n.tag ? n.stateNode : null } var La = !1; function Aa(e) { e.updateQueue = { baseState: e.memoizedState, firstBaseUpdate: null, lastBaseUpdate: null, shared: { pending: null, interleaved: null, lanes: 0 }, effects: null } } function Fa(e, t) { e = e.updateQueue, t.updateQueue === e && (t.updateQueue = { baseState: e.baseState, firstBaseUpdate: e.firstBaseUpdate, lastBaseUpdate: e.lastBaseUpdate, shared: e.shared, effects: e.effects }) } function za(e, t) { return { eventTime: e, lane: t, tag: 0, payload: null, callback: null, next: null } } function Ha(e, t, n) { var r = e.updateQueue; if (null === r) return null; if (r = r.shared, 0 !== (2 & _s)) { var o = r.pending; return null === o ? t.next = t : (t.next = o.next, o.next = t), r.pending = t, Ma(e, n) } return null === (o = r.interleaved) ? (t.next = t, Na(r)) : (t.next = o.next, o.next = t), r.interleaved = t, Ma(e, n) } function Ba(e, t, n) { if (null !== (t = t.updateQueue) && (t = t.shared, 0 !== (4194240 & n))) { var r = t.lanes; n |= r &= e.pendingLanes, t.lanes = n, mt(e, n) } } function Ka(e, t) { var n = e.updateQueue, r = e.alternate; if (null !== r && n === (r = r.updateQueue)) { var o = null, a = null; if (null !== (n = n.firstBaseUpdate)) { do { var i = { eventTime: n.eventTime, lane: n.lane, tag: n.tag, payload: n.payload, callback: n.callback, next: null }; null === a ? o = a = i : a = a.next = i, n = n.next } while (null !== n); null === a ? o = a = t : a = a.next = t } else o = a = t; return n = { baseState: r.baseState, firstBaseUpdate: o, lastBaseUpdate: a, shared: r.shared, effects: r.effects }, void (e.updateQueue = n) } null === (e = n.lastBaseUpdate) ? n.firstBaseUpdate = t : e.next = t, n.lastBaseUpdate = t } function Ua(e, t, n, r) { var o = e.updateQueue; La = !1; var a = o.firstBaseUpdate, i = o.lastBaseUpdate, l = o.shared.pending; if (null !== l) { o.shared.pending = null; var s = l, u = s.next; s.next = null, null === i ? a = u : i.next = u, i = s; var c = e.alternate; null !== c && ((l = (c = c.updateQueue).lastBaseUpdate) !== i && (null === l ? c.firstBaseUpdate = u : l.next = u, c.lastBaseUpdate = s)) } if (null !== a) { var d = o.baseState; for (i = 0, c = u = s = null, l = a; ;) { var f = l.lane, p = l.eventTime; if ((r & f) === f) { null !== c && (c = c.next = { eventTime: p, lane: 0, tag: l.tag, payload: l.payload, callback: l.callback, next: null }); e: { var h = e, v = l; switch (f = t, p = n, v.tag) { case 1: if ("function" === typeof (h = v.payload)) { d = h.call(p, d, f); break e } d = h; break e; case 3: h.flags = -65537 & h.flags | 128; case 0: if (null === (f = "function" === typeof (h = v.payload) ? h.call(p, d, f) : h) || void 0 === f) break e; d = A({}, d, f); break e; case 2: La = !0 } } null !== l.callback && 0 !== l.lane && (e.flags |= 64, null === (f = o.effects) ? o.effects = [l] : f.push(l)) } else p = { eventTime: p, lane: f, tag: l.tag, payload: l.payload, callback: l.callback, next: null }, null === c ? (u = c = p, s = d) : c = c.next = p, i |= f; if (null === (l = l.next)) { if (null === (l = o.shared.pending)) break; l = (f = l).next, f.next = null, o.lastBaseUpdate = f, o.shared.pending = null } } if (null === c && (s = d), o.baseState = s, o.firstBaseUpdate = u, o.lastBaseUpdate = c, null !== (t = o.shared.interleaved)) { o = t; do { i |= o.lane, o = o.next } while (o !== t) } else null === a && (o.shared.lanes = 0); Ms |= i, e.lanes = i, e.memoizedState = d } } function Va(e, t, n) { if (e = t.effects, t.effects = null, null !== e) for (t = 0; t < e.length; t++) { var r = e[t], o = r.callback; if (null !== o) { if (r.callback = null, r = n, "function" !== typeof o) throw Error(a(191, o)); o.call(r) } } } var Wa = {}, Ga = Eo(Wa), $a = Eo(Wa), Ya = Eo(Wa); function Xa(e) { if (e === Wa) throw Error(a(174)); return e } function Za(e, t) { switch (_o(Ya, t), _o($a, e), _o(Ga, Wa), e = t.nodeType) { case 9: case 11: t = (t = t.documentElement) ? t.namespaceURI : se(null, ""); break; default: t = se(t = (e = 8 === e ? t.parentNode : t).namespaceURI || null, e = e.tagName) }ko(Ga), _o(Ga, t) } function qa() { ko(Ga), ko($a), ko(Ya) } function Qa(e) { Xa(Ya.current); var t = Xa(Ga.current), n = se(t, e.type); t !== n && (_o($a, e), _o(Ga, n)) } function Ja(e) { $a.current === e && (ko(Ga), ko($a)) } var ei = Eo(0); function ti(e) { for (var t = e; null !== t;) { if (13 === t.tag) { var n = t.memoizedState; if (null !== n && (null === (n = n.dehydrated) || "$?" === n.data || "$!" === n.data)) return t } else if (19 === t.tag && void 0 !== t.memoizedProps.revealOrder) { if (0 !== (128 & t.flags)) return t } else if (null !== t.child) { t.child.return = t, t = t.child; continue } if (t === e) break; for (; null === t.sibling;) { if (null === t.return || t.return === e) return null; t = t.return } t.sibling.return = t.return, t = t.sibling } return null } var ni = []; function ri() { for (var e = 0; e < ni.length; e++)ni[e]._workInProgressVersionPrimary = null; ni.length = 0 } var oi = w.ReactCurrentDispatcher, ai = w.ReactCurrentBatchConfig, ii = 0, li = null, si = null, ui = null, ci = !1, di = !1, fi = 0, pi = 0; function hi() { throw Error(a(321)) } function vi(e, t) { if (null === t) return !1; for (var n = 0; n < t.length && n < e.length; n++)if (!lr(e[n], t[n])) return !1; return !0 } function gi(e, t, n, r, o, i) { if (ii = i, li = t, t.memoizedState = null, t.updateQueue = null, t.lanes = 0, oi.current = null === e || null === e.memoizedState ? Ji : el, e = n(r, o), di) { i = 0; do { if (di = !1, fi = 0, 25 <= i) throw Error(a(301)); i += 1, ui = si = null, t.updateQueue = null, oi.current = tl, e = n(r, o) } while (di) } if (oi.current = Qi, t = null !== si && null !== si.next, ii = 0, ui = si = li = null, ci = !1, t) throw Error(a(300)); return e } function yi() { var e = 0 !== fi; return fi = 0, e } function mi() { var e = { memoizedState: null, baseState: null, baseQueue: null, queue: null, next: null }; return null === ui ? li.memoizedState = ui = e : ui = ui.next = e, ui } function bi() { if (null === si) { var e = li.alternate; e = null !== e ? e.memoizedState : null } else e = si.next; var t = null === ui ? li.memoizedState : ui.next; if (null !== t) ui = t, si = e; else { if (null === e) throw Error(a(310)); e = { memoizedState: (si = e).memoizedState, baseState: si.baseState, baseQueue: si.baseQueue, queue: si.queue, next: null }, null === ui ? li.memoizedState = ui = e : ui = ui.next = e } return ui } function wi(e, t) { return "function" === typeof t ? t(e) : t } function Ci(e) { var t = bi(), n = t.queue; if (null === n) throw Error(a(311)); n.lastRenderedReducer = e; var r = si, o = r.baseQueue, i = n.pending; if (null !== i) { if (null !== o) { var l = o.next; o.next = i.next, i.next = l } r.baseQueue = o = i, n.pending = null } if (null !== o) { i = o.next, r = r.baseState; var s = l = null, u = null, c = i; do { var d = c.lane; if ((ii & d) === d) null !== u && (u = u.next = { lane: 0, action: c.action, hasEagerState: c.hasEagerState, eagerState: c.eagerState, next: null }), r = c.hasEagerState ? c.eagerState : e(r, c.action); else { var f = { lane: d, action: c.action, hasEagerState: c.hasEagerState, eagerState: c.eagerState, next: null }; null === u ? (s = u = f, l = r) : u = u.next = f, li.lanes |= d, Ms |= d } c = c.next } while (null !== c && c !== i); null === u ? l = r : u.next = s, lr(r, t.memoizedState) || (bl = !0), t.memoizedState = r, t.baseState = l, t.baseQueue = u, n.lastRenderedState = r } if (null !== (e = n.interleaved)) { o = e; do { i = o.lane, li.lanes |= i, Ms |= i, o = o.next } while (o !== e) } else null === o && (n.lanes = 0); return [t.memoizedState, n.dispatch] } function Oi(e) { var t = bi(), n = t.queue; if (null === n) throw Error(a(311)); n.lastRenderedReducer = e; var r = n.dispatch, o = n.pending, i = t.memoizedState; if (null !== o) { n.pending = null; var l = o = o.next; do { i = e(i, l.action), l = l.next } while (l !== o); lr(i, t.memoizedState) || (bl = !0), t.memoizedState = i, null === t.baseQueue && (t.baseState = i), n.lastRenderedState = i } return [i, r] } function Si() { } function xi(e, t) { var n = li, r = bi(), o = t(), i = !lr(r.memoizedState, o); if (i && (r.memoizedState = o, bl = !0), r = r.queue, Li(_i.bind(null, n, r, e), [e]), r.getSnapshot !== t || i || null !== ui && 1 & ui.memoizedState.tag) { if (n.flags |= 2048, Ri(9, ki.bind(null, n, r, o, t), void 0, null), null === Ps) throw Error(a(349)); 0 !== (30 & ii) || Ei(n, t, o) } return o } function Ei(e, t, n) { e.flags |= 16384, e = { getSnapshot: t, value: n }, null === (t = li.updateQueue) ? (t = { lastEffect: null, stores: null }, li.updateQueue = t, t.stores = [e]) : null === (n = t.stores) ? t.stores = [e] : n.push(e) } function ki(e, t, n, r) { t.value = n, t.getSnapshot = r, Pi(t) && Ti(e) } function _i(e, t, n) { return n((function () { Pi(t) && Ti(e) })) } function Pi(e) { var t = e.getSnapshot; e = e.value; try { var n = t(); return !lr(e, n) } catch (r) { return !0 } } function Ti(e) { var t = Ma(e, 1); null !== t && tu(t, e, 1, -1) } function ji(e) { var t = mi(); return "function" === typeof e && (e = e()), t.memoizedState = t.baseState = e, e = { pending: null, interleaved: null, lanes: 0, dispatch: null, lastRenderedReducer: wi, lastRenderedState: e }, t.queue = e, e = e.dispatch = Yi.bind(null, li, e), [t.memoizedState, e] } function Ri(e, t, n, r) { return e = { tag: e, create: t, destroy: n, deps: r, next: null }, null === (t = li.updateQueue) ? (t = { lastEffect: null, stores: null }, li.updateQueue = t, t.lastEffect = e.next = e) : null === (n = t.lastEffect) ? t.lastEffect = e.next = e : (r = n.next, n.next = e, e.next = r, t.lastEffect = e), e } function Ii() { return bi().memoizedState } function Ni(e, t, n, r) { var o = mi(); li.flags |= e, o.memoizedState = Ri(1 | t, n, void 0, void 0 === r ? null : r) } function Di(e, t, n, r) { var o = bi(); r = void 0 === r ? null : r; var a = void 0; if (null !== si) { var i = si.memoizedState; if (a = i.destroy, null !== r && vi(r, i.deps)) return void (o.memoizedState = Ri(t, n, a, r)) } li.flags |= e, o.memoizedState = Ri(1 | t, n, a, r) } function Mi(e, t) { return Ni(8390656, 8, e, t) } function Li(e, t) { return Di(2048, 8, e, t) } function Ai(e, t) { return Di(4, 2, e, t) } function Fi(e, t) { return Di(4, 4, e, t) } function zi(e, t) { return "function" === typeof t ? (e = e(), t(e), function () { t(null) }) : null !== t && void 0 !== t ? (e = e(), t.current = e, function () { t.current = null }) : void 0 } function Hi(e, t, n) { return n = null !== n && void 0 !== n ? n.concat([e]) : null, Di(4, 4, zi.bind(null, t, e), n) } function Bi() { } function Ki(e, t) { var n = bi(); t = void 0 === t ? null : t; var r = n.memoizedState; return null !== r && null !== t && vi(t, r[1]) ? r[0] : (n.memoizedState = [e, t], e) } function Ui(e, t) { var n = bi(); t = void 0 === t ? null : t; var r = n.memoizedState; return null !== r && null !== t && vi(t, r[1]) ? r[0] : (e = e(), n.memoizedState = [e, t], e) } function Vi(e, t, n) { return 0 === (21 & ii) ? (e.baseState && (e.baseState = !1, bl = !0), e.memoizedState = n) : (lr(n, t) || (n = vt(), li.lanes |= n, Ms |= n, e.baseState = !0), t) } function Wi(e, t) { var n = bt; bt = 0 !== n && 4 > n ? n : 4, e(!0); var r = ai.transition; ai.transition = {}; try { e(!1), t() } finally { bt = n, ai.transition = r } } function Gi() { return bi().memoizedState } function $i(e, t, n) { var r = eu(e); if (n = { lane: r, action: n, hasEagerState: !1, eagerState: null, next: null }, Xi(e)) Zi(t, n); else if (null !== (n = Da(e, t, n, r))) { tu(n, e, r, Js()), qi(n, t, r) } } function Yi(e, t, n) { var r = eu(e), o = { lane: r, action: n, hasEagerState: !1, eagerState: null, next: null }; if (Xi(e)) Zi(t, o); else { var a = e.alternate; if (0 === e.lanes && (null === a || 0 === a.lanes) && null !== (a = t.lastRenderedReducer)) try { var i = t.lastRenderedState, l = a(i, n); if (o.hasEagerState = !0, o.eagerState = l, lr(l, i)) { var s = t.interleaved; return null === s ? (o.next = o, Na(t)) : (o.next = s.next, s.next = o), void (t.interleaved = o) } } catch (u) { } null !== (n = Da(e, t, o, r)) && (tu(n, e, r, o = Js()), qi(n, t, r)) } } function Xi(e) { var t = e.alternate; return e === li || null !== t && t === li } function Zi(e, t) { di = ci = !0; var n = e.pending; null === n ? t.next = t : (t.next = n.next, n.next = t), e.pending = t } function qi(e, t, n) { if (0 !== (4194240 & n)) { var r = t.lanes; n |= r &= e.pendingLanes, t.lanes = n, mt(e, n) } } var Qi = { readContext: Ra, useCallback: hi, useContext: hi, useEffect: hi, useImperativeHandle: hi, useInsertionEffect: hi, useLayoutEffect: hi, useMemo: hi, useReducer: hi, useRef: hi, useState: hi, useDebugValue: hi, useDeferredValue: hi, useTransition: hi, useMutableSource: hi, useSyncExternalStore: hi, useId: hi, unstable_isNewReconciler: !1 }, Ji = { readContext: Ra, useCallback: function (e, t) { return mi().memoizedState = [e, void 0 === t ? null : t], e }, useContext: Ra, useEffect: Mi, useImperativeHandle: function (e, t, n) { return n = null !== n && void 0 !== n ? n.concat([e]) : null, Ni(4194308, 4, zi.bind(null, t, e), n) }, useLayoutEffect: function (e, t) { return Ni(4194308, 4, e, t) }, useInsertionEffect: function (e, t) { return Ni(4, 2, e, t) }, useMemo: function (e, t) { var n = mi(); return t = void 0 === t ? null : t, e = e(), n.memoizedState = [e, t], e }, useReducer: function (e, t, n) { var r = mi(); return t = void 0 !== n ? n(t) : t, r.memoizedState = r.baseState = t, e = { pending: null, interleaved: null, lanes: 0, dispatch: null, lastRenderedReducer: e, lastRenderedState: t }, r.queue = e, e = e.dispatch = $i.bind(null, li, e), [r.memoizedState, e] }, useRef: function (e) { return e = { current: e }, mi().memoizedState = e }, useState: ji, useDebugValue: Bi, useDeferredValue: function (e) { return mi().memoizedState = e }, useTransition: function () { var e = ji(!1), t = e[0]; return e = Wi.bind(null, e[1]), mi().memoizedState = e, [t, e] }, useMutableSource: function () { }, useSyncExternalStore: function (e, t, n) { var r = li, o = mi(); if (aa) { if (void 0 === n) throw Error(a(407)); n = n() } else { if (n = t(), null === Ps) throw Error(a(349)); 0 !== (30 & ii) || Ei(r, t, n) } o.memoizedState = n; var i = { value: n, getSnapshot: t }; return o.queue = i, Mi(_i.bind(null, r, i, e), [e]), r.flags |= 2048, Ri(9, ki.bind(null, r, i, n, t), void 0, null), n }, useId: function () { var e = mi(), t = Ps.identifierPrefix; if (aa) { var n = Qo; t = ":" + t + "R" + (n = (qo & ~(1 << 32 - it(qo) - 1)).toString(32) + n), 0 < (n = fi++) && (t += "H" + n.toString(32)), t += ":" } else t = ":" + t + "r" + (n = pi++).toString(32) + ":"; return e.memoizedState = t }, unstable_isNewReconciler: !1 }, el = { readContext: Ra, useCallback: Ki, useContext: Ra, useEffect: Li, useImperativeHandle: Hi, useInsertionEffect: Ai, useLayoutEffect: Fi, useMemo: Ui, useReducer: Ci, useRef: Ii, useState: function () { return Ci(wi) }, useDebugValue: Bi, useDeferredValue: function (e) { return Vi(bi(), si.memoizedState, e) }, useTransition: function () { return [Ci(wi)[0], bi().memoizedState] }, useMutableSource: Si, useSyncExternalStore: xi, useId: Gi, unstable_isNewReconciler: !1 }, tl = { readContext: Ra, useCallback: Ki, useContext: Ra, useEffect: Li, useImperativeHandle: Hi, useInsertionEffect: Ai, useLayoutEffect: Fi, useMemo: Ui, useReducer: Oi, useRef: Ii, useState: function () { return Oi(wi) }, useDebugValue: Bi, useDeferredValue: function (e) { var t = bi(); return null === si ? t.memoizedState = e : Vi(t, si.memoizedState, e) }, useTransition: function () { return [Oi(wi)[0], bi().memoizedState] }, useMutableSource: Si, useSyncExternalStore: xi, useId: Gi, unstable_isNewReconciler: !1 }; function nl(e, t) { if (e && e.defaultProps) { for (var n in t = A({}, t), e = e.defaultProps) void 0 === t[n] && (t[n] = e[n]); return t } return t } function rl(e, t, n, r) { n = null === (n = n(r, t = e.memoizedState)) || void 0 === n ? t : A({}, t, n), e.memoizedState = n, 0 === e.lanes && (e.updateQueue.baseState = n) } var ol = { isMounted: function (e) { return !!(e = e._reactInternals) && Ke(e) === e }, enqueueSetState: function (e, t, n) { e = e._reactInternals; var r = Js(), o = eu(e), a = za(r, o); a.payload = t, void 0 !== n && null !== n && (a.callback = n), null !== (t = Ha(e, a, o)) && (tu(t, e, o, r), Ba(t, e, o)) }, enqueueReplaceState: function (e, t, n) { e = e._reactInternals; var r = Js(), o = eu(e), a = za(r, o); a.tag = 1, a.payload = t, void 0 !== n && null !== n && (a.callback = n), null !== (t = Ha(e, a, o)) && (tu(t, e, o, r), Ba(t, e, o)) }, enqueueForceUpdate: function (e, t) { e = e._reactInternals; var n = Js(), r = eu(e), o = za(n, r); o.tag = 2, void 0 !== t && null !== t && (o.callback = t), null !== (t = Ha(e, o, r)) && (tu(t, e, r, n), Ba(t, e, r)) } }; function al(e, t, n, r, o, a, i) { return "function" === typeof (e = e.stateNode).shouldComponentUpdate ? e.shouldComponentUpdate(r, a, i) : !t.prototype || !t.prototype.isPureReactComponent || (!sr(n, r) || !sr(o, a)) } function il(e, t, n) { var r = !1, o = Po, a = t.contextType; return "object" === typeof a && null !== a ? a = Ra(a) : (o = No(t) ? Ro : To.current, a = (r = null !== (r = t.contextTypes) && void 0 !== r) ? Io(e, o) : Po), t = new t(n, a), e.memoizedState = null !== t.state && void 0 !== t.state ? t.state : null, t.updater = ol, e.stateNode = t, t._reactInternals = e, r && ((e = e.stateNode).__reactInternalMemoizedUnmaskedChildContext = o, e.__reactInternalMemoizedMaskedChildContext = a), t } function ll(e, t, n, r) { e = t.state, "function" === typeof t.componentWillReceiveProps && t.componentWillReceiveProps(n, r), "function" === typeof t.UNSAFE_componentWillReceiveProps && t.UNSAFE_componentWillReceiveProps(n, r), t.state !== e && ol.enqueueReplaceState(t, t.state, null) } function sl(e, t, n, r) { var o = e.stateNode; o.props = n, o.state = e.memoizedState, o.refs = {}, Aa(e); var a = t.contextType; "object" === typeof a && null !== a ? o.context = Ra(a) : (a = No(t) ? Ro : To.current, o.context = Io(e, a)), o.state = e.memoizedState, "function" === typeof (a = t.getDerivedStateFromProps) && (rl(e, t, a, n), o.state = e.memoizedState), "function" === typeof t.getDerivedStateFromProps || "function" === typeof o.getSnapshotBeforeUpdate || "function" !== typeof o.UNSAFE_componentWillMount && "function" !== typeof o.componentWillMount || (t = o.state, "function" === typeof o.componentWillMount && o.componentWillMount(), "function" === typeof o.UNSAFE_componentWillMount && o.UNSAFE_componentWillMount(), t !== o.state && ol.enqueueReplaceState(o, o.state, null), Ua(e, n, o, r), o.state = e.memoizedState), "function" === typeof o.componentDidMount && (e.flags |= 4194308) } function ul(e, t) { try { var n = "", r = t; do { n += B(r), r = r.return } while (r); var o = n } catch (a) { o = "\nError generating stack: " + a.message + "\n" + a.stack } return { value: e, source: t, stack: o, digest: null } } function cl(e, t, n) { return { value: e, source: null, stack: null != n ? n : null, digest: null != t ? t : null } } function dl(e, t) { try { console.error(t.value) } catch (n) { setTimeout((function () { throw n })) } } var fl = "function" === typeof WeakMap ? WeakMap : Map; function pl(e, t, n) { (n = za(-1, n)).tag = 3, n.payload = { element: null }; var r = t.value; return n.callback = function () { Us || (Us = !0, Vs = r), dl(0, t) }, n } function hl(e, t, n) { (n = za(-1, n)).tag = 3; var r = e.type.getDerivedStateFromError; if ("function" === typeof r) { var o = t.value; n.payload = function () { return r(o) }, n.callback = function () { dl(0, t) } } var a = e.stateNode; return null !== a && "function" === typeof a.componentDidCatch && (n.callback = function () { dl(0, t), "function" !== typeof r && (null === Ws ? Ws = new Set([this]) : Ws.add(this)); var e = t.stack; this.componentDidCatch(t.value, { componentStack: null !== e ? e : "" }) }), n } function vl(e, t, n) { var r = e.pingCache; if (null === r) { r = e.pingCache = new fl; var o = new Set; r.set(t, o) } else void 0 === (o = r.get(t)) && (o = new Set, r.set(t, o)); o.has(n) || (o.add(n), e = xu.bind(null, e, t, n), t.then(e, e)) } function gl(e) { do { var t; if ((t = 13 === e.tag) && (t = null === (t = e.memoizedState) || null !== t.dehydrated), t) return e; e = e.return } while (null !== e); return null } function yl(e, t, n, r, o) { return 0 === (1 & e.mode) ? (e === t ? e.flags |= 65536 : (e.flags |= 128, n.flags |= 131072, n.flags &= -52805, 1 === n.tag && (null === n.alternate ? n.tag = 17 : ((t = za(-1, 1)).tag = 2, Ha(n, t, 1))), n.lanes |= 1), e) : (e.flags |= 65536, e.lanes = o, e) } var ml = w.ReactCurrentOwner, bl = !1; function wl(e, t, n, r) { t.child = null === e ? Oa(t, null, n, r) : Ca(t, e.child, n, r) } function Cl(e, t, n, r, o) { n = n.render; var a = t.ref; return ja(t, o), r = gi(e, t, n, r, a, o), n = yi(), null === e || bl ? (aa && n && ta(t), t.flags |= 1, wl(e, t, r, o), t.child) : (t.updateQueue = e.updateQueue, t.flags &= -2053, e.lanes &= ~o, Ul(e, t, o)) } function Ol(e, t, n, r, o) { if (null === e) { var a = n.type; return "function" !== typeof a || Ru(a) || void 0 !== a.defaultProps || null !== n.compare || void 0 !== n.defaultProps ? ((e = Nu(n.type, null, r, t, t.mode, o)).ref = t.ref, e.return = t, t.child = e) : (t.tag = 15, t.type = a, Sl(e, t, a, r, o)) } if (a = e.child, 0 === (e.lanes & o)) { var i = a.memoizedProps; if ((n = null !== (n = n.compare) ? n : sr)(i, r) && e.ref === t.ref) return Ul(e, t, o) } return t.flags |= 1, (e = Iu(a, r)).ref = t.ref, e.return = t, t.child = e } function Sl(e, t, n, r, o) { if (null !== e) { var a = e.memoizedProps; if (sr(a, r) && e.ref === t.ref) { if (bl = !1, t.pendingProps = r = a, 0 === (e.lanes & o)) return t.lanes = e.lanes, Ul(e, t, o); 0 !== (131072 & e.flags) && (bl = !0) } } return kl(e, t, n, r, o) } function xl(e, t, n) { var r = t.pendingProps, o = r.children, a = null !== e ? e.memoizedState : null; if ("hidden" === r.mode) if (0 === (1 & t.mode)) t.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }, _o(Is, Rs), Rs |= n; else { if (0 === (1073741824 & n)) return e = null !== a ? a.baseLanes | n : n, t.lanes = t.childLanes = 1073741824, t.memoizedState = { baseLanes: e, cachePool: null, transitions: null }, t.updateQueue = null, _o(Is, Rs), Rs |= e, null; t.memoizedState = { baseLanes: 0, cachePool: null, transitions: null }, r = null !== a ? a.baseLanes : n, _o(Is, Rs), Rs |= r } else null !== a ? (r = a.baseLanes | n, t.memoizedState = null) : r = n, _o(Is, Rs), Rs |= r; return wl(e, t, o, n), t.child } function El(e, t) { var n = t.ref; (null === e && null !== n || null !== e && e.ref !== n) && (t.flags |= 512, t.flags |= 2097152) } function kl(e, t, n, r, o) { var a = No(n) ? Ro : To.current; return a = Io(t, a), ja(t, o), n = gi(e, t, n, r, a, o), r = yi(), null === e || bl ? (aa && r && ta(t), t.flags |= 1, wl(e, t, n, o), t.child) : (t.updateQueue = e.updateQueue, t.flags &= -2053, e.lanes &= ~o, Ul(e, t, o)) } function _l(e, t, n, r, o) { if (No(n)) { var a = !0; Ao(t) } else a = !1; if (ja(t, o), null === t.stateNode) Kl(e, t), il(t, n, r), sl(t, n, r, o), r = !0; else if (null === e) { var i = t.stateNode, l = t.memoizedProps; i.props = l; var s = i.context, u = n.contextType; "object" === typeof u && null !== u ? u = Ra(u) : u = Io(t, u = No(n) ? Ro : To.current); var c = n.getDerivedStateFromProps, d = "function" === typeof c || "function" === typeof i.getSnapshotBeforeUpdate; d || "function" !== typeof i.UNSAFE_componentWillReceiveProps && "function" !== typeof i.componentWillReceiveProps || (l !== r || s !== u) && ll(t, i, r, u), La = !1; var f = t.memoizedState; i.state = f, Ua(t, r, i, o), s = t.memoizedState, l !== r || f !== s || jo.current || La ? ("function" === typeof c && (rl(t, n, c, r), s = t.memoizedState), (l = La || al(t, n, l, r, f, s, u)) ? (d || "function" !== typeof i.UNSAFE_componentWillMount && "function" !== typeof i.componentWillMount || ("function" === typeof i.componentWillMount && i.componentWillMount(), "function" === typeof i.UNSAFE_componentWillMount && i.UNSAFE_componentWillMount()), "function" === typeof i.componentDidMount && (t.flags |= 4194308)) : ("function" === typeof i.componentDidMount && (t.flags |= 4194308), t.memoizedProps = r, t.memoizedState = s), i.props = r, i.state = s, i.context = u, r = l) : ("function" === typeof i.componentDidMount && (t.flags |= 4194308), r = !1) } else { i = t.stateNode, Fa(e, t), l = t.memoizedProps, u = t.type === t.elementType ? l : nl(t.type, l), i.props = u, d = t.pendingProps, f = i.context, "object" === typeof (s = n.contextType) && null !== s ? s = Ra(s) : s = Io(t, s = No(n) ? Ro : To.current); var p = n.getDerivedStateFromProps; (c = "function" === typeof p || "function" === typeof i.getSnapshotBeforeUpdate) || "function" !== typeof i.UNSAFE_componentWillReceiveProps && "function" !== typeof i.componentWillReceiveProps || (l !== d || f !== s) && ll(t, i, r, s), La = !1, f = t.memoizedState, i.state = f, Ua(t, r, i, o); var h = t.memoizedState; l !== d || f !== h || jo.current || La ? ("function" === typeof p && (rl(t, n, p, r), h = t.memoizedState), (u = La || al(t, n, u, r, f, h, s) || !1) ? (c || "function" !== typeof i.UNSAFE_componentWillUpdate && "function" !== typeof i.componentWillUpdate || ("function" === typeof i.componentWillUpdate && i.componentWillUpdate(r, h, s), "function" === typeof i.UNSAFE_componentWillUpdate && i.UNSAFE_componentWillUpdate(r, h, s)), "function" === typeof i.componentDidUpdate && (t.flags |= 4), "function" === typeof i.getSnapshotBeforeUpdate && (t.flags |= 1024)) : ("function" !== typeof i.componentDidUpdate || l === e.memoizedProps && f === e.memoizedState || (t.flags |= 4), "function" !== typeof i.getSnapshotBeforeUpdate || l === e.memoizedProps && f === e.memoizedState || (t.flags |= 1024), t.memoizedProps = r, t.memoizedState = h), i.props = r, i.state = h, i.context = s, r = u) : ("function" !== typeof i.componentDidUpdate || l === e.memoizedProps && f === e.memoizedState || (t.flags |= 4), "function" !== typeof i.getSnapshotBeforeUpdate || l === e.memoizedProps && f === e.memoizedState || (t.flags |= 1024), r = !1) } return Pl(e, t, n, r, a, o) } function Pl(e, t, n, r, o, a) { El(e, t); var i = 0 !== (128 & t.flags); if (!r && !i) return o && Fo(t, n, !1), Ul(e, t, a); r = t.stateNode, ml.current = t; var l = i && "function" !== typeof n.getDerivedStateFromError ? null : r.render(); return t.flags |= 1, null !== e && i ? (t.child = Ca(t, e.child, null, a), t.child = Ca(t, null, l, a)) : wl(e, t, l, a), t.memoizedState = r.state, o && Fo(t, n, !0), t.child } function Tl(e) { var t = e.stateNode; t.pendingContext ? Mo(0, t.pendingContext, t.pendingContext !== t.context) : t.context && Mo(0, t.context, !1), Za(e, t.containerInfo) } function jl(e, t, n, r, o) { return ha(), va(o), t.flags |= 256, wl(e, t, n, r), t.child } var Rl, Il, Nl, Dl = { dehydrated: null, treeContext: null, retryLane: 0 }; function Ml(e) { return { baseLanes: e, cachePool: null, transitions: null } } function Ll(e, t, n) { var r, o = t.pendingProps, i = ei.current, l = !1, s = 0 !== (128 & t.flags); if ((r = s) || (r = (null === e || null !== e.memoizedState) && 0 !== (2 & i)), r ? (l = !0, t.flags &= -129) : null !== e && null === e.memoizedState || (i |= 1), _o(ei, 1 & i), null === e) return ca(t), null !== (e = t.memoizedState) && null !== (e = e.dehydrated) ? (0 === (1 & t.mode) ? t.lanes = 1 : "$!" === e.data ? t.lanes = 8 : t.lanes = 1073741824, null) : (s = o.children, e = o.fallback, l ? (o = t.mode, l = t.child, s = { mode: "hidden", children: s }, 0 === (1 & o) && null !== l ? (l.childLanes = 0, l.pendingProps = s) : l = Mu(s, o, 0, null), e = Du(e, o, n, null), l.return = t, e.return = t, l.sibling = e, t.child = l, t.child.memoizedState = Ml(n), t.memoizedState = Dl, e) : Al(t, s)); if (null !== (i = e.memoizedState) && null !== (r = i.dehydrated)) return function (e, t, n, r, o, i, l) { if (n) return 256 & t.flags ? (t.flags &= -257, Fl(e, t, l, r = cl(Error(a(422))))) : null !== t.memoizedState ? (t.child = e.child, t.flags |= 128, null) : (i = r.fallback, o = t.mode, r = Mu({ mode: "visible", children: r.children }, o, 0, null), (i = Du(i, o, l, null)).flags |= 2, r.return = t, i.return = t, r.sibling = i, t.child = r, 0 !== (1 & t.mode) && Ca(t, e.child, null, l), t.child.memoizedState = Ml(l), t.memoizedState = Dl, i); if (0 === (1 & t.mode)) return Fl(e, t, l, null); if ("$!" === o.data) { if (r = o.nextSibling && o.nextSibling.dataset) var s = r.dgst; return r = s, Fl(e, t, l, r = cl(i = Error(a(419)), r, void 0)) } if (s = 0 !== (l & e.childLanes), bl || s) { if (null !== (r = Ps)) { switch (l & -l) { case 4: o = 2; break; case 16: o = 8; break; case 64: case 128: case 256: case 512: case 1024: case 2048: case 4096: case 8192: case 16384: case 32768: case 65536: case 131072: case 262144: case 524288: case 1048576: case 2097152: case 4194304: case 8388608: case 16777216: case 33554432: case 67108864: o = 32; break; case 536870912: o = 268435456; break; default: o = 0 }0 !== (o = 0 !== (o & (r.suspendedLanes | l)) ? 0 : o) && o !== i.retryLane && (i.retryLane = o, Ma(e, o), tu(r, e, o, -1)) } return hu(), Fl(e, t, l, r = cl(Error(a(421)))) } return "$?" === o.data ? (t.flags |= 128, t.child = e.child, t = ku.bind(null, e), o._reactRetry = t, null) : (e = i.treeContext, oa = uo(o.nextSibling), ra = t, aa = !0, ia = null, null !== e && (Yo[Xo++] = qo, Yo[Xo++] = Qo, Yo[Xo++] = Zo, qo = e.id, Qo = e.overflow, Zo = t), t = Al(t, r.children), t.flags |= 4096, t) }(e, t, s, o, r, i, n); if (l) { l = o.fallback, s = t.mode, r = (i = e.child).sibling; var u = { mode: "hidden", children: o.children }; return 0 === (1 & s) && t.child !== i ? ((o = t.child).childLanes = 0, o.pendingProps = u, t.deletions = null) : (o = Iu(i, u)).subtreeFlags = 14680064 & i.subtreeFlags, null !== r ? l = Iu(r, l) : (l = Du(l, s, n, null)).flags |= 2, l.return = t, o.return = t, o.sibling = l, t.child = o, o = l, l = t.child, s = null === (s = e.child.memoizedState) ? Ml(n) : { baseLanes: s.baseLanes | n, cachePool: null, transitions: s.transitions }, l.memoizedState = s, l.childLanes = e.childLanes & ~n, t.memoizedState = Dl, o } return e = (l = e.child).sibling, o = Iu(l, { mode: "visible", children: o.children }), 0 === (1 & t.mode) && (o.lanes = n), o.return = t, o.sibling = null, null !== e && (null === (n = t.deletions) ? (t.deletions = [e], t.flags |= 16) : n.push(e)), t.child = o, t.memoizedState = null, o } function Al(e, t) { return (t = Mu({ mode: "visible", children: t }, e.mode, 0, null)).return = e, e.child = t } function Fl(e, t, n, r) { return null !== r && va(r), Ca(t, e.child, null, n), (e = Al(t, t.pendingProps.children)).flags |= 2, t.memoizedState = null, e } function zl(e, t, n) { e.lanes |= t; var r = e.alternate; null !== r && (r.lanes |= t), Ta(e.return, t, n) } function Hl(e, t, n, r, o) { var a = e.memoizedState; null === a ? e.memoizedState = { isBackwards: t, rendering: null, renderingStartTime: 0, last: r, tail: n, tailMode: o } : (a.isBackwards = t, a.rendering = null, a.renderingStartTime = 0, a.last = r, a.tail = n, a.tailMode = o) } function Bl(e, t, n) { var r = t.pendingProps, o = r.revealOrder, a = r.tail; if (wl(e, t, r.children, n), 0 !== (2 & (r = ei.current))) r = 1 & r | 2, t.flags |= 128; else { if (null !== e && 0 !== (128 & e.flags)) e: for (e = t.child; null !== e;) { if (13 === e.tag) null !== e.memoizedState && zl(e, n, t); else if (19 === e.tag) zl(e, n, t); else if (null !== e.child) { e.child.return = e, e = e.child; continue } if (e === t) break e; for (; null === e.sibling;) { if (null === e.return || e.return === t) break e; e = e.return } e.sibling.return = e.return, e = e.sibling } r &= 1 } if (_o(ei, r), 0 === (1 & t.mode)) t.memoizedState = null; else switch (o) { case "forwards": for (n = t.child, o = null; null !== n;)null !== (e = n.alternate) && null === ti(e) && (o = n), n = n.sibling; null === (n = o) ? (o = t.child, t.child = null) : (o = n.sibling, n.sibling = null), Hl(t, !1, o, n, a); break; case "backwards": for (n = null, o = t.child, t.child = null; null !== o;) { if (null !== (e = o.alternate) && null === ti(e)) { t.child = o; break } e = o.sibling, o.sibling = n, n = o, o = e } Hl(t, !0, n, null, a); break; case "together": Hl(t, !1, null, null, void 0); break; default: t.memoizedState = null }return t.child } function Kl(e, t) { 0 === (1 & t.mode) && null !== e && (e.alternate = null, t.alternate = null, t.flags |= 2) } function Ul(e, t, n) { if (null !== e && (t.dependencies = e.dependencies), Ms |= t.lanes, 0 === (n & t.childLanes)) return null; if (null !== e && t.child !== e.child) throw Error(a(153)); if (null !== t.child) { for (n = Iu(e = t.child, e.pendingProps), t.child = n, n.return = t; null !== e.sibling;)e = e.sibling, (n = n.sibling = Iu(e, e.pendingProps)).return = t; n.sibling = null } return t.child } function Vl(e, t) { if (!aa) switch (e.tailMode) { case "hidden": t = e.tail; for (var n = null; null !== t;)null !== t.alternate && (n = t), t = t.sibling; null === n ? e.tail = null : n.sibling = null; break; case "collapsed": n = e.tail; for (var r = null; null !== n;)null !== n.alternate && (r = n), n = n.sibling; null === r ? t || null === e.tail ? e.tail = null : e.tail.sibling = null : r.sibling = null } } function Wl(e) { var t = null !== e.alternate && e.alternate.child === e.child, n = 0, r = 0; if (t) for (var o = e.child; null !== o;)n |= o.lanes | o.childLanes, r |= 14680064 & o.subtreeFlags, r |= 14680064 & o.flags, o.return = e, o = o.sibling; else for (o = e.child; null !== o;)n |= o.lanes | o.childLanes, r |= o.subtreeFlags, r |= o.flags, o.return = e, o = o.sibling; return e.subtreeFlags |= r, e.childLanes = n, t } function Gl(e, t, n) { var r = t.pendingProps; switch (na(t), t.tag) { case 2: case 16: case 15: case 0: case 11: case 7: case 8: case 12: case 9: case 14: return Wl(t), null; case 1: case 17: return No(t.type) && Do(), Wl(t), null; case 3: return r = t.stateNode, qa(), ko(jo), ko(To), ri(), r.pendingContext && (r.context = r.pendingContext, r.pendingContext = null), null !== e && null !== e.child || (fa(t) ? t.flags |= 4 : null === e || e.memoizedState.isDehydrated && 0 === (256 & t.flags) || (t.flags |= 1024, null !== ia && (au(ia), ia = null))), Wl(t), null; case 5: Ja(t); var o = Xa(Ya.current); if (n = t.type, null !== e && null != t.stateNode) Il(e, t, n, r), e.ref !== t.ref && (t.flags |= 512, t.flags |= 2097152); else { if (!r) { if (null === t.stateNode) throw Error(a(166)); return Wl(t), null } if (e = Xa(Ga.current), fa(t)) { r = t.stateNode, n = t.type; var i = t.memoizedProps; switch (r[po] = t, r[ho] = i, e = 0 !== (1 & t.mode), n) { case "dialog": zr("cancel", r), zr("close", r); break; case "iframe": case "object": case "embed": zr("load", r); break; case "video": case "audio": for (o = 0; o < Mr.length; o++)zr(Mr[o], r); break; case "source": zr("error", r); break; case "img": case "image": case "link": zr("error", r), zr("load", r); break; case "details": zr("toggle", r); break; case "input": Z(r, i), zr("invalid", r); break; case "select": r._wrapperState = { wasMultiple: !!i.multiple }, zr("invalid", r); break; case "textarea": oe(r, i), zr("invalid", r) }for (var s in me(n, i), o = null, i) if (i.hasOwnProperty(s)) { var u = i[s]; "children" === s ? "string" === typeof u ? r.textContent !== u && (!0 !== i.suppressHydrationWarning && Qr(r.textContent, u, e), o = ["children", u]) : "number" === typeof u && r.textContent !== "" + u && (!0 !== i.suppressHydrationWarning && Qr(r.textContent, u, e), o = ["children", "" + u]) : l.hasOwnProperty(s) && null != u && "onScroll" === s && zr("scroll", r) } switch (n) { case "input": G(r), J(r, i, !0); break; case "textarea": G(r), ie(r); break; case "select": case "option": break; default: "function" === typeof i.onClick && (r.onclick = Jr) }r = o, t.updateQueue = r, null !== r && (t.flags |= 4) } else { s = 9 === o.nodeType ? o : o.ownerDocument, "http://www.w3.org/1999/xhtml" === e && (e = le(n)), "http://www.w3.org/1999/xhtml" === e ? "script" === n ? ((e = s.createElement("div")).innerHTML = " + + + + + + + + diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Assets/index.html b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Assets/index.html new file mode 100644 index 000000000..27d3f7158 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Assets/index.html @@ -0,0 +1,470 @@ + + + + + + %(DocumentTitle) + + + + + %(HeadContent) + + + +
+
+ + +
+ + + + + + + + diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Attributes/EnumToNumberAttribute.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Attributes/EnumToNumberAttribute.cs new file mode 100644 index 000000000..55ded4ff1 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Attributes/EnumToNumberAttribute.cs @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 用于控制 Swager 生成 Enum 类型 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Enum, AllowMultiple = false)] +public sealed class EnumToNumberAttribute : Attribute +{ + /// + /// 构造函数 + /// + public EnumToNumberAttribute() + : this(true) + { + } + + /// + /// 构造函数 + /// + /// 启用状态 + public EnumToNumberAttribute(bool enabled = true) + { + Enabled = enabled; + } + + /// + /// 启用状态 + /// + /// 设置 false 则使用字符串类型 + public bool Enabled { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Attributes/OperationIdAttribute.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Attributes/OperationIdAttribute.cs new file mode 100644 index 000000000..90721da17 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Attributes/OperationIdAttribute.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 配置规范化文档 OperationId 问题 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class OperationIdAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// 自定义 OperationId,可用户生成可读的前端代码 + public OperationIdAttribute(string operationId) + { + OperationId = operationId; + } + + /// + /// 自定义 OperationId + /// + public string OperationId { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Attributes/SchemaIdAttribute.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Attributes/SchemaIdAttribute.cs new file mode 100644 index 000000000..2f7b99c07 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Attributes/SchemaIdAttribute.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 解决规范化文档 SchemaId 冲突问题 +/// +[AttributeUsage(AttributeTargets.Class)] +public sealed class SchemaIdAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// 自定义 SchemaId,只能是字母开头,只运行下划线_连接 + public SchemaIdAttribute(string schemaId) + { + SchemaId = schemaId; + } + + /// + /// 构造函数 + /// + /// 自定义 SchemaId + /// 默认在头部叠加,设置 true 之后,将直接使用 + public SchemaIdAttribute(string schemaId, bool replace) + { + SchemaId = schemaId; + Replace = replace; + } + + /// + /// 自定义 SchemaId + /// + public string SchemaId { get; set; } + + /// + /// 完全覆盖 + /// + /// 默认在头部叠加,设置 true 之后,将直接使用 + public bool Replace { get; set; } = false; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Builders/SpecificationDocumentBuilder.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Builders/SpecificationDocumentBuilder.cs new file mode 100644 index 000000000..9641571a6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Builders/SpecificationDocumentBuilder.cs @@ -0,0 +1,910 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.OpenApi.Models; + +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerGen; +using Swashbuckle.AspNetCore.SwaggerUI; + +using System.Collections.Concurrent; +using System.Reflection; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using System.Xml.XPath; + +using ThingsGateway.DynamicApiController; +using ThingsGateway.Extensions; +using ThingsGateway.Reflection; + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 规范化文档构建器 +/// +[SuppressSniffer] +public static class SpecificationDocumentBuilder +{ + /// + /// 所有分组默认的组名 Key + /// + private const string AllGroupsKey = "All Groups"; + + /// + /// 规范化文档配置 + /// + private static readonly SpecificationDocumentSettingsOptions _specificationDocumentSettings; + + /// + /// 应用全局配置 + /// + private static readonly AppSettingsOptions _appSettings; + + /// + /// 分组信息 + /// + private static readonly IEnumerable DocumentGroupExtras; + + /// + /// 带排序的分组名 + /// + private static readonly Regex _groupOrderRegex; + + /// + /// 文档分组列表 + /// + public static readonly IEnumerable DocumentGroups; + + /// + /// 构造函数 + /// + static SpecificationDocumentBuilder() + { + // 载入配置 + _specificationDocumentSettings = App.GetConfig("SpecificationDocumentSettings", true); + _appSettings = App.Settings; + + // 初始化常量 + _groupOrderRegex = new Regex(@"@(?[0-9]+$)"); + GetActionGroupsCached = new ConcurrentDictionary>(); + GetControllerGroupsCached = new ConcurrentDictionary>(); + GetGroupOpenApiInfoCached = new ConcurrentDictionary(); + GetControllerTagCached = new ConcurrentDictionary(); + GetActionTagCached = new ConcurrentDictionary(); + + // 默认分组,支持多个逗号分割 + DocumentGroupExtras = new List { ResolveGroupExtraInfo(_specificationDocumentSettings.DefaultGroupName) }; + + // 加载所有分组 + DocumentGroups = ReadGroups(); + } + + /// + /// 检查方法是否在分组中 + /// + /// + /// + /// + public static bool CheckApiDescriptionInCurrentGroup(string currentGroup, ApiDescription apiDescription) + { + if (!apiDescription.TryGetMethodInfo(out var method)) return false; + + // 处理 Mvc 和 WebAPI 混合项目路由问题 + if (typeof(Controller).IsAssignableFrom(method.DeclaringType) && apiDescription.ActionDescriptor.ActionConstraints == null) + { + return false; + } + + // 处理贴有 [ApiExplorerSettings(IgnoreApi = true)] 或者 [ApiDescriptionSettings(false)] 特性的接口 + var apiExplorerSettings = method.GetFoundAttribute(true); + + var apiDescriptionSettings = method.GetFoundAttribute(true); + + if (apiExplorerSettings?.IgnoreApi == true || apiDescriptionSettings?.IgnoreApi == true) return false; + + if (currentGroup == AllGroupsKey) + { + return true; + } + + return GetActionGroups(method).Any(u => u.Group == currentGroup); + } + + /// + /// 获取所有的规范化分组信息 + /// + /// + public static List GetOpenApiGroups() + { + var openApiGroups = new List(); + foreach (var group in DocumentGroups) + { + openApiGroups.Add(GetGroupOpenApiInfo(group)); + } + + return openApiGroups; + } + + /// + /// 获取分组信息缓存集合 + /// + private static readonly ConcurrentDictionary GetGroupOpenApiInfoCached; + + /// + /// 获取分组配置信息 + /// + /// + /// + public static SpecificationOpenApiInfo GetGroupOpenApiInfo(string group) + { + return GetGroupOpenApiInfoCached.GetOrAdd(group, Function); + + // 本地函数 + static SpecificationOpenApiInfo Function(string group) + { + // 替换路由模板 + var routeTemplate = _specificationDocumentSettings.RouteTemplate.Replace("{documentName}", Uri.EscapeDataString(group)); + if (!string.IsNullOrWhiteSpace(_specificationDocumentSettings.ServerDir)) + { + routeTemplate = _specificationDocumentSettings.ServerDir + "/" + routeTemplate; + } + + // 处理虚拟目录问题 + var template = $"{_appSettings.VirtualPath}/{routeTemplate}"; + + var groupInfo = _specificationDocumentSettings.GroupOpenApiInfos.FirstOrDefault(u => u.Group == group); + if (groupInfo != null) + { + groupInfo.RouteTemplate = template; + groupInfo.Title ??= group; + } + else + { + groupInfo = new SpecificationOpenApiInfo { Group = group, RouteTemplate = template }; + } + + // 处理外部定义 + var groupKey = "[openapi:{0}]"; + if (App.Configuration.Exists(string.Format(groupKey, group))) + { + SetProperty(group, nameof(SpecificationOpenApiInfo.Order), value => groupInfo.Order = value); + SetProperty(group, nameof(SpecificationOpenApiInfo.Visible), value => groupInfo.Visible = value); + SetProperty(group, nameof(SpecificationOpenApiInfo.RouteTemplate), value => groupInfo.RouteTemplate = value); + SetProperty(group, nameof(SpecificationOpenApiInfo.Title), value => groupInfo.Title = value); + SetProperty(group, nameof(SpecificationOpenApiInfo.Description), value => groupInfo.Description = value); + SetProperty(group, nameof(SpecificationOpenApiInfo.Version), value => groupInfo.Version = value); + SetProperty(group, nameof(SpecificationOpenApiInfo.TermsOfService), value => groupInfo.TermsOfService = value); + SetProperty(group, nameof(SpecificationOpenApiInfo.Contact), value => groupInfo.Contact = value); + SetProperty(group, nameof(SpecificationOpenApiInfo.License), value => groupInfo.License = value); + } + + return groupInfo; + } + } + + /// + /// 设置额外配置的值 + /// + /// + /// + /// + /// + private static void SetProperty(string group, string propertyName, Action action) + { + var propertyKey = string.Format("[openapi:{0}]:{1}", group, propertyName); + if (App.Configuration.Exists(propertyKey)) + { + var value = App.GetConfig(propertyKey); + action?.Invoke(value); + } + } + + /// + /// 构建Swagger全局配置 + /// + /// Swagger 全局配置 + /// + internal static void Build(SwaggerOptions swaggerOptions, Action configure = null) + { + // 生成V2版本 + swaggerOptions.SerializeAsV2 = _specificationDocumentSettings.FormatAsV2 == true; + + // 判断是否启用 Server + if (_specificationDocumentSettings.HideServers != true) + { + // 启动服务器 Servers + swaggerOptions.PreSerializeFilters.Add((swagger, request) => + { + // 默认 Server + var servers = new List { + new OpenApiServer { Url = $"{request.Scheme}://{request.Host.Value}{_appSettings.VirtualPath}",Description="Default" } + }; + servers.AddRange(_specificationDocumentSettings.Servers); + + swagger.Servers = servers; + }); + } + + // 配置路由模板 + swaggerOptions.RouteTemplate = _specificationDocumentSettings.RouteTemplate; + + // 自定义配置 + configure?.Invoke(swaggerOptions); + } + + /// + /// Swagger 生成器构建 + /// + /// Swagger 生成器配置 + /// 自定义配置 + internal static void BuildGen(SwaggerGenOptions swaggerGenOptions, Action configure = null) + { + // 创建分组文档 + CreateSwaggerDocs(swaggerGenOptions); + + // 加载分组控制器和动作方法列表 + LoadGroupControllerWithActions(swaggerGenOptions); + + // 配置 Swagger OperationIds + ConfigureOperationIds(swaggerGenOptions); + + // 配置 Swagger SchemaId + ConfigureSchemaIds(swaggerGenOptions); + + // 配置标签 + ConfigureTagsAction(swaggerGenOptions); + + // 配置 Action 排序 + ConfigureActionSequence(swaggerGenOptions); + + if (_specificationDocumentSettings.EnableXmlComments == true) + { + // 加载注释描述文件 + LoadXmlComments(swaggerGenOptions); + } + + // 配置授权 + ConfigureSecurities(swaggerGenOptions); + + //使得 Swagger 能够正确地显示 Enum 的对应关系 + if (_specificationDocumentSettings.EnableEnumSchemaFilter == true) swaggerGenOptions.SchemaFilter(); + + // 修复 editor.swagger.io 生成不能正常处理 C# object 类型问题 + swaggerGenOptions.SchemaFilter(); + + // 添加 Action 操作过滤器 + swaggerGenOptions.OperationFilter(); + + // 自定义配置 + configure?.Invoke(swaggerGenOptions); + + // 支持控制器排序操作 + if (_specificationDocumentSettings.EnableTagsOrderDocumentFilter == true) swaggerGenOptions.DocumentFilter(); + } + + /// + /// Swagger UI 构建 + /// + /// + /// + /// + /// 解决 Swagger 被代理问题 + internal static void BuildUI(SwaggerUIOptions swaggerUIOptions, string routePrefix = default, Action configure = null, bool withProxy = false) + { + // 配置分组终点路由 + CreateGroupEndpoint(swaggerUIOptions, routePrefix, withProxy); + + // 配置文档标题 + swaggerUIOptions.DocumentTitle = _specificationDocumentSettings.DocumentTitle; + + // 配置UI地址(处理二级虚拟目录) + swaggerUIOptions.RoutePrefix = _specificationDocumentSettings.RoutePrefix ?? routePrefix ?? "api"; + + // 文档展开设置 + swaggerUIOptions.DocExpansion(_specificationDocumentSettings.DocExpansionState.Value); + + // 自定义 Swagger 首页 + CustomizeIndex(swaggerUIOptions); + + // 配置多语言和自动登录token + AddDefaultInterceptor(swaggerUIOptions); + + // 自定义配置 + configure?.Invoke(swaggerUIOptions); + } + + /// + /// 创建分组文档 + /// + /// Swagger生成器对象 + private static void CreateSwaggerDocs(SwaggerGenOptions swaggerGenOptions) + { + foreach (var group in DocumentGroups) + { + if (swaggerGenOptions.SwaggerGeneratorOptions.SwaggerDocs.ContainsKey(group)) continue; + + var groupOpenApiInfo = GetGroupOpenApiInfo(group) as OpenApiInfo; + swaggerGenOptions.SwaggerDoc(group, groupOpenApiInfo); + } + } + + /// + /// 加载分组控制器和动作方法列表 + /// + /// Swagger 生成器配置 + private static void LoadGroupControllerWithActions(SwaggerGenOptions swaggerGenOptions) + { + swaggerGenOptions.DocInclusionPredicate(CheckApiDescriptionInCurrentGroup); + } + + /// + /// 配置标签 + /// + /// + private static void ConfigureTagsAction(SwaggerGenOptions swaggerGenOptions) + { + swaggerGenOptions.TagActionsBy(apiDescription => + { + return new[] { GetActionTag(apiDescription) }; + }); + } + + /// + /// 配置 Action 排序 + /// + /// + private static void ConfigureActionSequence(SwaggerGenOptions swaggerGenOptions) + { + swaggerGenOptions.OrderActionsBy(apiDesc => + { + var apiDescriptionSettings = apiDesc.CustomAttributes() + .FirstOrDefault(u => u.GetType() == typeof(ApiDescriptionSettingsAttribute)) + as ApiDescriptionSettingsAttribute ?? new ApiDescriptionSettingsAttribute(); + + return (int.MaxValue - apiDescriptionSettings.Order).ToString() + .PadLeft(int.MaxValue.ToString().Length, '0'); + }); + } + + /// + /// 配置 Swagger OperationIds + /// + /// Swagger 生成器配置 + private static void ConfigureOperationIds(SwaggerGenOptions swaggerGenOptions) + { + swaggerGenOptions.CustomOperationIds(apiDescription => + { + var isMethod = apiDescription.TryGetMethodInfo(out var method); + + // 判断是否自定义了 [OperationId] 特性 + if (isMethod && method.IsDefined(typeof(OperationIdAttribute), false)) + { + return method.GetCustomAttribute(false).OperationId; + } + + var operationId = apiDescription.RelativePath.Replace("/", "-") + .Replace("{", "-") + .Replace("}", "-") + "-" + apiDescription.HttpMethod.ToLower().ToUpperCamelCase(); + + return operationId.Replace("--", "-"); + }); + } + + /// + /// 配置 Swagger SchemaIds + /// + /// Swagger 生成器配置 + private static void ConfigureSchemaIds(SwaggerGenOptions swaggerGenOptions) + { + // 本地函数 + static string DefaultSchemaIdSelector(Type modelType) + { + var modelName = modelType.Name; + + // 处理泛型类型问题 + if (modelType.IsConstructedGenericType) + { + var prefix = modelType.GetGenericArguments() + .Select(genericArg => DefaultSchemaIdSelector(genericArg)) + .Aggregate((previous, current) => previous + current); + + // 通过 _ 拼接多个泛型 + modelName = modelName.Split('`').First() + "_" + prefix; + } + + // 判断是否自定义了 [SchemaId] 特性,解决模块化多个程序集命名冲突 + var isCustomize = modelType.IsDefined(typeof(SchemaIdAttribute)); + if (isCustomize) + { + var schemaIdAttribute = modelType.GetCustomAttribute(); + if (!schemaIdAttribute.Replace) return schemaIdAttribute.SchemaId + modelName; + else return schemaIdAttribute.SchemaId; + } + + return modelName; + } + + // 调用本地函数 + swaggerGenOptions.CustomSchemaIds(modelType => DefaultSchemaIdSelector(modelType)); + } + + /// + /// 加载注释描述文件 + /// + /// Swagger 生成器配置 + private static void LoadXmlComments(SwaggerGenOptions swaggerGenOptions) + { + var xmlComments = _specificationDocumentSettings.XmlComments ?? Array.Empty(); + var members = new Dictionary(); + + // 显式继承的注释 + var regex = new Regex(@"[A-Z]:[a-zA-Z0-9_@\.]+"); + // 隐式继承的注释 + var regex2 = new Regex(@"[A-Z]:[a-zA-Z0-9_@\.]+\."); + + // 支持注释完整特性,包括 inheritdoc 注释语法 + foreach (var xmlComment in xmlComments) + { + var assemblyXmlName = xmlComment.EndsWith(".xml") ? xmlComment : $"{xmlComment}.xml"; + var assemblyXmlPath = Path.Combine(AppContext.BaseDirectory, assemblyXmlName); + + if (File.Exists(assemblyXmlPath)) + { + var xmlDoc = XDocument.Load(assemblyXmlPath); + + // 查找所有 member[name] 节点,且不包含 节点的注释 + var memberNotInheritdocElementList = xmlDoc.XPathSelectElements("/doc/members/member[@name and not(inheritdoc)]"); + + foreach (var memberElement in memberNotInheritdocElementList) + { + members.TryAdd(memberElement.Attribute("name").Value, memberElement); + } + + // 查找所有 member[name] 含有 节点的注释 + var memberElementList = xmlDoc.XPathSelectElements("/doc/members/member[inheritdoc]"); + foreach (var memberElement in memberElementList) + { + var inheritdocElement = memberElement.Element("inheritdoc"); + var cref = inheritdocElement.Attribute("cref"); + var value = cref?.Value; + + // 处理不带 cref 的 inheritdoc 注释 + if (value == null) + { + var memberName = inheritdocElement.Parent.Attribute("name").Value; + + // 处理隐式实现接口的注释 + // 注释格式:M:ThingsGateway.Application.TestInheritdoc.Furion#Application#ITestInheritdoc#Abc(System.String) + // 匹配格式:[A-Z]:[a-zA-Z0-9_@\.]+\. + // 处理逻辑:直接替换匹配为空,然后讲 # 替换为 . 查找即可 + if (memberName.Contains('#')) + { + value = $"{memberName[..2]}{regex2.Replace(memberName, "").Replace('#', '.')}"; + } + // 处理带参数的注释 + // 注释格式:M:ThingsGateway.Application.TestInheritdoc.WithParams(System.String) + // 匹配格式:[A-Z]:[a-zA-Z0-9_@\.]+ + // 处理逻辑:匹配出不带参数的部分,然后获取类型命名空间,最后调用 GenerateInheritdocCref 进行生成 + else if (memberName.Contains('(')) + { + var noParamsClassName = regex.Match(memberName).Value; + var className = noParamsClassName[noParamsClassName.IndexOf(':')..noParamsClassName.LastIndexOf(':')]; + value = GenerateInheritdocCref(xmlDoc, memberName, className); + } + // 处理不带参数的注释 + // 注释格式:M:ThingsGateway.Application.TestInheritdoc.WithParams + // 匹配格式:无 + // 处理逻辑:获取类型命名空间,最后调用 GenerateInheritdocCref 进行生成 + else + { + + try + { + var className = memberName[memberName.IndexOf(':')..memberName.LastIndexOf(':')]; + value = GenerateInheritdocCref(xmlDoc, memberName, className); + } + catch (Exception ex) + { + throw new Exception($"namespace {memberName} parsing doc warn", ex); + } + } + } + + if (string.IsNullOrWhiteSpace(value)) continue; + + // 处理带 cref 的 inheritdoc 注释 + if (members.TryGetValue(value, out var realDocMember)) + { + memberElement.SetAttributeValue("_ref_", value); + inheritdocElement.Parent.ReplaceNodes(realDocMember.Nodes()); + } + } + + swaggerGenOptions.IncludeXmlComments(() => new XPathDocument(xmlDoc.CreateReader()), true); + } + } + } + + /// + /// 生成 Inheritdoc cref 属性 + /// + /// + /// + /// + /// + private static string GenerateInheritdocCref(XDocument xmlDoc, string memberName, string className) + { + var classElement = xmlDoc.XPathSelectElements($"/doc/members/member[@name='{"T" + className}' and @_ref_]").FirstOrDefault(); + if (classElement == null) return default; + + var _ref_value = classElement.Attribute("_ref_")?.Value; + if (_ref_value == null) return default; + + var classCrefValue = _ref_value[_ref_value.IndexOf(':')..]; + return memberName.Replace(className, classCrefValue); + } + + /// + /// 配置授权 + /// + /// Swagger 生成器配置 + private static void ConfigureSecurities(SwaggerGenOptions swaggerGenOptions) + { + // 判断是否启用了授权 + if (_specificationDocumentSettings.EnableAuthorized != true || _specificationDocumentSettings.SecurityDefinitions.Length == 0) return; + + var openApiSecurityRequirement = new OpenApiSecurityRequirement(); + + // 生成安全定义 + foreach (var securityDefinition in _specificationDocumentSettings.SecurityDefinitions) + { + // Id 必须定义 + if (string.IsNullOrWhiteSpace(securityDefinition.Id) + || swaggerGenOptions.SwaggerGeneratorOptions.SecuritySchemes.ContainsKey(securityDefinition.Id)) continue; + + // 添加安全定义 + var openApiSecurityScheme = securityDefinition as OpenApiSecurityScheme; + swaggerGenOptions.AddSecurityDefinition(securityDefinition.Id, openApiSecurityScheme); + + // 添加安全需求 + var securityRequirement = securityDefinition.Requirement; + + // C# 9.0 模式匹配新语法 + if (securityRequirement is { Scheme.Reference: not null }) + { + securityRequirement.Scheme.Reference.Id ??= securityDefinition.Id; + openApiSecurityRequirement.Add(securityRequirement.Scheme, securityRequirement.Accesses); + } + } + + // 添加安全需求 + if (openApiSecurityRequirement.Count > 0) + { + swaggerGenOptions.AddSecurityRequirement(openApiSecurityRequirement); + } + } + + /// + /// 配置分组终点路由 + /// + /// + /// + /// 解决 Swagger 被代理问题 + private static void CreateGroupEndpoint(SwaggerUIOptions swaggerUIOptions, string routePrefix = default, bool withProxy = false) + { + var routePrefixArrs = (routePrefix ?? swaggerUIOptions.RoutePrefix).Split('/', StringSplitOptions.RemoveEmptyEntries); + var routePrefixList = routePrefixArrs.Length == 0 ? routePrefixArrs.Concat(new[] { string.Empty }) : routePrefixArrs; + + foreach (var group in DocumentGroups) + { + var groupOpenApiInfo = GetGroupOpenApiInfo(group); + + swaggerUIOptions.SwaggerEndpoint((withProxy ? string.Join(string.Empty, routePrefixList.Select(c => "../")) : "/") + groupOpenApiInfo.RouteTemplate.TrimStart('/'), groupOpenApiInfo?.Title ?? group); + } + } + + /// + /// 自定义 Swagger 首页 + /// + /// + private static void CustomizeIndex(SwaggerUIOptions swaggerUIOptions) + { + var thisType = typeof(SpecificationDocumentBuilder); + var thisAssembly = thisType.Assembly; + + // 判断是否启用 MiniProfile + var customIndex = $"{Reflect.GetAssemblyName(thisAssembly)}{thisType.Namespace.Replace(nameof(ThingsGateway), string.Empty)}.Assets.{(_appSettings.InjectMiniProfiler != true ? "index" : "index-mini-profiler")}.html"; + swaggerUIOptions.IndexStream = () => + { + StringBuilder htmlBuilder; + // 自定义首页模板参数 + var indexArguments = new Dictionary + { + {"%(VirtualPath)", _appSettings.VirtualPath } // 解决二级虚拟目录 MiniProfiler 丢失问题 + }; + + // 读取文件内容 + using (var stream = thisAssembly.GetManifestResourceStream(customIndex)) + { + using var reader = new StreamReader(stream); + htmlBuilder = new StringBuilder(reader.ReadToEnd()); + } + + // 替换模板参数 + foreach (var (template, value) in indexArguments) + { + htmlBuilder.Replace(template, value); + } + + // 返回新的内存流 + var byteArray = Encoding.UTF8.GetBytes(htmlBuilder.ToString()); + return new MemoryStream(byteArray); + }; + + // 添加登录信息配置 + var additionals = _specificationDocumentSettings.LoginInfo; + if (additionals != null) + { + swaggerUIOptions.ConfigObject.AdditionalItems.Add(nameof(_specificationDocumentSettings.LoginInfo), new JsonObject + { + [nameof(SpecificationLoginInfo.Enabled)] = additionals.Enabled || (App.HostEnvironment.IsProduction() && additionals.EnableOnProduction), + [nameof(SpecificationLoginInfo.CheckUrl)] = additionals.CheckUrl, + [nameof(SpecificationLoginInfo.SubmitUrl)] = additionals.SubmitUrl + }); + } + } + + /// + /// 添加默认请求/响应拦截器 + /// + /// + private static void AddDefaultInterceptor(SwaggerUIOptions swaggerUIOptions) + { + // 配置多语言和自动登录token + swaggerUIOptions.UseRequestInterceptor("function(request) { return defaultRequestInterceptor(request); }"); + swaggerUIOptions.UseResponseInterceptor("function(response) { return defaultResponseInterceptor(response); }"); + } + + /// + /// 读取所有分组信息 + /// + /// + private static IEnumerable ReadGroups() + { + // 获取所有的控制器和动作方法 + var controllers = App.EffectiveTypes.Where(u => Penetrates.IsApiController(u)); + if (!controllers.Any()) + { + var defaultGroups = new List + { + _specificationDocumentSettings.DefaultGroupName + }; + + // 启用总分组功能 + if (_specificationDocumentSettings.EnableAllGroups == true) + { + defaultGroups.Add(AllGroupsKey); + } + + return defaultGroups; + } + + var actions = controllers.SelectMany(c => c.GetMethods().Where(u => IsApiAction(u, c))); + + // 合并所有分组 + var groupOrders = controllers.SelectMany(u => GetControllerGroups(u)) + .Union( + actions.SelectMany(u => GetActionGroups(u)) + ) + .Where(u => u != null && u.Visible) + // 分组后取最大排序 + .GroupBy(u => u.Group) + .Select(u => new GroupExtraInfo + { + Group = u.Key, + Order = u.Max(x => x.Order), + Visible = true + }); + + // 分组排序 + var groups = groupOrders + .OrderByDescending(u => u.Order) + .ThenBy(u => u.Group) + .Select(u => u.Group) + .Union(_specificationDocumentSettings.PackagesGroups); + + // 启用总分组功能 + if (_specificationDocumentSettings.EnableAllGroups == true) + { + groups = groups.Concat(new[] { AllGroupsKey }); + } + + return groups; + } + + /// + /// 获取控制器组缓存集合 + /// + private static readonly ConcurrentDictionary> GetControllerGroupsCached; + + /// + /// 获取控制器分组列表 + /// + /// + /// + public static IEnumerable GetControllerGroups(Type type) + { + return GetControllerGroupsCached.GetOrAdd(type, Function); + + // 本地函数 + static IEnumerable Function(Type type) + { + // 如果控制器没有定义 [ApiDescriptionSettings] 特性,则返回默认分组 + if (!type.IsDefined(typeof(ApiDescriptionSettingsAttribute), true)) return DocumentGroupExtras; + + // 读取分组 + var apiDescriptionSettings = type.GetCustomAttribute(true); + if (apiDescriptionSettings.Groups == null || apiDescriptionSettings.Groups.Length == 0) return DocumentGroupExtras; + + // 处理分组额外信息 + var groupExtras = new List(); + foreach (var group in apiDescriptionSettings.Groups) + { + groupExtras.Add(ResolveGroupExtraInfo(group)); + } + + return groupExtras; + } + } + + /// + /// 缓存集合 + /// + private static readonly ConcurrentDictionary> GetActionGroupsCached; + + /// + /// 获取动作方法分组列表 + /// + /// 方法 + /// + public static IEnumerable GetActionGroups(MethodInfo method) + { + return GetActionGroupsCached.GetOrAdd(method, Function); + + // 本地函数 + static IEnumerable Function(MethodInfo method) + { + // 如果动作方法没有定义 [ApiDescriptionSettings] 特性,则返回所在控制器分组 + if (!method.IsDefined(typeof(ApiDescriptionSettingsAttribute), true)) return GetControllerGroups(method.ReflectedType); + + // 读取分组 + var apiDescriptionSettings = method.GetCustomAttribute(true); + if (apiDescriptionSettings.Groups == null || apiDescriptionSettings.Groups.Length == 0) return GetControllerGroups(method.ReflectedType); + + // 处理排序 + var groupExtras = new List(); + foreach (var group in apiDescriptionSettings.Groups) + { + groupExtras.Add(ResolveGroupExtraInfo(group)); + } + + return groupExtras; + } + } + + /// + /// 缓存集合 + /// + private static readonly ConcurrentDictionary GetControllerTagCached; + + /// + /// 获取控制器标签 + /// + /// 控制器接口描述器 + /// + public static string GetControllerTag(ControllerActionDescriptor controllerActionDescriptor) + { + return GetControllerTagCached.GetOrAdd(controllerActionDescriptor, Function); + + // 本地函数 + static string Function(ControllerActionDescriptor controllerActionDescriptor) + { + var type = controllerActionDescriptor.ControllerTypeInfo; + // 如果动作方法没有定义 [ApiDescriptionSettings] 特性,则返回所在控制器名 + if (!type.IsDefined(typeof(ApiDescriptionSettingsAttribute), true)) return controllerActionDescriptor.ControllerName; + + // 读取标签 + var apiDescriptionSettings = type.GetCustomAttribute(true); + return string.IsNullOrWhiteSpace(apiDescriptionSettings.Tag) ? controllerActionDescriptor.ControllerName : apiDescriptionSettings.Tag; + } + } + + /// + /// 缓存集合 + /// + private static readonly ConcurrentDictionary GetActionTagCached; + + /// + /// 获取动作方法标签 + /// + /// 接口描述器 + /// + public static string GetActionTag(ApiDescription apiDescription) + { + return GetActionTagCached.GetOrAdd(apiDescription, Function); + + // 本地函数 + static string Function(ApiDescription apiDescription) + { + if (!apiDescription.TryGetMethodInfo(out var method) + || apiDescription.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor) return Assembly.GetEntryAssembly().GetName().Name; + + // 如果动作方法没有定义 [ApiDescriptionSettings] 特性,则返回所在控制器名 + if (!method.IsDefined(typeof(ApiDescriptionSettingsAttribute), true)) return GetControllerTag(controllerActionDescriptor); + + // 读取标签 + var apiDescriptionSettings = method.GetCustomAttribute(true); + return string.IsNullOrWhiteSpace(apiDescriptionSettings.Tag) ? GetControllerTag(controllerActionDescriptor) : apiDescriptionSettings.Tag; + } + } + + /// + /// 是否是动作方法 + /// + /// 方法 + /// 声明类型 + /// + public static bool IsApiAction(MethodInfo method, Type ReflectedType) + { + // 不是非公开、抽象、静态、泛型方法 + if (!method.IsPublic || method.IsAbstract || method.IsStatic || method.IsGenericMethod) return false; + + // 如果所在类型不是控制器,则该行为也被忽略 + if (method.ReflectedType != ReflectedType || method.DeclaringType == typeof(object)) return false; + + return true; + } + + /// + /// 解析分组附加信息 + /// + /// 分组名 + /// + private static GroupExtraInfo ResolveGroupExtraInfo(string group) + { + string realGroup; + var order = 0; + + if (!_groupOrderRegex.IsMatch(group)) realGroup = group; + else + { + realGroup = _groupOrderRegex.Replace(group, ""); + order = int.Parse(_groupOrderRegex.Match(group).Groups["order"].Value); + } + + var groupOpenApiInfo = GetGroupOpenApiInfo(realGroup); + return new GroupExtraInfo + { + Group = realGroup, + Order = groupOpenApiInfo.Order ?? order, + Visible = groupOpenApiInfo.Visible ?? true + }; + } +} diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Extensions/SpecificationDocumentApplicationBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Extensions/SpecificationDocumentApplicationBuilderExtensions.cs new file mode 100644 index 000000000..fa1ce4ab8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Extensions/SpecificationDocumentApplicationBuilderExtensions.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Swashbuckle.AspNetCore.Swagger; +using Swashbuckle.AspNetCore.SwaggerUI; + +using ThingsGateway; +using ThingsGateway.SpecificationDocument; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// 规范化文档中间件拓展 +/// +[SuppressSniffer] +public static class SpecificationDocumentApplicationBuilderExtensions +{ + /// + /// 添加规范化文档中间件 + /// + /// + /// + /// + /// + /// 解决 Swagger 被代理问题 + /// + public static IApplicationBuilder UseSpecificationDocuments(this IApplicationBuilder app + , string routePrefix = default + , Action configureSwagger = default + , Action configureSwaggerUI = default + , bool withProxy = false) + { + // 判断是否启用规范化文档 + if (App.Settings.InjectSpecificationDocument != true) return app; + + // 配置 Swagger 全局参数 + app.UseSwagger(options => SpecificationDocumentBuilder.Build(options, configureSwagger)); + + // 配置 Swagger UI 参数 + app.UseSwaggerUI(options => SpecificationDocumentBuilder.BuildUI(options, routePrefix, configureSwaggerUI, withProxy)); + + // 启用 MiniProfiler组件 + if (App.Settings.InjectMiniProfiler == true) app.UseMiniProfiler(); + + return app; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Extensions/SpecificationDocumentServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Extensions/SpecificationDocumentServiceCollectionExtensions.cs new file mode 100644 index 000000000..46f3d00e9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Extensions/SpecificationDocumentServiceCollectionExtensions.cs @@ -0,0 +1,93 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Options; + +using Swashbuckle.AspNetCore.SwaggerGen; + +using ThingsGateway; +using ThingsGateway.SpecificationDocument; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 规范化接口服务拓展类 +/// +[SuppressSniffer] +public static class SpecificationDocumentServiceCollectionExtensions +{ + /// + /// 添加规范化文档服务 + /// + /// Mvc 构建器 + /// 自定义配置 + /// 服务集合 + public static IMvcBuilder AddSpecificationDocuments(this IMvcBuilder mvcBuilder, Action configure = default) + { + mvcBuilder.Services.AddSpecificationDocuments(configure); + + return mvcBuilder; + } + + /// + /// 添加规范化文档服务 + /// + /// 服务集合 + /// 自定义配置 + /// 服务集合 + public static IServiceCollection AddSpecificationDocuments(this IServiceCollection services, Action configure = default) + { + // 解决服务重复注册问题 + if (services.Any(u => u.ServiceType == typeof(IConfigureOptions))) + { + return services; + } + + // 判断是否启用规范化文档 + if (App.Settings.InjectSpecificationDocument != true) return services; + + // 添加配置 + services.AddConfigurableOptions(); + services.AddEndpointsApiExplorer(); + + // 添加Swagger生成器服务 + services.AddSwaggerGen(options => SpecificationDocumentBuilder.BuildGen(options, configure)); + + // 添加 MiniProfiler 服务 + AddMiniProfiler(services); + + return services; + } + + /// + /// 添加 MiniProfiler 配置 + /// + /// + private static void AddMiniProfiler(IServiceCollection services) + { + // 注册MiniProfiler 组件 + if (App.Settings.InjectMiniProfiler != true) return; + + services.AddMiniProfiler(options => + { + // 减少非 Swagger 页面请求监听问题 + options.ShouldProfile = (req) => + { + if (!req.Headers.ContainsKey("request-from")) return false; + return true; + }; + + options.RouteBasePath = "/index-mini-profiler"; + options.EnableMvcFilterProfiling = false; + options.EnableMvcViewProfiling = false; + }); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/AnySchemaFilter.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/AnySchemaFilter.cs new file mode 100644 index 000000000..007d3eab0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/AnySchemaFilter.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.OpenApi.Models; + +using Swashbuckle.AspNetCore.SwaggerGen; + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 修正 规范化文档 object schema,统一显示为 any +/// +/// 相关 issue:https://github.com/swagger-api/swagger-codegen-generators/issues/692 +[SuppressSniffer] +public class AnySchemaFilter : ISchemaFilter +{ + /// + /// 实现过滤器方法 + /// + /// + /// + public void Apply(OpenApiSchema model, SchemaFilterContext context) + { + var type = context.Type; + + if (type == typeof(object)) + { + model.AdditionalPropertiesAllowed = false; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/ApiActionFilter.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/ApiActionFilter.cs new file mode 100644 index 000000000..776109c1b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/ApiActionFilter.cs @@ -0,0 +1,69 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Models; + +using Swashbuckle.AspNetCore.SwaggerGen; + +using System.ComponentModel; +using System.Reflection; + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 规范化文档自定义更多功能 +/// +public class ApiActionFilter : IOperationFilter +{ + /// + /// 实现过滤器方法 + /// + /// + /// + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + // 获取方法 + var method = context.MethodInfo; + + // 处理更多描述 + if (method.IsDefined(typeof(ApiDescriptionSettingsAttribute), true)) + { + var apiDescriptionSettings = method.GetCustomAttribute(true); + + // 添加单一接口描述 + if (!string.IsNullOrWhiteSpace(apiDescriptionSettings.Description)) + { + operation.Description += apiDescriptionSettings.Description; + } + } + + // 处理定义 [DisplayName] 特性但并未注释的情况 + if (string.IsNullOrWhiteSpace(operation.Summary) && method.IsDefined(typeof(DisplayNameAttribute), true)) + { + var displayName = method.GetCustomAttribute(true); + if (!string.IsNullOrWhiteSpace(displayName.DisplayName)) + { + operation.Summary = displayName.DisplayName; + } + } + + // 处理过时 + if (method.IsDefined(typeof(ObsoleteAttribute), true)) + { + var deprecated = method.GetCustomAttribute(true); + if (!string.IsNullOrWhiteSpace(deprecated.Message)) + { + operation.Description = $"
{deprecated.Message}
" + operation.Description; + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/EnumSchemaFilter.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/EnumSchemaFilter.cs new file mode 100644 index 000000000..524bdff89 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/EnumSchemaFilter.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +using Swashbuckle.AspNetCore.SwaggerGen; + +using System.ComponentModel; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 修正 规范化文档 Enum 提示 +/// +[SuppressSniffer] +public class EnumSchemaFilter : ISchemaFilter +{ + /// + /// 中文正则表达式 + /// + private const string CHINESE_PATTERN = @"[\u4e00-\u9fa5]"; + + /// + /// 实现过滤器方法 + /// + /// + /// + public void Apply(OpenApiSchema model, SchemaFilterContext context) + { + var type = context.Type; + + // 排除其他程序集的枚举 + if (type.IsEnum && App.Assemblies.Contains(type.Assembly)) + { + model.Enum.Clear(); + var stringBuilder = new StringBuilder(); + stringBuilder.Append($"{model.Description}
"); + + var enumValues = Enum.GetValues(type); + + bool convertToNumber; + // 定义 [EnumToNumber] 特性情况 + if (type.IsDefined(typeof(EnumToNumberAttribute), false)) + { + var enumToNumberAttribute = type.GetCustomAttribute(false); + convertToNumber = enumToNumberAttribute.Enabled; + } + else + { + convertToNumber = App.Configuration.GetValue("SpecificationDocumentSettings:EnumToNumber", false); + } + + // 包含中文情况 + if (Enum.GetNames(type).Any(v => Regex.IsMatch(v, CHINESE_PATTERN))) + { + convertToNumber = true; + } + + // 获取枚举实际值类型 + var enumValueType = type.GetField("value__").FieldType; + + foreach (var value in enumValues) + { + var numValue = value.ChangeType(enumValueType); + + // 获取枚举成员特性 + var fieldinfo = type.GetField(Enum.GetName(type, value)); + var descriptionAttribute = fieldinfo.GetCustomAttribute(true); + model.Enum.Add(!convertToNumber + ? new OpenApiString(value.ToString()) + : OpenApiAnyFactory.CreateFromJson($"{numValue}")); + + stringBuilder.Append($" {descriptionAttribute?.Description} {value} = {numValue}
"); + } + model.Description = stringBuilder.ToString(); + + if (!convertToNumber) + { + model.Type = "string"; + model.Format = null; + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/TagsOrderDocumentFilter.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/TagsOrderDocumentFilter.cs new file mode 100644 index 000000000..cfc0d0b6c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Filters/TagsOrderDocumentFilter.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.OpenApi.Models; + +using Swashbuckle.AspNetCore.SwaggerGen; + +using ThingsGateway.DynamicApiController; + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 标签文档排序/注释拦截器 +/// +[SuppressSniffer] +public class TagsOrderDocumentFilter : IDocumentFilter +{ + /// + /// 配置拦截 + /// + /// + /// + public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context) + { + swaggerDoc.Tags = Penetrates.ControllerOrderCollection + .Where(u => SpecificationDocumentBuilder.GetControllerGroups(u.Value.Item3).Any(c => c.Group == context.DocumentName)) + .OrderByDescending(u => u.Value.Item2) + .ThenBy(u => u.Key) + .Select(c => new OpenApiTag + { + Name = c.Value.Item1, + Description = swaggerDoc.Tags.FirstOrDefault(m => m.Name == c.Key)?.Description + }).ToList(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/GroupExtraInfo.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/GroupExtraInfo.cs new file mode 100644 index 000000000..90db41cfa --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/GroupExtraInfo.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 分组附加信息 +/// +[SuppressSniffer] +public sealed class GroupExtraInfo +{ + /// + /// 分组名 + /// + public string Group { get; internal set; } + + /// + /// 分组排序 + /// + public int Order { get; internal set; } + + /// + /// 是否可见 + /// + public bool Visible { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationAuth.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationAuth.cs new file mode 100644 index 000000000..e32b5dfa4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationAuth.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 规范化文档授权参数类型 +/// +[SuppressSniffer] +public sealed class SpecificationAuth +{ + /// + /// 用户名 + /// + public string UserName { get; set; } + + /// + /// 密码 + /// + public string Password { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationLoginInfo.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationLoginInfo.cs new file mode 100644 index 000000000..06e6faa58 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationLoginInfo.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 规范化文档授权登录配置信息 +/// +[SuppressSniffer] +public sealed class SpecificationLoginInfo +{ + /// + /// 是否启用授权控制 + /// + public bool Enabled { get; set; } + + /// + /// 检查登录地址 + /// + public string CheckUrl { get; set; } + + /// + /// 提交登录地址 + /// + public string SubmitUrl { get; set; } + + /// + /// 生产环境自动开启 + /// + public bool EnableOnProduction { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationOpenApiInfo.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationOpenApiInfo.cs new file mode 100644 index 000000000..6944199e8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationOpenApiInfo.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.OpenApi.Models; + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 规范化文档开放接口信息 +/// +[SuppressSniffer] +public sealed class SpecificationOpenApiInfo : OpenApiInfo +{ + /// + /// 构造函数 + /// + public SpecificationOpenApiInfo() + { + Version = "1.0.0"; + } + + /// + /// 分组私有字段 + /// + private string _group; + + /// + /// 所属组 + /// + public string Group + { + get => _group; + set + { + _group = value; + //Title ??= string.Join(' ', _group.SplitCamelCase()); + Title ??= _group; + } + } + + /// + /// 排序 + /// + public int? Order { get; set; } + + /// + /// 是否可见 + /// + public bool? Visible { get; set; } + + /// + /// 路由模板 + /// + public string RouteTemplate { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationOpenApiSecurityRequirementItem.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationOpenApiSecurityRequirementItem.cs new file mode 100644 index 000000000..a5a3fb012 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationOpenApiSecurityRequirementItem.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.OpenApi.Models; + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 安全定义需求子项 +/// +[SuppressSniffer] +public sealed class SpecificationOpenApiSecurityRequirementItem +{ + /// + /// 构造函数 + /// + public SpecificationOpenApiSecurityRequirementItem() + { + Accesses = System.Array.Empty(); + } + + /// + /// 安全Schema + /// + public OpenApiSecurityScheme Scheme { get; set; } + + /// + /// 权限 + /// + public string[] Accesses { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationOpenApiSecurityScheme.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationOpenApiSecurityScheme.cs new file mode 100644 index 000000000..8b18e8986 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Internal/SpecificationOpenApiSecurityScheme.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.OpenApi.Models; + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 规范化文档安全配置 +/// +public sealed class SpecificationOpenApiSecurityScheme : OpenApiSecurityScheme +{ + /// + /// 构造函数 + /// + public SpecificationOpenApiSecurityScheme() + { + } + + /// + /// 唯一Id + /// + public string Id { get; set; } + + /// + /// 安全需求 + /// + public SpecificationOpenApiSecurityRequirementItem Requirement { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/SpecificationDocument/Options/SpecificationDocumentSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Options/SpecificationDocumentSettingsOptions.cs new file mode 100644 index 000000000..2e822a343 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/SpecificationDocument/Options/SpecificationDocumentSettingsOptions.cs @@ -0,0 +1,197 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; +using Microsoft.OpenApi.Models; + +using Swashbuckle.AspNetCore.SwaggerUI; + +using ThingsGateway.ConfigurableOptions; +using ThingsGateway.Reflection; + +namespace ThingsGateway.SpecificationDocument; + +/// +/// 规范化文档配置选项 +/// +public sealed class SpecificationDocumentSettingsOptions : IConfigurableOptions +{ + /// + /// 文档标题 + /// + public string DocumentTitle { get; set; } + + /// + /// 默认分组名 + /// + public string DefaultGroupName { get; set; } + + /// + /// 启用授权支持 + /// + public bool? EnableAuthorized { get; set; } + + /// + /// 格式化为V2版本 + /// + public bool? FormatAsV2 { get; set; } + + /// + /// 配置规范化文档地址 + /// + public string RoutePrefix { get; set; } + + /// + /// 文档展开设置 + /// + public DocExpansion? DocExpansionState { get; set; } + + /// + /// XML 描述文件 + /// + public string[] XmlComments { get; set; } + + /// + /// 是否自动加载 Xml 注释文件 + /// + public bool? EnableXmlComments { get; set; } + + /// + /// 分组信息 + /// + public SpecificationOpenApiInfo[] GroupOpenApiInfos { get; set; } + + /// + /// 安全定义 + /// + public SpecificationOpenApiSecurityScheme[] SecurityDefinitions { get; set; } + + /// + /// 配置 Servers + /// + public OpenApiServer[] Servers { get; set; } + + /// + /// 隐藏 Servers + /// + public bool? HideServers { get; set; } + + /// + /// 默认 swagger.json 路由模板 + /// + public string RouteTemplate { get; set; } + + /// + /// 配置安装第三方包的分组名 + /// + public string[] PackagesGroups { get; set; } + + /// + /// 启用枚举 Schema 筛选器 + /// + public bool? EnableEnumSchemaFilter { get; set; } + + /// + /// 启用标签排序筛选器 + /// + public bool? EnableTagsOrderDocumentFilter { get; set; } + + /// + /// 服务目录(修正 IIS 创建 Application 问题) + /// + public string ServerDir { get; set; } + + /// + /// 配置规范化文档登录信息 + /// + public SpecificationLoginInfo LoginInfo { get; set; } + + /// + /// 启用 All Groups 功能 + /// + public bool? EnableAllGroups { get; set; } + + /// + /// 枚举类型生成值类型 + /// + public bool? EnumToNumber { get; set; } + + /// + /// 后期配置 + /// + /// + /// + public void PostConfigure(SpecificationDocumentSettingsOptions options, IConfiguration configuration) + { + options.DocumentTitle ??= "Specification Api Document"; + options.DefaultGroupName ??= "Default"; + options.FormatAsV2 ??= false; + //options.RoutePrefix ??= "api"; // 可以通过 UseInject() 配置,所以注释 + options.DocExpansionState ??= DocExpansion.List; + + // 加载项目注册和模块化/插件注释 + EnableXmlComments ??= true; + if (EnableXmlComments == true) + { + var frameworkPackageName = Reflect.GetAssemblyName(GetType()); + var projectXmlComments = App.Assemblies.Where(u => u.GetName().Name != frameworkPackageName).Select(t => t.GetName().Name); + var externalXmlComments = App.ExternalAssemblies.Any() ? App.PathOfExternalAssemblies.Select(u => u.EndsWith(".dll") ? u[0..^4] : u) : Array.Empty(); + XmlComments ??= projectXmlComments.Concat(externalXmlComments).ToArray(); + } + + GroupOpenApiInfos ??= new SpecificationOpenApiInfo[] + { + new SpecificationOpenApiInfo() + { + Group=options.DefaultGroupName + } + }; + + EnableAuthorized ??= true; + if (EnableAuthorized == true) + { + SecurityDefinitions ??= new SpecificationOpenApiSecurityScheme[] + { + new SpecificationOpenApiSecurityScheme + { + Id="Bearer", + Type= SecuritySchemeType.Http, + Name="Authorization", + Description="JWT Authorization header using the Bearer scheme.", + BearerFormat="JWT", + Scheme="bearer", + In= ParameterLocation.Header, + Requirement=new SpecificationOpenApiSecurityRequirementItem + { + Scheme=new OpenApiSecurityScheme + { + Reference=new OpenApiReference + { + Id="Bearer", + Type= ReferenceType.SecurityScheme + } + }, + Accesses=Array.Empty() + } + } + }; + } + + Servers ??= Array.Empty(); + HideServers ??= true; + RouteTemplate ??= "swagger/{documentName}/swagger.json"; + PackagesGroups ??= Array.Empty(); + EnableEnumSchemaFilter ??= true; + EnableTagsOrderDocumentFilter ??= true; + EnableAllGroups ??= false; + EnumToNumber ??= false; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Templates/Extensions/StringRenderExtensions.cs b/src/Admin/ThingsGateway.Furion/Templates/Extensions/StringRenderExtensions.cs new file mode 100644 index 000000000..8feb81ae0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Templates/Extensions/StringRenderExtensions.cs @@ -0,0 +1,168 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.RegularExpressions; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.Templates.Extensions; + +/// +/// 字符串渲染模板拓展类 +/// +[SuppressSniffer] +public static class StringRenderExtensions +{ + /// + /// 模板正则表达式 + /// + private const string commonTemplatePattern = @"\{(?

.+?)\}"; + + ///

+ /// 读取配置模板正则表达式 + /// + private const string configTemplatePattern = @"\#\((?

.*?)\)"; + + ///

+ /// 渲染模板 + /// + /// + /// + /// + /// + public static string Render(this string template, object templateData, bool encode = false) + { + if (template == null) return default; + + return template.Render(templateData == null ? default : templateData.ObjectToDictionary().ToDictionary(u => u.Key.ToString(), u => u.Value), encode); + } + + + /// + /// 渲染模板 + /// + /// + /// + /// + /// + public static string Render(this string template, IDictionary templateData, bool encode = false) + { + if (template == null) return default; + + // 如果模板为空,则跳过 + if (templateData == null || templateData.Count == 0) return template; + + // 判断字符串是否包含模板 + if (!Regex.IsMatch(template, commonTemplatePattern)) return template; + + // 获取所有匹配的模板 + var templateValues = Regex.Matches(template, commonTemplatePattern) + .Select(u => new + { + Template = u.Groups["p"].Value, + Value = MatchTemplateValue(u.Groups["p"].Value, templateData) + }); + + // 循环替换模板 + foreach (var item in templateValues) + { + template = template.Replace($"{{{item.Template}}}", encode ? Uri.EscapeDataString(item.Value?.ToString() ?? string.Empty) : item.Value?.ToString()); + } + + return template; + } + + /// + /// 从配置中渲染字符串模板 + /// + /// + /// + /// + public static string Render(this string template, bool encode = false) + { + if (template == null) return default; + + // 判断字符串是否包含模板 + if (!Regex.IsMatch(template, configTemplatePattern)) return template; + + // 获取所有匹配的模板 + var templateValues = Regex.Matches(template, configTemplatePattern) + .Select(u => new + { + Template = u.Groups["p"].Value, + Value = App.Configuration[u.Groups["p"].Value] + }); + + // 循环替换模板 + foreach (var item in templateValues) + { + template = template.Replace($"#({item.Template})", encode ? Uri.EscapeDataString(item.Value?.ToString() ?? string.Empty) : item.Value?.ToString()); + } + + return template; + } + + /// + /// 匹配模板值 + /// + /// + /// + /// + private static object MatchTemplateValue(string template, IDictionary templateData) + { + string tmpl; + if (!template.Contains('.', StringComparison.CurrentCulture)) tmpl = template; + else tmpl = template.Split('.', StringSplitOptions.RemoveEmptyEntries).First(); + + var succeed = templateData.TryGetValue(tmpl, out var templateValue); + return ResolveTemplateValue(template, succeed ? templateValue : default); + } + + /// + /// 解析模板的值 + /// + /// + /// + /// + private static object ResolveTemplateValue(string template, object data) + { + // 根据 . 分割模板 + var propertyCrumbs = template.Split('.', StringSplitOptions.RemoveEmptyEntries); + return GetValue(propertyCrumbs, data); + + // 静态本地函数 + static object GetValue(string[] propertyCrumbs, object data) + { + if (data == null || propertyCrumbs == null || propertyCrumbs.Length <= 1) return data; + var dataType = data.GetType(); + + // 如果是基元类型则直接返回 + if (dataType.IsRichPrimitive()) return data; + object value = null; + + // 递归获取下一级模板值 + for (var i = 1; i < propertyCrumbs.Length; i++) + { + var propery = dataType.GetProperty(propertyCrumbs[i]); + if (propery == null) break; + + value = propery.GetValue(data); + if (i + 1 < propertyCrumbs.Length) + { + value = GetValue(propertyCrumbs.Skip(i).ToArray(), value); + } + else break; + } + + return value; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/Templates/TP.cs b/src/Admin/ThingsGateway.Furion/Templates/TP.cs new file mode 100644 index 000000000..c65c9ddf9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/Templates/TP.cs @@ -0,0 +1,171 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text; +using System.Text.RegularExpressions; + +namespace ThingsGateway.Templates; + +/// +/// 模板静态类 +/// +[SuppressSniffer] +public static class TP +{ + /// + /// 模板正则表达式对象 + /// + private static readonly Lazy _lazyRegex = new(() => new(@"^##(?.*)?##[::]?\s*(?[\s\S]*)")); + + /// + /// 生成规范日志模板 + /// + /// 标题 + /// 描述 + /// 列表项,如果以 ##xxx## 开头,自动生成 xxx: 属性 + /// + public static string Wrapper(string title, string description, params string[] items) + { + // 处理不同编码问题 + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + var stringBuilder = new StringBuilder(); + stringBuilder.Append($"┏━━━━━━━━━━━ {title} ━━━━━━━━━━━").AppendLine(); + + // 添加描述 + if (!string.IsNullOrWhiteSpace(description)) + { + stringBuilder.Append($"┣ {description}").AppendLine().Append("┣ ").AppendLine(); + } + + // 添加项 + if (items != null && items.Length > 0) + { + var propMaxLength = items.Where(u => _lazyRegex.Value.IsMatch(u)) + .DefaultIfEmpty(string.Empty) + .Max(u => _lazyRegex.Value.Match(u).Groups["prop"].Value.Length); + + // 控制项名称对齐空白占位数 + propMaxLength += (propMaxLength >= 5 ? 10 : 5); + + // 遍历每一项并进行正则表达式匹配 + for (var i = 0; i < items.Length; i++) + { + var item = items[i]; + + // 判断是否匹配 ##xxx## + if (_lazyRegex.Value.IsMatch(item)) + { + var match = _lazyRegex.Value.Match(item); + var prop = match.Groups["prop"].Value; + var content = match.Groups["content"].Value; + + var propTitle = $"{prop}:"; + stringBuilder.Append($"┣ {PadRight(propTitle, propMaxLength)}{content}").AppendLine(); + } + else + { + stringBuilder.Append($"┣ {item}").AppendLine(); + } + } + } + + stringBuilder.Append($"┗━━━━━━━━━━━ {title} ━━━━━━━━━━━"); + return stringBuilder.ToString(); + } + + /// + /// 矩形包裹 + /// + /// 多行消息 + /// 对齐方式,-1/左对齐;0/居中对其;1/右对齐 + /// 间隙 + /// + public static string WrapperRectangle(string[] lines, int align = 0, int pad = 20) + { + // 处理不同编码问题 + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + // 计算矩形框的宽度,取所有字符串中最长的长度,再乘以 2 + var width = lines.Max(GetLength) + pad; + + var stringBuilder = new StringBuilder(); + + // 添加矩形框的上边框 + stringBuilder.AppendLine("+" + new string('-', width - 2) + "+"); + + var row = 0; + foreach (var line in lines) + { + // 当前字符串的长度 + var len = GetLength(line); + var padding = align switch + { + -1 => 2, + 0 => (width - len - 2) / 2, + 1 => (width - len - 2) - 2, + _ => 2 + }; + + // 添加当前字符串前的空格,实现居中显示 + stringBuilder.Append("|" + new string(' ', padding)); + + // 添加当前字符串 + stringBuilder.Append(line); + + // 添加当前字符串后的空格,实现等宽 + stringBuilder.Append(new string(' ', width - len - 2 - padding) + "|"); + + // 添加换行符 + stringBuilder.AppendLine(); + + // 更新当前行数 + row++; + } + + // 添加矩形框的下边框 + stringBuilder.Append("+" + new string('-', width - 2) + "+"); + + return stringBuilder.ToString(); + } + + /// + /// 等宽文字对齐 + /// + /// + /// + /// + private static string PadRight(string str, int totalByteCount) + { + var coding = Encoding.GetEncoding("gbk"); + var dcount = 0; + + foreach (var character in str.ToCharArray()) + { + if (coding.GetByteCount(character.ToString()) == 2) + dcount++; + } + + var w = str.PadRight(totalByteCount - dcount); + return w; + } + + /// + /// 获取字符串长度 + /// + /// 字符串 + /// 字符串长度 + public static int GetLength(string str) + { + var coding = Encoding.GetEncoding("gbk"); + return coding.GetByteCount(str); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/ThingsGateway.Furion.csproj b/src/Admin/ThingsGateway.Furion/ThingsGateway.Furion.csproj new file mode 100644 index 000000000..05f8be9a5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/ThingsGateway.Furion.csproj @@ -0,0 +1,68 @@ + + + + + + + net9.0;net8.0; + + + + + true + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Constants/Constants.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Constants/Constants.cs new file mode 100644 index 000000000..a4171de44 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Constants/Constants.cs @@ -0,0 +1,105 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// TimeCrontab 模块常量 +/// +internal static class Constants +{ + /// + /// Cron 字段种类最大值 + /// + internal static readonly Dictionary MaximumDateTimeValues = new() + { + { CrontabFieldKind.Second, 59 }, + { CrontabFieldKind.Minute, 59 }, + { CrontabFieldKind.Hour, 23 }, + { CrontabFieldKind.DayOfWeek, 7 }, + { CrontabFieldKind.Day, 31 }, + { CrontabFieldKind.Month, 12 }, + { CrontabFieldKind.Year, 9999 }, + }; + + /// + /// Cron 字段种类最大值 + /// + internal static readonly Dictionary MinimumDateTimeValues = new() + { + { CrontabFieldKind.Second, 0 }, + { CrontabFieldKind.Minute, 0 }, + { CrontabFieldKind.Hour, 0 }, + { CrontabFieldKind.DayOfWeek, 0 }, + { CrontabFieldKind.Day, 1 }, + { CrontabFieldKind.Month, 1 }, + { CrontabFieldKind.Year, 1 }, + }; + + /// + /// Cron 不同格式化类型字段数量 + /// + internal static readonly Dictionary ExpectedFieldCounts = new() + { + { CronStringFormat.Default, 5 }, + { CronStringFormat.WithYears, 6 }, + { CronStringFormat.WithSeconds, 6 }, + { CronStringFormat.WithSecondsAndYears, 7 }, + }; + + /// + /// 配置 C# 中 枚举元素值 + /// + /// 主要解决 C# 中该类型和 Cron 星期字段域不对应问题 + internal static readonly Dictionary CronDays = new() + { + { DayOfWeek.Sunday, 0 }, + { DayOfWeek.Monday, 1 }, + { DayOfWeek.Tuesday, 2 }, + { DayOfWeek.Wednesday, 3 }, + { DayOfWeek.Thursday, 4 }, + { DayOfWeek.Friday, 5 }, + { DayOfWeek.Saturday, 6 }, + }; + + /// + /// 定义 Cron 星期字段域值支持的星期英文缩写 + /// + internal static readonly Dictionary Days = new() + { + { "SUN", 0 }, + { "MON", 1 }, + { "TUE", 2 }, + { "WED", 3 }, + { "THU", 4 }, + { "FRI", 5 }, + { "SAT", 6 }, + }; + + /// + /// 定义 Cron 月字段域值支持的星期英文缩写 + /// + internal static readonly Dictionary Months = new() + { + { "JAN", 1 }, + { "FEB", 2 }, + { "MAR", 3 }, + { "APR", 4 }, + { "MAY", 5 }, + { "JUN", 6 }, + { "JUL", 7 }, + { "AUG", 8 }, + { "SEP", 9 }, + { "OCT", 10 }, + { "NOV", 11 }, + { "DEC", 12 }, + }; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Constants/CronFieldKind.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Constants/CronFieldKind.cs new file mode 100644 index 000000000..c9e3af5c9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Constants/CronFieldKind.cs @@ -0,0 +1,53 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段种类 +/// +internal enum CrontabFieldKind +{ + /// + /// 秒 + /// + Second = 0, + + /// + /// 分 + /// + Minute = 1, + + /// + /// 时 + /// + Hour = 2, + + /// + /// 天 + /// + Day = 3, + + /// + /// 月 + /// + Month = 4, + + /// + /// 星期 + /// + DayOfWeek = 5, + + /// + /// 年 + /// + Year = 6 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Constants/CronStringFormat.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Constants/CronStringFormat.cs new file mode 100644 index 000000000..e5fc207f1 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Constants/CronStringFormat.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 表达式格式化类型 +/// +[SuppressSniffer] +public enum CronStringFormat +{ + /// + /// 默认格式 + /// + /// 书写顺序:分 时 天 月 周 + Default = 0, + + /// + /// 带年份格式 + /// + /// 书写顺序:分 时 天 月 周 年 + WithYears = 1, + + /// + /// 带秒格式 + /// + /// 书写顺序:秒 分 时 天 月 周 + WithSeconds = 2, + + /// + /// 带秒和年格式 + /// + /// 书写顺序:秒 分 时 天 月 周 年 + WithSecondsAndYears = 3 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.Internal.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.Internal.cs new file mode 100644 index 000000000..50a993695 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.Internal.cs @@ -0,0 +1,671 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 表达式抽象类 +/// +/// 主要将 Cron 表达式转换成 OOP 类进行操作 +public partial class Crontab +{ + private static readonly char[] Separator = new[] { ' ' }; + /// + /// 解析 Cron 表达式字段并存储其 所有发生值 字符解析器 + /// + /// Cron 表达式 + /// Cron 表达式格式化类型 + /// + /// + private static Dictionary> ParseToDictionary(string expression, CronStringFormat format) + { + // Cron 表达式空检查 + if (string.IsNullOrWhiteSpace(expression)) + { + throw new TimeCrontabException("The provided cron string is null, empty or contains only whitespace."); + } + + // 通过空白符切割 Cron 表达式每个字段域 + var instructions = expression.Split(Separator, StringSplitOptions.RemoveEmptyEntries); + + // 验证当前 Cron 格式化类型字段数量和表达式字段数量是否一致 + var expectedCount = Constants.ExpectedFieldCounts[format]; + if (instructions.Length > expectedCount) + { + throw new TimeCrontabException(string.Format("The provided cron string <{0}> has too many parameters.", expression)); + } + if (instructions.Length < expectedCount) + { + throw new TimeCrontabException(string.Format("The provided cron string <{0}> has too few parameters.", expression)); + } + + // 初始化字段偏移量和字段字符解析器 + var defaultFieldOffset = 0; + var fieldParsers = new Dictionary>(); + + // 判断当前 Cron 格式化类型是否包含秒字段域,如果包含则优先解析秒字段域字符解析器 + if (format == CronStringFormat.WithSeconds || format == CronStringFormat.WithSecondsAndYears) + { + fieldParsers.Add(CrontabFieldKind.Second, ParseField(instructions[0], CrontabFieldKind.Second)); + defaultFieldOffset = 1; + } + + // Cron 常规字段域 + fieldParsers.Add(CrontabFieldKind.Minute, ParseField(instructions[defaultFieldOffset + 0], CrontabFieldKind.Minute)); // 偏移量 1 + fieldParsers.Add(CrontabFieldKind.Hour, ParseField(instructions[defaultFieldOffset + 1], CrontabFieldKind.Hour)); // 偏移量 2 + fieldParsers.Add(CrontabFieldKind.Day, ParseField(instructions[defaultFieldOffset + 2], CrontabFieldKind.Day)); // 偏移量 3 + fieldParsers.Add(CrontabFieldKind.Month, ParseField(instructions[defaultFieldOffset + 3], CrontabFieldKind.Month)); // 偏移量 4 + fieldParsers.Add(CrontabFieldKind.DayOfWeek, ParseField(instructions[defaultFieldOffset + 4], CrontabFieldKind.DayOfWeek)); // 偏移量 5 + + // 判断当前 Cron 格式化类型是否包含年字段域,如果包含则解析年字段域字符解析器 + if (format == CronStringFormat.WithYears || format == CronStringFormat.WithSecondsAndYears) + { + fieldParsers.Add(CrontabFieldKind.Year, ParseField(instructions[defaultFieldOffset + 5], CrontabFieldKind.Year)); // 偏移量 6 + } + + // 检查非法字符解析器,如 2 月没有 30 和 31 号 + CheckForIllegalParsers(fieldParsers); + + return fieldParsers; + } + + /// + /// 解析 Cron 单个字段域所有发生值 字符解析器 + /// + /// 字段值 + /// Cron 表达式格式化类型 + /// + /// + private static List ParseField(string field, CrontabFieldKind kind) + { + /* + * 在 Cron 表达式中,单个字段域值也支持定义多个值(我们称为值中值),如 1,2,3 或 SUN,FRI,SAT + * 所以,这里需要将字段域值通过 , 进行切割后独立处理 + */ + + try + { + return field.Split(',').Select(parser => ParseParser(parser, kind)).ToList(); + } + catch (Exception ex) + { + throw new TimeCrontabException( + string.Format("There was an error parsing '{0}' for the {1} field.", field, Enum.GetName(typeof(CrontabFieldKind), kind)) + , ex); + } + } + + /// + /// 解析 Cron 字段域值中值 + /// + /// 字段值中值 + /// Cron 表达式格式化类型 + /// + /// + private static ICronParser ParseParser(string parser, CrontabFieldKind kind) + { + // Cron 字段中所有字母均采用大写方式,所以需要转换所有为大写再操作 + var newParser = parser.ToUpper(); + + try + { + // 判断值是否以 * 字符开头 + if (newParser.StartsWith('*')) + { + // 继续往后解析 + newParser = newParser[1..]; + + // 判断是否以 / 字符开头,如果是,则该值为带步长的 Cron 值 + if (newParser.StartsWith('/')) + { + // 继续往后解析 + newParser = newParser[1..]; + + // 解析 Cron 值步长并创建 StepParser 解析器 + var steps = GetValue(ref newParser, kind); + return new StepParser(0, steps, kind); + } + + // 处理 * 携带意外值 + if (!string.IsNullOrEmpty(newParser)) + { + throw new TimeCrontabException(string.Format("Invalid parser '{0}'.", parser)); + } + + // 否则,创建 AnyParser 解析器 + return new AnyParser(kind); + } + + // 判断值是否以 L 字符开头 + if (newParser.StartsWith('L') && kind == CrontabFieldKind.Day) + { + // 继续往后解析 + newParser = newParser[1..]; + + // 是否是 LW 字符,如果是,创建 LastWeekdayOfMonthParser 解析器 + if (newParser == "W") + { + return new LastWeekdayOfMonthParser(kind); + } + // 否则创建 LastDayOfMonthParser 解析器 + else + { + return new LastDayOfMonthParser(kind); + } + } + + // 判断值是否等于 R + if (newParser == "R") + { + return new RandomParser(kind); + } + + // 判断值是否等于 ? + if (newParser == "?") + { + // 创建 BlankDayOfMonthOrWeekParser 解析器 + return new BlankDayOfMonthOrWeekParser(kind); + } + + /* + * 如果上面均不匹配,那么该值类似取值有:2,1/2,1-10,1-10/2,SUN,SUNDAY,SUNL,JAN,3W,3L,2#5 等 + */ + + // 继续推进解析 + var firstValue = GetValue(ref newParser, kind); + + // 如果没有返回新的待解析字符,则认为这是一个具体值 + if (string.IsNullOrEmpty(newParser)) + { + // 对年份进行特别处理 + if (kind == CrontabFieldKind.Year) + { + return new SpecificYearParser(firstValue, kind); + } + else + { + // 创建 SpecificParser 解析器 + return new SpecificParser(firstValue, kind); + } + } + + // 如果存在待解析字符,如 - / # L W 值,则进一步解析 + switch (newParser[0]) + { + // 判断值是否以 / 字符开头 + case '/': + { + // 继续往后解析 + newParser = newParser[1..]; + + // 解析 Cron 值步长并创建 StepParser 解析器 + var steps = GetValue(ref newParser, kind); + return new StepParser(firstValue, steps, kind); + } + // 判断值是否以 - 字符开头 + case '-': + { + // 继续往后解析 + newParser = newParser[1..]; + + // 获取范围结束值 + var endValue = GetValue(ref newParser, kind); + int? steps = null; + + // 继续推进解析,判断是否以 / 开头,如果是,则获取步长 + if (newParser.StartsWith('/')) + { + newParser = newParser[1..]; + steps = GetValue(ref newParser, kind); + } + + // 创建 RangeParser 解析器 + return new RangeParser(firstValue, endValue, steps, kind); + } + // 判断值是否以 # 字符开头 + case '#': + { + // 继续往后解析 + newParser = newParser[1..]; + + // 获取第几个 + var weekNumber = GetValue(ref newParser, kind); + + // 继续推进解析,如果存在其他字符,则抛异常 + if (!string.IsNullOrEmpty(newParser)) + { + throw new TimeCrontabException(string.Format("Invalid parser '{0}.'", parser)); + } + + // 创建 SpecificDayOfWeekInMonthParser 解析器 + return new SpecificDayOfWeekInMonthParser(firstValue, weekNumber, kind); + } + // 判断解析值是否等于 L 或 W + default: + // 创建 LastDayOfWeekInMonthParser 解析器 + if (newParser == "L" && kind == CrontabFieldKind.DayOfWeek) + { + return new LastDayOfWeekInMonthParser(firstValue, kind); + } + // 创建 NearestWeekdayParser 解析器 + else if (newParser == "W" && kind == CrontabFieldKind.Day) + { + return new NearestWeekdayParser(firstValue, kind); + } + break; + } + + throw new TimeCrontabException(string.Format("Invalid parser '{0}'.", parser)); + } + catch (Exception ex) + { + throw new TimeCrontabException(string.Format("Invalid parser '{0}'. See inner exception for details.", parser), ex); + } + } + + /// + /// 将 Cron 字段值中值进一步解析 + /// + /// 当前解析值 + /// Cron 表达式格式化类型 + /// + /// + private static int GetValue(ref string parser, CrontabFieldKind kind) + { + // 值空检查 + if (string.IsNullOrEmpty(parser)) + { + throw new TimeCrontabException("Expected number, but parser was empty."); + } + + // 字符偏移量 + int offset; + + // 判断首个字符是数字还是字符串 + var isDigit = char.IsDigit(parser[0]); + var isLetter = char.IsLetter(parser[0]); + + // 推进式遍历值并检查每一个字符,一旦出现类型不连贯则停止检查 + for (offset = 0; offset < parser.Length; offset++) + { + // 如果存在不连贯数字或字母则跳出循环 + if ((isDigit && !char.IsDigit(parser[offset])) || (isLetter && !char.IsLetter(parser[offset]))) + { + break; + } + } + + var maximum = Constants.MaximumDateTimeValues[kind]; + + // 前面连贯类型的值 + var valueToParse = parser[..offset]; + + // 处理数字开头的连贯类型值 + if (int.TryParse(valueToParse, out var value)) + { + // 导出下一轮待解析的值(依旧采用推进式) + parser = parser[offset..]; + + var returnValue = value; + + // 验证值范围 + if (returnValue > maximum) + { + throw new TimeCrontabException(string.Format("Value for {0} parser exceeded maximum value of {1}.", Enum.GetName(typeof(CrontabFieldKind), kind), maximum)); + } + + return returnValue; + } + // 处理字母开头的连贯类型值,通常认为这是一个单词,如SUN,JAN + else + { + List> replaceVal = null; + + // 判断当前 Cron 字段类型是否是星期,如果是,则查找该单词是否在 Constants.Days 定义之中 + if (kind == CrontabFieldKind.DayOfWeek) + { + replaceVal = Constants.Days.Where(x => valueToParse.StartsWith(x.Key)).ToList(); + } + // 判断当前 Cron 字段类型是否是月份,如果是,则查找该单词是否在 Constants.Months 定义之中 + else if (kind == CrontabFieldKind.Month) + { + replaceVal = Constants.Months.Where(x => valueToParse.StartsWith(x.Key)).ToList(); + } + + // 如果存在且唯一,则进入下一轮判断 + // 接下来的判断是处理 SUN + L 的情况,如 SUNL == 0L == SUNDAY,它们都是合法的 Cron 值 + if (replaceVal != null && replaceVal.Count == 1) + { + var missingParser = ""; + + // 处理带 L 和不带 L 的单词问题 + if (parser.Length == offset + && parser.EndsWith('L') + && kind == CrontabFieldKind.DayOfWeek) + { + missingParser = "L"; + } + parser = parser[offset..] + missingParser; + + // 转换成 int 值返回(SUN,JAN.....) + var returnValue = replaceVal.First().Value; + + // 验证值范围 + if (returnValue > maximum) + { + throw new TimeCrontabException(string.Format("Value for {0} parser exceeded maximum value of {1}.", Enum.GetName(typeof(CrontabFieldKind), kind), maximum)); + } + + return returnValue; + } + } + + throw new TimeCrontabException("Parser does not contain expected number."); + } + + /// + /// 检查非法字符解析器,如 2 月没有 30 和 31 号 + /// + /// 检查 2 月份是否存在 30 和 31 天的非法数值解析器 + /// Cron 字段解析器字典集合 + /// + private static void CheckForIllegalParsers(Dictionary> parsers) + { + // 获取当前 Cron 表达式月字段和天字段所有数值 + var monthSingle = GetSpecificParsers(parsers, CrontabFieldKind.Month); + var daySingle = GetSpecificParsers(parsers, CrontabFieldKind.Day); + + // 如果月份为 2 月单天数出现 30 和 31 天,则是无效数值 + if (monthSingle.Count > 0 && monthSingle.All(x => x.SpecificValue == 2)) + { + if (daySingle.Count > 0 && daySingle.All(x => (x.SpecificValue == 30) || (x.SpecificValue == 31))) + { + throw new TimeCrontabException("The February 30 and 31 don't exist."); + } + } + } + + /// + /// 查找 Cron 字段类型所有具体值解析器 + /// + /// Cron 字段解析器字典集合 + /// Cron 字段种类 + /// + private static List GetSpecificParsers(Dictionary> parsers, CrontabFieldKind kind) + { + var kindParsers = parsers[kind]; + + // 查找 Cron 字段类型所有具体值解析器 + return kindParsers.Where(x => x.GetType() == typeof(SpecificParser)).Cast() + .Union( + kindParsers.Where(x => x.GetType() == typeof(RangeParser)).SelectMany(x => ((RangeParser)x).SpecificParsers) + ).Union( + kindParsers.Where(x => x.GetType() == typeof(StepParser)).SelectMany(x => ((StepParser)x).SpecificParsers) + ).ToList(); + } + + /// + /// 获取特定时间范围下一个发生时间 + /// + /// 起始时间 + /// 结束时间 + /// + private DateTime InternalGetNextOccurence(DateTime baseTime, DateTime endTime) + { + // 判断当前 Cron 格式化类型是否支持秒 + var isSecondFormat = Format == CronStringFormat.WithSeconds || Format == CronStringFormat.WithSecondsAndYears; + + // 由于 Cron 格式化类型不包含毫秒,则裁剪掉毫秒部分 + var newValue = baseTime; + newValue = newValue.AddMilliseconds(-newValue.Millisecond); + + // 如果当前 Cron 格式化类型不支持秒,则裁剪掉秒部分 + if (!isSecondFormat) + { + newValue = newValue.AddSeconds(-newValue.Second); + } + + // 获取分钟、小时所有字符解析器 + var minuteParsers = Parsers[CrontabFieldKind.Minute].Where(x => x is ITimeParser).Cast().ToList(); + var hourParsers = Parsers[CrontabFieldKind.Hour].Where(x => x is ITimeParser).Cast().ToList(); + + // 获取秒、分钟、小时解析器中最小起始值 + // 该值主要用来获取下一个发生值的输入参数 + var firstSecondValue = newValue.Second; + var firstMinuteValue = minuteParsers.Select(x => x.First()).Min(); + var firstHourValue = hourParsers.Select(x => x.First()).Min(); + + // 定义一个标识,标识当前时间下一个发生时间值是否进入新一轮循环 + // 如:如果当前时间的秒数为 59,那么下一个秒数应该为 00,那么当前时间分钟就应该 +1 + // 以此类推,如果 +1 后分钟数为 59,那么下一个分钟数也应该为 00,那么当前时间小时数就应该 +1 + // .... + var overflow = true; + + // 处理 Cron 格式化类型包含秒的情况 ================================================================= + var newSeconds = newValue.Second; + if (isSecondFormat) + { + // 获取秒所有字符解析器 + var secondParsers = Parsers[CrontabFieldKind.Second].Where(x => x is ITimeParser).Cast().ToList(); + + // 获取秒解析器最小起始值 + firstSecondValue = secondParsers.Select(x => x.First()).Min(); + + // 获取秒下一个发生值 + newSeconds = Increment(secondParsers, newValue.Second, firstSecondValue, out overflow); + + // 设置起始时间为下一个秒时间 + newValue = new DateTime(newValue.Year, newValue.Month, newValue.Day, newValue.Hour, newValue.Minute, newSeconds); + + // 如果当前秒并没有进入下一轮循环但存在不匹配的字符过滤器 + if (!overflow && !IsMatch(newValue)) + { + // 重置秒为起始值并标记 overflow 为 true 进入新一轮循环 + newSeconds = firstSecondValue; + + // 此时计算时间秒部分应该为起始值 + // 如 22:10:59 -> 22:10:00 + newValue = new DateTime(newValue.Year, newValue.Month, newValue.Day, newValue.Hour, newValue.Minute, newSeconds); + + // 标记进入下一轮循环 + overflow = true; + } + + // 如果程序到达这里,说明并没有进入上面分支,则直接返回下一秒时间 + if (!overflow) + { + return MinDate(newValue, endTime); + } + } + + // 程序到达这里,说明秒部分已经标识进入新一轮循环,那么分支就应该获取下一个分钟发生值 ================================================================= + var newMinutes = Increment(minuteParsers, newValue.Minute + (overflow ? 0 : -1), firstMinuteValue, out overflow); + + // 设置起始时间为下一个分钟时间 + newValue = new DateTime(newValue.Year, newValue.Month, newValue.Day, newValue.Hour, newMinutes, overflow ? firstSecondValue : newSeconds); + + // 如果当前分钟并没有进入下一轮循环但存在不匹配的字符过滤器 + if (!overflow && !IsMatch(newValue)) + { + // 重置秒,分钟为起始值并标记 overflow 为 true 进入新一轮循环 + newSeconds = firstSecondValue; + newMinutes = firstMinuteValue; + + // 此时计算时间秒和分钟部分应该为起始值 + // 如 22:59:59 -> 22:00:00 + newValue = new DateTime(newValue.Year, newValue.Month, newValue.Day, newValue.Hour, newMinutes, firstSecondValue); + + // 标记进入下一轮循环 + overflow = true; + } + + // 如果程序到达这里,说明并没有进入上面分支,则直接返回下一分钟时间 + if (!overflow) + { + return MinDate(newValue, endTime); + } + + // 程序到达这里,说明分钟部分已经标识进入新一轮循环,那么分支就应该获取下一个小时发生值 ================================================================= + var newHours = Increment(hourParsers, newValue.Hour + (overflow ? 0 : -1), firstHourValue, out overflow); + + // 设置起始时间为下一个小时时间 + newValue = new DateTime(newValue.Year, newValue.Month, newValue.Day, newHours, + overflow ? firstMinuteValue : newMinutes, + overflow ? firstSecondValue : newSeconds); + + // 如果当前小时并没有进入下一轮循环但存在不匹配的字符过滤器 + if (!overflow && !IsMatch(newValue)) + { + // 此时计算时间秒,分钟和小时部分应该为起始值 + // 如 23:59:59 -> 23:00:00 + newValue = new DateTime(newValue.Year, newValue.Month, newValue.Day, firstHourValue, firstMinuteValue, firstSecondValue); + + // 标记进入下一轮循环 + overflow = true; + } + + // 如果程序到达这里,说明并没有进入上面分支,则直接返回下一小时时间 + if (!overflow) + { + return MinDate(newValue, endTime); + } + + // 如果程序达到这里,说明天数变了(一旦天数变了,那么月份可能也变了,星期可能也变了,年份也可能变了) + // 所以这里的计算最为复杂 + List yearParsers = null; + + // 首先先判断当前 Cron 格式化类型是否支持年份 + var isYearFormat = Format == CronStringFormat.WithYears || Format == CronStringFormat.WithSecondsAndYears; + + // 如果支持,读取年份字符过滤器 + if (isYearFormat) + { + yearParsers = Parsers[CrontabFieldKind.Year].Where(x => x is ITimeParser).Cast().ToList(); + } + + // 程序能够执行到这里,那么说明时间已经是 23:59:59,所以起始时间追加 1 天 + // 这里的代码看起来很奇怪,但是是为了处理终止时间为 12/31/9999 23:59:59.999 的情况,也就是世界末日了~~~ + try + { + newValue = newValue.AddDays(1); + } + catch + { + return endTime; + } + + // 在有效的年份时间内死循环至天、周、月、年全部匹配才终止循环 + while (!(IsMatch(newValue, CrontabFieldKind.Day) + && IsMatch(newValue, CrontabFieldKind.DayOfWeek) + && IsMatch(newValue, CrontabFieldKind.Month) + && (!isYearFormat || IsMatch(newValue, CrontabFieldKind.Year)))) + { + // 如果当前匹配到的时间已经大于或等于终止时间,则直接返回 + if (newValue >= endTime) + { + return MinDate(newValue, endTime); + } + + // 如果 Cron 年份字段域获取下一个发生值为 null,那么直接返回 终止时间 + // 也就是已经没有匹配项了 + if (isYearFormat && yearParsers!.Select(x => x.Next(newValue.Year - 1)).All(x => x == null)) + { + return endTime; + } + + // 同样防止终止时间为 12/31/9999 23:59:59.999 的情况 + try + { + // 不断增加 1 天直至匹配成功 + newValue = newValue.AddDays(1); + } + catch + { + return endTime; + } + } + + return MinDate(newValue, endTime); + } + + /// + /// 获取当前时间解析器下一个发生值 + /// + /// 解析器 + /// 当前值 + /// 默认值 + /// 控制秒、分钟、小时到达59秒/分和23小时开关 + /// + private static int Increment(IEnumerable parsers, int value, int defaultValue, out bool overflow) + { + var nextValue = parsers.Select(x => x.Next(value)) + .Where(x => x > value) + .Min() + ?? defaultValue; + + // 如果此时秒或分钟或23到达最大值,则应该返回起始值 + overflow = nextValue <= value; + + return nextValue; + } + + /// + /// 处理下一个发生时间边界值 + /// + /// 如果发生时间大于终止时间,则返回终止时间,否则返回发生时间 + /// 下一个发生时间 + /// 终止时间 + /// + private static DateTime MinDate(DateTime newTime, DateTime endTime) + { + return newTime >= endTime ? endTime : newTime; + } + + /// + /// 判断 Cron 所有字段字符解析器是否都能匹配当前时间各个部分 + /// + /// 当前时间 + /// + private bool IsMatch(DateTime datetime) + { + return Parsers.All(fieldKind => + fieldKind.Value.Any(parser => parser.IsMatch(datetime)) + ); + } + + /// + /// 判断当前 Cron 字段类型字符解析器和当前时间至少存在一种匹配 + /// + /// 当前时间 + /// Cron 字段种类 + /// + private bool IsMatch(DateTime datetime, CrontabFieldKind kind) + { + return Parsers.Where(x => x.Key == kind) + .SelectMany(x => x.Value) + .Any(parser => parser.IsMatch(datetime)); + } + + /// + /// 将 Cron 字段解析器转换成字符串 + /// + /// Cron 字段字符串集合 + /// Cron 字段种类 + private void JoinParsers(List paramList, CrontabFieldKind kind) + { + paramList.Add( + string.Join(",", Parsers + .Where(x => x.Key == kind) + .SelectMany(x => x.Value.Select(y => y.ToString())).ToArray() + ) + ); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.Macro.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.Macro.cs new file mode 100644 index 000000000..28f92012d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.Macro.cs @@ -0,0 +1,59 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 表达式抽象类 +/// +/// 主要将 Cron 表达式转换成 OOP 类进行操作 +public sealed partial class Crontab +{ + /// + /// 表示每秒开始的 对象 + /// + public static readonly Crontab Secondly = Parse("* * * * * *", CronStringFormat.WithSeconds); + + /// + /// 表示每分钟开始的 对象 + /// + public static readonly Crontab Minutely = Parse("* * * * *", CronStringFormat.Default); + + /// + /// 表示每小时开始 的 对象 + /// + public static readonly Crontab Hourly = Parse("0 * * * *", CronStringFormat.Default); + + /// + /// 表示每天(午夜)开始的 对象 + /// + public static readonly Crontab Daily = Parse("0 0 * * *", CronStringFormat.Default); + + /// + /// 表示每月1号(午夜)开始的 对象 + /// + public static readonly Crontab Monthly = Parse("0 0 1 * *", CronStringFormat.Default); + + /// + /// 表示每周日(午夜)开始的 对象 + /// + public static readonly Crontab Weekly = Parse("0 0 * * 0", CronStringFormat.Default); + + /// + /// 表示每年1月1号(午夜)开始的 对象 + /// + public static readonly Crontab Yearly = Parse("0 0 1 1 *", CronStringFormat.Default); + + /// + /// 表示每周一至周五(午夜)开始的 对象 + /// + public static readonly Crontab Workday = Parse("0 0 * * 1-5", CronStringFormat.Default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.MacroAt.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.MacroAt.cs new file mode 100644 index 000000000..ab4771aea --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.MacroAt.cs @@ -0,0 +1,133 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 表达式抽象类 +/// +/// 主要将 Cron 表达式转换成 OOP 类进行操作 +public sealed partial class Crontab +{ + /// + /// 创建指定特定秒开始作业触发器构建器 + /// + /// 字段值 + /// + public static Crontab SecondlyAt(params object[] fields) + { + // 检查字段合法性 + CheckFieldsNotNullOrEmpty(fields); + + return Parse($"{FieldsToString(fields)} * * * * *", CronStringFormat.WithSeconds); + } + + /// + /// 创建每分钟特定秒开始作业触发器构建器 + /// + /// 字段值 + /// + public static Crontab MinutelyAt(params object[] fields) + { + // 检查字段合法性 + CheckFieldsNotNullOrEmpty(fields); + + return Parse($"{FieldsToString(fields)} * * * * *", CronStringFormat.WithSeconds); + } + + /// + /// 创建每小时特定分钟开始作业触发器构建器 + /// + /// 字段值 + /// + public static Crontab HourlyAt(params object[] fields) + { + // 检查字段合法性 + CheckFieldsNotNullOrEmpty(fields); + + return Parse($"{FieldsToString(fields)} * * * *", CronStringFormat.Default); + } + + /// + /// 创建每天特定小时开始作业触发器构建器 + /// + /// 字段值 + /// + public static Crontab DailyAt(params object[] fields) + { + // 检查字段合法性 + CheckFieldsNotNullOrEmpty(fields); + + return Parse($"0 {FieldsToString(fields)} * * *", CronStringFormat.Default); + } + + /// + /// 创建每月特定天(午夜)开始作业触发器构建器 + /// + /// 字段值 + /// + public static Crontab MonthlyAt(params object[] fields) + { + // 检查字段合法性 + CheckFieldsNotNullOrEmpty(fields); + + return Parse($"0 0 {FieldsToString(fields)} * *", CronStringFormat.Default); + } + + /// + /// 创建每周特定星期几(午夜)开始作业触发器构建器 + /// + /// 字段值 + /// + public static Crontab WeeklyAt(params object[] fields) + { + // 检查字段合法性 + CheckFieldsNotNullOrEmpty(fields); + + return Parse($"0 0 * * {FieldsToString(fields)}", CronStringFormat.Default); + } + + /// + /// 创建每年特定月1号(午夜)开始作业触发器构建器 + /// + /// 字段值 + /// + public static Crontab YearlyAt(params object[] fields) + { + // 检查字段合法性 + CheckFieldsNotNullOrEmpty(fields); + + return Parse($"0 0 1 {FieldsToString(fields)} *", CronStringFormat.Default); + } + + /// + /// 检查字段域 非 Null 非空数组 + /// + /// 字段值 + private static void CheckFieldsNotNullOrEmpty(params object[] fields) + { + // 空检查 + if (fields == null || fields.Length == 0) throw new ArgumentNullException(nameof(fields)); + + // 检查 fields 只能是 int, long,string 和非 null 类型 + if (fields.Any(f => f == null || (f.GetType() != typeof(int) && f.GetType() != typeof(long) && f.GetType() != typeof(string)))) throw new InvalidOperationException("Invalid Cron expression."); + } + + /// + /// 将字段域转换成 string + /// + /// 字段值 + /// + private static string FieldsToString(params object[] fields) + { + return string.Join(",", fields); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.cs new file mode 100644 index 000000000..9d5822915 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Crontab.cs @@ -0,0 +1,227 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 表达式抽象类 +/// +/// 主要将 Cron 表达式转换成 OOP 类进行操作 +[SuppressSniffer] +public sealed partial class Crontab +{ + /// + /// 构造函数 + /// + /// 禁止外部 new 实例化 + private Crontab() + { + Parsers = new Dictionary>(); + Format = CronStringFormat.Default; + } + + /// + /// Cron 字段解析器字典集合 + /// + private Dictionary> Parsers { get; set; } + + /// + /// Cron 表达式格式化类型 + /// + /// 禁止运行时更改 + public CronStringFormat Format { get; private set; } + + /// + /// 解析 Cron 表达式并转换成 对象 + /// + /// Cron 表达式 + /// Cron 表达式格式化类型 + /// + /// + public static Crontab Parse(string expression, CronStringFormat format = CronStringFormat.Default) + { + // 处理 Macro 表达式 + if (expression.StartsWith('@')) + { + return expression switch + { + "@secondly" => Secondly, + "@minutely" => Minutely, + "@hourly" => Hourly, + "@daily" => Daily, + "@monthly" => Monthly, + "@weekly" => Weekly, + "@yearly" => Yearly, + "@workday" => Workday, + _ => throw new NotImplementedException(), + }; + } + + return new Crontab + { + Format = format, + Parsers = ParseToDictionary(expression, format) + }; + } + + /// + /// 解析 Cron Macro 符号并转换成 对象 + /// + /// Macro 符号 + /// 字段值 + /// + /// + public static Crontab ParseAt(string macro, params object[] fields) + { + // 空检查 + if (string.IsNullOrWhiteSpace(macro)) throw new ArgumentNullException(nameof(macro)); + + return macro switch + { + "@secondly" => SecondlyAt(fields), + "@minutely" => MinutelyAt(fields), + "@hourly" => HourlyAt(fields), + "@daily" => DailyAt(fields), + "@monthly" => MonthlyAt(fields), + "@weekly" => WeeklyAt(fields), + "@yearly" => YearlyAt(fields), + _ => throw new NotImplementedException(), + }; + } + + /// + /// 解析 Cron 表达式并转换成 对象 + /// + /// 解析失败返回 default + /// Cron 表达式 + /// Cron 表达式格式化类型 + /// + public static Crontab TryParse(string expression, CronStringFormat format = CronStringFormat.Default) + { + try + { + return Parse(expression, format); + } + catch + { + return null; + } + } + + /// + /// 判断 Cron 表达式是否有效 + /// + /// Cron 表达式 + /// Cron 表达式格式化类型 + /// + public static bool IsValid(string expression, CronStringFormat format = CronStringFormat.Default) + { + return TryParse(expression, format) != null; + } + + /// + /// 获取起始时间下一个发生时间 + /// + /// 起始时间 + /// + public DateTime GetNextOccurrence(DateTime baseTime) + { + return GetNextOccurrence(baseTime, DateTime.MaxValue); + } + + /// + /// 获取特定时间范围下一个发生时间 + /// + /// 起始时间 + /// 结束时间 + /// + public DateTime GetNextOccurrence(DateTime baseTime, DateTime endTime) + { + return InternalGetNextOccurence(baseTime, endTime); + } + + /// + /// 获取特定时间范围所有发生时间 + /// + /// 起始时间 + /// 结束时间 + /// + public IEnumerable GetNextOccurrences(DateTime baseTime, DateTime endTime) + { + for (var occurrence = GetNextOccurrence(baseTime, endTime); + occurrence < endTime; + occurrence = GetNextOccurrence(occurrence, endTime)) + { + yield return occurrence; + } + } + + /// + /// 计算距离下一个发生时间相差毫秒数 + /// + /// 起始时间 + /// + public double GetSleepMilliseconds(DateTime baseTime) + { + // 采用 DateTimeKind.Unspecified 转换当前时间并忽略毫秒之后部分 + var startAt = new DateTime(baseTime.Year + , baseTime.Month + , baseTime.Day + , baseTime.Hour + , baseTime.Minute + , baseTime.Second + , baseTime.Millisecond); + + // 计算总休眠时间 + return (GetNextOccurrence(startAt) - startAt).TotalMilliseconds; + } + + /// + /// 计算距离下一个发生时间相差时间戳 + /// + /// 起始时间 + /// + public TimeSpan GetSleepTimeSpan(DateTime baseTime) + { + return TimeSpan.FromMilliseconds(GetSleepMilliseconds(baseTime)); + } + + /// + /// 将 对象转换成 Cron 表达式字符串 + /// + /// + public override string ToString() + { + var paramList = new List(); + + // 判断当前 Cron 格式化类型是否包含秒字段域 + if (Format == CronStringFormat.WithSeconds || Format == CronStringFormat.WithSecondsAndYears) + { + JoinParsers(paramList, CrontabFieldKind.Second); + } + + // Cron 常规字段域 + JoinParsers(paramList, CrontabFieldKind.Minute); + JoinParsers(paramList, CrontabFieldKind.Hour); + JoinParsers(paramList, CrontabFieldKind.Day); + JoinParsers(paramList, CrontabFieldKind.Month); + JoinParsers(paramList, CrontabFieldKind.DayOfWeek); + + // 判断当前 Cron 格式化类型是否包含年字段域 + if (Format == CronStringFormat.WithYears || Format == CronStringFormat.WithSecondsAndYears) + { + JoinParsers(paramList, CrontabFieldKind.Year); + } + + // 空格分割并输出 + return string.Join(" ", paramList.ToArray()); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Exceptions/TimeCrontabException.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Exceptions/TimeCrontabException.cs new file mode 100644 index 000000000..bc97f241d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Exceptions/TimeCrontabException.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// TimeCrontab 模块异常类 +/// +[SuppressSniffer] +public sealed class TimeCrontabException : Exception +{ + /// + /// 构造函数 + /// + public TimeCrontabException() + : base() + { + } + + /// + /// 构造函数 + /// + /// 异常消息 + public TimeCrontabException(string message) + : base(message) + { + } + + /// + /// 构造函数 + /// + /// 异常消息 + /// 内部异常 + public TimeCrontabException(string message, Exception innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Extensions/DayOfWeekExtensions.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Extensions/DayOfWeekExtensions.cs new file mode 100644 index 000000000..a3f247d7c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Extensions/DayOfWeekExtensions.cs @@ -0,0 +1,59 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// 拓展类 +/// +internal static class DayOfWeekExtensions +{ + /// + /// 将 C# 中 枚举元素转换成数值 + /// + /// 枚举 + /// + internal static int ToCronDayOfWeek(this DayOfWeek dayOfWeek) + { + return Constants.CronDays[dayOfWeek]; + } + + /// + /// 将数值转换成 C# 中 枚举元素 + /// + /// + /// + internal static DayOfWeek ToDayOfWeek(this int dayOfWeek) + { + return Constants.CronDays.First(x => x.Value == dayOfWeek).Key; + } + + /// + /// 获取当前年月最后一个星期几 + /// + /// 星期几, 类型 + /// 年 + /// 月 + /// + internal static int LastDayOfMonth(this DayOfWeek dayOfWeek, int year, int month) + { + var daysInMonth = DateTime.DaysInMonth(year, month); + var currentDay = new DateTime(year, month, daysInMonth); + + // 从月底天数进行递归查找 + while (currentDay.DayOfWeek != dayOfWeek) + { + currentDay = currentDay.AddDays(-1); + } + + return currentDay.Day; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/AnyParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/AnyParser.cs new file mode 100644 index 000000000..fe472e686 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/AnyParser.cs @@ -0,0 +1,96 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 * 字符解析器 +/// +/// +/// * 表示任意值,该字符支持在 Cron 所有字段域中设置 +/// +internal sealed class AnyParser : ICronParser, ITimeParser +{ + /// + /// 构造函数 + /// + /// Cron 字段种类 + public AnyParser(CrontabFieldKind kind) + { + Kind = kind; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + return true; + } + + /// + /// 获取 Cron 字段种类当前值的下一个发生值 + /// + /// 时间值 + /// + /// + public int? Next(int currentValue) + { + // 由于天、月、周计算复杂,所以这里排除对它们的处理 + if (Kind == CrontabFieldKind.Day + || Kind == CrontabFieldKind.Month + || Kind == CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException("Cannot call Next for Day, Month or DayOfWeek types."); + } + + // 默认递增步长为 1 + int? newValue = currentValue + 1; + + // 验证最大值 + var maximum = Constants.MaximumDateTimeValues[Kind]; + return newValue > maximum ? null : newValue; + } + + /// + /// 获取 Cron 字段种类字段起始值 + /// + /// + /// + public int First() + { + // 由于天、月、周计算复杂,所以这里排除对它们的处理 + if (Kind == CrontabFieldKind.Day + || Kind == CrontabFieldKind.Month + || Kind == CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException("Cannot call First for Day, Month or DayOfWeek types."); + } + + return 0; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return "*"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/BlankDayOfMonthOrWeekParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/BlankDayOfMonthOrWeekParser.cs new file mode 100644 index 000000000..e6dcd300f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/BlankDayOfMonthOrWeekParser.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 ? 字符解析器 +/// +/// +/// 只能用在 Day 和 DayOfWeek 两个域使用。它也匹配域的任意值,但实际不会。因为 Day 和 DayOfWeek 会相互影响 +/// 例如想在每月的 20 日触发调度,不管 20 日到底是星期几,则只能使用如下写法:13 15 20 * ? +/// 其中最后一位只能用 ?,而不能使用 *,如果使用 * 表示不管星期几都会触发,实际上并不是这样 +/// 所以 ? 起着 Day 和 DayOfWeek 互斥性作用 +/// 仅在 字段域中使用 +/// +internal sealed class BlankDayOfMonthOrWeekParser : ICronParser +{ + /// + /// 构造函数 + /// + /// Cron 字段种类 + /// + public BlankDayOfMonthOrWeekParser(CrontabFieldKind kind) + { + // 验证 ? 字符是否在 DayOfWeek 和 Day 字段域中使用 + if (kind != CrontabFieldKind.DayOfWeek && kind != CrontabFieldKind.Day) + { + throw new TimeCrontabException("The parser can only be used in the Day-of-Week or Day-of-Month fields."); + } + + Kind = kind; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + return true; + } + + /// + /// 获取 Cron 字段种类当前值的下一个发生值 + /// + /// 时间值 + /// + /// + public int? Next(int currentValue) + { + // 由于天、月、周计算复杂,所以这里排除对它们的处理 + if (Kind == CrontabFieldKind.Day + || Kind == CrontabFieldKind.Month + || Kind == CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException("Cannot call Next for Day, Month or DayOfWeek types."); + } + + // 默认递增步长为 1 + int? newValue = currentValue + 1; + + // 验证最大值 + var maximum = Constants.MaximumDateTimeValues[Kind]; + return newValue >= maximum ? null : newValue; + } + + /// + /// 获取 Cron 字段种类字段起始值 + /// + /// + /// + public int First() + { + // 由于天、月、周计算复杂,所以这里排除对它们的处理 + if (Kind == CrontabFieldKind.Day + || Kind == CrontabFieldKind.Month + || Kind == CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException("Cannot call First for Day, Month or DayOfWeek types."); + } + + return 0; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return "?"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/Dependencies/ICronParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/Dependencies/ICronParser.cs new file mode 100644 index 000000000..913a68c8f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/Dependencies/ICronParser.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段字符解析器依赖接口 +/// +internal interface ICronParser +{ + /// + /// Cron 字段种类 + /// + CrontabFieldKind Kind { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + bool IsMatch(DateTime datetime); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/Dependencies/ITimeParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/Dependencies/ITimeParser.cs new file mode 100644 index 000000000..01ff9bf8d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/Dependencies/ITimeParser.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// DateTime 时间解析器依赖接口 +/// +/// 主要用于计算 DateTime 主要组成部分(秒,分,时,年)的下一个取值 +internal interface ITimeParser +{ + /// + /// 获取 Cron 字段种类当前值的下一个发生值 + /// + /// 时间值 + /// + /// + int? Next(int currentValue); + + /// + /// 获取 Cron 字段种类字段起始值 + /// + /// + /// + int First(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/LastDayOfMonthParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/LastDayOfMonthParser.cs new file mode 100644 index 000000000..3d499eb50 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/LastDayOfMonthParser.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 L 字符解析器 +/// +/// +/// L 表示月中最后一天,仅在 字段域中使用 +/// +internal sealed class LastDayOfMonthParser : ICronParser +{ + /// + /// 构造函数 + /// + /// Cron 字段种类 + /// + public LastDayOfMonthParser(CrontabFieldKind kind) + { + // 验证 L 字符是否在 Day 字段域中使用 + if (kind != CrontabFieldKind.Day) + { + throw new TimeCrontabException("The parser can only be used with the Day field."); + } + + Kind = kind; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + return DateTime.DaysInMonth(datetime.Year, datetime.Month) == datetime.Day; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return "L"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/LastDayOfWeekInMonthParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/LastDayOfWeekInMonthParser.cs new file mode 100644 index 000000000..135edfed9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/LastDayOfWeekInMonthParser.cs @@ -0,0 +1,74 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 {0}L 字符解析器 +/// +/// +/// 表示月中最后一个星期{0},仅在 字段域中使用 +/// +internal sealed class LastDayOfWeekInMonthParser : ICronParser +{ + /// + /// 构造函数 + /// + /// 星期,0 = 星期天,7 = 星期六 + /// Cron 字段种类 + /// + public LastDayOfWeekInMonthParser(int dayOfWeek, CrontabFieldKind kind) + { + // 验证 {0}L 字符是否在 DayOfWeek 字段域中使用 + if (kind != CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException(string.Format("The <{0}L> parser can only be used in the Day of Week field.", dayOfWeek)); + } + + DayOfWeek = dayOfWeek; + DateTimeDayOfWeek = dayOfWeek.ToDayOfWeek(); + Kind = kind; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 星期 + /// + public int DayOfWeek { get; } + + /// + /// 类型星期 + /// + private DayOfWeek DateTimeDayOfWeek { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + return datetime.Day == DateTimeDayOfWeek.LastDayOfMonth(datetime.Year, datetime.Month); + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return string.Format("{0}L", DayOfWeek); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/LastWeekdayOfMonthParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/LastWeekdayOfMonthParser.cs new file mode 100644 index 000000000..cd0395486 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/LastWeekdayOfMonthParser.cs @@ -0,0 +1,102 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 LW 字符解析器 +/// +/// +/// 表示月中最后一个工作日,即最后一个非周六周末的日期,仅在 字段域中使用 +/// +internal sealed class LastWeekdayOfMonthParser : ICronParser +{ + /// + /// 构造函数 + /// + /// Cron 字段种类 + /// + public LastWeekdayOfMonthParser(CrontabFieldKind kind) + { + // 验证 LW 字符是否在 Day 字段域中使用 + if (kind != CrontabFieldKind.Day) + { + throw new TimeCrontabException("The parser can only be used in the Day field."); + } + + Kind = kind; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + /* + * W:表示有效工作日(周一到周五),只能出现在 Day 域,系统将在离指定日期的最近的有效工作日触发事件 + * 例如:在 Day 使用 5W,如果 5 日是星期六,则将在最近的工作日:星期五,即 4 日触发 + * 如果 5 日是星期天,则在 6 日(周一)触发;如果 5 日在星期一到星期五中的一天,则就在 5 日触发 + * 另外一点,W 的最近寻找不会跨过月份 + */ + + // 获取当前时间所在月最后一天 + var specificValue = DateTime.DaysInMonth(datetime.Year, datetime.Month); + var specificDay = new DateTime(datetime.Year, datetime.Month, specificValue); + + // 最靠近的工作日时间 + DateTime closestWeekday; + + // 处理月中最后一天的不同情况 + switch (specificDay.DayOfWeek) + { + // 如果最后一天是周六,则退一天 + case DayOfWeek.Saturday: + closestWeekday = specificDay.AddDays(-1); + + break; + + // 如果最后一天是周天,则进一天 + case DayOfWeek.Sunday: + closestWeekday = specificDay.AddDays(1); + + // 如果进一天不在本月,则退到上周五 + if (closestWeekday.Month != specificDay.Month) + { + closestWeekday = specificDay.AddDays(-2); + } + + break; + + // 处理恰好是工作日情况,直接使用 + default: + closestWeekday = specificDay; + break; + } + + return datetime.Day == closestWeekday.Day; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return "LW"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/NearestWeekdayParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/NearestWeekdayParser.cs new file mode 100644 index 000000000..26f0d1c86 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/NearestWeekdayParser.cs @@ -0,0 +1,127 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 {0}W 字符解析器 +/// +/// +/// 表示离指定日期最近的工作日,即最后一个非周六周末日,仅在 字段域中使用 +/// +internal sealed class NearestWeekdayParser : ICronParser +{ + /// + /// 构造函数 + /// + /// 天数(具体值) + /// Cron 字段种类 + /// Cron 字段种类 + public NearestWeekdayParser(int specificValue, CrontabFieldKind kind) + { + // 验证 {0}W 字符是否在 Day 字段域中使用 + if (kind != CrontabFieldKind.Day) + { + throw new TimeCrontabException(string.Format("The <{0}W> parser can only be used in the Day field.", specificValue)); + } + + // 判断天数是否在有效取值范围内 + var maximum = Constants.MaximumDateTimeValues[CrontabFieldKind.Day]; + if (specificValue <= 0 || specificValue > maximum) + { + throw new TimeCrontabException(string.Format("The <{0}W> is out of bounds for the Day field.", specificValue)); + } + + SpecificValue = specificValue; + Kind = kind; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 天数(具体值) + /// + public int SpecificValue { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + /* + * W:表示有效工作日(周一到周五),只能出现在 Day 域,系统将在离指定日期的最近的有效工作日触发事件 + * 例如:在 Day 使用 5W,如果 5 日是星期六,则将在最近的工作日:星期五,即 4 日触发 + * 如果 5 日是星期天,则在 6 日(周一)触发;如果 5 日在星期一到星期五中的一天,则就在 5 日触发 + * 另外一点,W 的最近寻找不会跨过月份 + */ + + // 如果这个月没有足够的天数则跳过(例如,二月没有 30 和 31 日) + if (DateTime.DaysInMonth(datetime.Year, datetime.Month) < SpecificValue) + { + return false; + } + + // 获取当前时间特定天数时间 + var specificDay = new DateTime(datetime.Year, datetime.Month, SpecificValue); + + // 最靠近的工作日时间 + DateTime closestWeekday; + + // 处理当天的不同情况 + switch (specificDay.DayOfWeek) + { + // 如果当天是周六,则退一天 + case DayOfWeek.Saturday: + closestWeekday = specificDay.AddDays(-1); + + // 如果退一天不在本月,则转到下周一 + if (closestWeekday.Month != specificDay.Month) + { + closestWeekday = specificDay.AddDays(2); + } + + break; + + // 如果当天是周天,则进一天 + case DayOfWeek.Sunday: + closestWeekday = specificDay.AddDays(1); + + // 如果进一天不在本月,则退到上周五 + if (closestWeekday.Month != specificDay.Month) + { + closestWeekday = specificDay.AddDays(-2); + } + + break; + + // 处理恰好是工作日情况,直接使用 + default: + closestWeekday = specificDay; + break; + } + + return datetime.Day == closestWeekday.Day; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return string.Format("{0}W", SpecificValue); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/RandomParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/RandomParser.cs new file mode 100644 index 000000000..1c85cae1a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/RandomParser.cs @@ -0,0 +1,105 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 R 字符解析器 +/// +/// +/// R 表示随机生成的时刻,仅在 字段域中使用。 +/// 参考文献:https://help.eset.com/protect_admin/10.0/zh-CN/cron_expression.html。 +/// +internal sealed class RandomParser : ICronParser, ITimeParser +{ + /// + /// 随机对象 + /// + private static readonly Random random = new(); + + /// + /// Cron 字段种类最小值 + /// + private readonly int _minimumOfKind; + + /// + /// Cron 字段种类最大值 + /// + private readonly int _maximumOfKind; + + /// + /// 构造函数 + /// + /// Cron 字段种类 + /// + public RandomParser(CrontabFieldKind kind) + { + // 验证 R 字符是否在 Second、Minute 或 Hour 字段域中使用 + if (kind != CrontabFieldKind.Second && + kind != CrontabFieldKind.Minute && + kind != CrontabFieldKind.Hour) + { + throw new TimeCrontabException("The parser can only be used with the Second, Minute, or Hour fields."); + } + + Kind = kind; + + // 获取 Cron 字段种类最小值和最大值 + _minimumOfKind = Constants.MinimumDateTimeValues[Kind]; + _maximumOfKind = Constants.MaximumDateTimeValues[Kind]; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + return true; + } + + /// + /// 获取 Cron 字段种类当前值的下一个发生值 + /// + /// 时间值 + /// + /// + public int? Next(int currentValue) + { + // 生成最小值和最大值之间的随机数 + return random.Next(_minimumOfKind, _maximumOfKind + 1); + } + + /// + /// 获取 Cron 字段种类字段起始值 + /// + /// + /// + public int First() + { + return 0; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return "R"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/RangeParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/RangeParser.cs new file mode 100644 index 000000000..ff1151467 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/RangeParser.cs @@ -0,0 +1,218 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 - 字符解析器 +/// +/// +/// 表示特定取值范围,如 1-5 或 1-5/2,该字符支持在 Cron 所有字段域中设置 +/// +internal sealed class RangeParser : ICronParser, ITimeParser +{ + /// + /// 构造函数 + /// + /// 起始值 + /// 终止值 + /// 步长 + /// Cron 字段种类 + /// + public RangeParser(int start, int end, int? steps, CrontabFieldKind kind) + { + var maximum = Constants.MaximumDateTimeValues[kind]; + + // 验证起始值有效性 + if (start < 0 || start > maximum) + { + throw new TimeCrontabException(string.Format("Start = {0} is out of bounds for <{1}> field.", start, Enum.GetName(typeof(CrontabFieldKind), kind))); + } + + // 验证终止值有效性 + if (end < 0 || end > maximum) + { + throw new TimeCrontabException(string.Format("End = {0} is out of bounds for <{1}> field.", end, Enum.GetName(typeof(CrontabFieldKind), kind))); + } + + // 验证步长有效性 + if (steps != null && (steps <= 0 || steps > maximum)) + { + throw new TimeCrontabException(string.Format("Steps = {0} is out of bounds for <{1}> field.", steps, Enum.GetName(typeof(CrontabFieldKind), kind))); + } + + Start = start; + End = end; + Kind = kind; + Steps = steps; + + // 计算所有满足范围计算的解析器 + var parsers = new List(); + for (var evalValue = Start; evalValue <= End; evalValue++) + { + if (IsMatch(evalValue)) + { + parsers.Add(new SpecificParser(evalValue, Kind)); + } + } + + SpecificParsers = parsers; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 起始值 + /// + public int Start { get; } + + /// + /// 终止值 + /// + public int End { get; } + + /// + /// 步长 + /// + public int? Steps { get; } + + /// + /// 所有满足范围计算的解析器 + /// + public IEnumerable SpecificParsers { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + // 获取不同 Cron 字段种类对应时间值 + var evalValue = Kind switch + { + CrontabFieldKind.Second => datetime.Second, + CrontabFieldKind.Minute => datetime.Minute, + CrontabFieldKind.Hour => datetime.Hour, + CrontabFieldKind.Day => datetime.Day, + CrontabFieldKind.Month => datetime.Month, + CrontabFieldKind.DayOfWeek => datetime.DayOfWeek.ToCronDayOfWeek(), + CrontabFieldKind.Year => datetime.Year, + _ => throw new ArgumentOutOfRangeException(nameof(datetime), Kind, null), + }; + + return IsMatch(evalValue); + } + + /// + /// 获取 Cron 字段种类当前值的下一个发生值 + /// + /// 时间值 + /// + /// + public int? Next(int currentValue) + { + // 由于天、月、周计算复杂,所以这里排除对它们的处理 + if (Kind == CrontabFieldKind.Day + || Kind == CrontabFieldKind.Month + || Kind == CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException("Cannot call Next for Day, Month or DayOfWeek types."); + } + + // 默认递增步长为 1 + int? newValue = currentValue + 1; + + // 获取下一个匹配的发生值 + var maximum = Constants.MaximumDateTimeValues[Kind]; + while (newValue < maximum && !IsMatch(newValue.Value)) + { + newValue++; + } + + return newValue > maximum ? null : newValue; + } + + /// + /// 存储起始值,避免重复计算 + /// + private int? FirstCache { get; set; } + + /// + /// 获取 Cron 字段种类字段起始值 + /// + /// + /// + public int First() + { + // 判断是否缓存过起始值,如果有则跳过 + if (FirstCache.HasValue) + { + return FirstCache.Value; + } + + // 由于天、月、周计算复杂,所以这里排除对它们的处理 + if (Kind == CrontabFieldKind.Day + || Kind == CrontabFieldKind.Month + || Kind == CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException("Cannot call First for Day, Month or DayOfWeek types."); + } + + var maximum = Constants.MaximumDateTimeValues[Kind]; + var newValue = 0; + + // 获取首个符合的起始值 + while (newValue < maximum && !IsMatch(newValue)) + { + newValue++; + } + + // 验证起始值有效性 + if (newValue > maximum) + { + throw new TimeCrontabException( + string.Format("Next value for {0} on field {1} could not be found!", + ToString(), + Enum.GetName(typeof(CrontabFieldKind), Kind)) + ); + } + + // 缓存起始值 + FirstCache = newValue; + return newValue; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return Steps.HasValue + ? string.Format("{0}-{1}/{2}", Start, End, Steps) + : string.Format("{0}-{1}", Start, End); + } + + /// + /// 判断是否符合范围或带步长范围解析规则 + /// + /// 当前值 + /// + private bool IsMatch(int evalValue) + { + return evalValue >= Start && evalValue <= End + && (!Steps.HasValue || ((evalValue - Start) % Steps) == 0); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/SpecificDayOfWeekInMonthParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/SpecificDayOfWeekInMonthParser.cs new file mode 100644 index 000000000..64a66cb10 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/SpecificDayOfWeekInMonthParser.cs @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 {0}#{1} 字符解析器 +/// +/// +/// 表示月中第{0}个星期{1},仅在 字段域中使用 +/// +internal sealed class SpecificDayOfWeekInMonthParser : ICronParser +{ + /// + /// 构造函数 + /// + /// 星期,0 = 星期天,7 = 星期六 + /// 月中第几个星期 + /// Cron 字段种类 + /// + public SpecificDayOfWeekInMonthParser(int dayOfWeek, int weekNumber, CrontabFieldKind kind) + { + // 验证星期数有效性 + if (weekNumber <= 0 || weekNumber > 5) + { + throw new TimeCrontabException(string.Format("Week number = {0} is out of bounds.", weekNumber)); + } + + // 验证 L 字符是否在 DayOfWeek 字段域中使用 + if (kind != CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException(string.Format("The <{0}#{1}> parser can only be used in the Day of Week field.", dayOfWeek, weekNumber)); + } + + DayOfWeek = dayOfWeek; + DateTimeDayOfWeek = dayOfWeek.ToDayOfWeek(); + WeekNumber = weekNumber; + Kind = kind; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 星期 + /// + public int DayOfWeek { get; } + + /// + /// 类型星期 + /// + private DayOfWeek DateTimeDayOfWeek { get; } + + /// + /// 月中第几个星期 + /// + public int WeekNumber { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + // 获取当前时间所在月第一天 + var currentDay = new DateTime(datetime.Year, datetime.Month, 1); + + // 第几个星期计数器 + var weekCount = 0; + + // 限制当前循环仅在本月 + while (currentDay.Month == datetime.Month) + { + // 首先确认星期是否相等,如果相等,则计数器 + 1 + if (currentDay.DayOfWeek == DateTimeDayOfWeek) + { + weekCount++; + + // 如果计算器和指定 WeekNumber 一致,则退出循环 + if (weekCount == WeekNumber) + { + break; + } + + // 否则,则追加一周(即7天)进入下一次循环 + currentDay = currentDay.AddDays(7); + } + // 如果星期不相等,则追加一天i将纳入下一次循环 + else + { + currentDay = currentDay.AddDays(1); + } + } + + // 如果最后计算出现跨月份情况,则不匹配 + if (currentDay.Month != datetime.Month) + { + return false; + } + + return datetime.Day == currentDay.Day; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return string.Format("{0}#{1}", DayOfWeek, WeekNumber); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/SpecificParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/SpecificParser.cs new file mode 100644 index 000000000..dc515802d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/SpecificParser.cs @@ -0,0 +1,121 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 数值 字符解析器 +/// +/// +/// 表示具体值,该字符支持在 Cron 所有字段域中设置 +/// +internal class SpecificParser : ICronParser, ITimeParser +{ + /// + /// 构造函数 + /// + /// 具体值 + /// Cron 字段种类 + public SpecificParser(int specificValue, CrontabFieldKind kind) + { + SpecificValue = specificValue; + Kind = kind; + + // 验证值有效性 + ValidateBounds(specificValue); + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 具体值 + /// + public int SpecificValue { get; private set; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + // 获取不同 Cron 字段种类对应时间值 + var evalValue = Kind switch + { + CrontabFieldKind.Second => datetime.Second, + CrontabFieldKind.Minute => datetime.Minute, + CrontabFieldKind.Hour => datetime.Hour, + CrontabFieldKind.Day => datetime.Day, + CrontabFieldKind.Month => datetime.Month, + CrontabFieldKind.DayOfWeek => datetime.DayOfWeek.ToCronDayOfWeek(), + CrontabFieldKind.Year => datetime.Year, + _ => throw new ArgumentOutOfRangeException(nameof(datetime), Kind, null), + }; + + return evalValue == SpecificValue; + } + + /// + /// 获取 Cron 字段种类当前值的下一个发生值 + /// + /// 时间值 + /// + /// + public virtual int? Next(int currentValue) + { + return SpecificValue; + } + + /// + /// 获取 Cron 字段种类字段起始值 + /// + /// + /// + public int First() + { + return SpecificValue; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return SpecificValue.ToString(); + } + + /// + /// 验证值有效性 + /// + /// 具体值 + /// + private void ValidateBounds(int value) + { + var minimum = Constants.MinimumDateTimeValues[Kind]; + var maximum = Constants.MaximumDateTimeValues[Kind]; + + // 验证值有效性 + if (value < minimum || value > maximum) + { + throw new ArgumentOutOfRangeException(nameof(value), $"{nameof(value)} should be between {minimum} and {maximum} (was {SpecificValue})."); + } + + // 兼容星期日可以同时用 0 或 7 表示 + if (Kind == CrontabFieldKind.DayOfWeek) + { + SpecificValue %= 7; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/SpecificYearParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/SpecificYearParser.cs new file mode 100644 index 000000000..af1ab3f8c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/SpecificYearParser.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 数值 字符解析器 +/// +/// +/// 表示具体值,这里仅处理 字段域 +/// +internal sealed class SpecificYearParser : SpecificParser +{ + /// + /// 构造函数 + /// + /// 年(具体值) + /// Cron 字段种类 + public SpecificYearParser(int specificValue, CrontabFieldKind kind) + : base(specificValue, kind) + { + } + + /// + /// 获取 Cron 字段种类当前值的下一个发生值 + /// + /// 时间值 + /// + /// + public override int? Next(int currentValue) + { + // 如果当前年份小于具体值,则返回具体值,否则返回 null + // 因为一旦指定了年份,那么就必须等到那一年才触发 + return currentValue < SpecificValue ? SpecificValue : null; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/StepParser.cs b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/StepParser.cs new file mode 100644 index 000000000..52c657222 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/TimeCrontab/Parsers/StepParser.cs @@ -0,0 +1,199 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.TimeCrontab; + +/// +/// Cron 字段值含 / 字符解析器 +/// +/// +/// 表示从某值开始,每隔固定值触发,该字符支持在 Cron 所有字段域中设置 +/// +internal sealed class StepParser : ICronParser, ITimeParser +{ + /// + /// 构造函数 + /// + /// 起始值 + /// 步长 + /// Cron 字段种类 + /// + public StepParser(int start, int steps, CrontabFieldKind kind) + { + // 验证步长有效性:不能小于或等于0,且不能大于 Cron 字段种类取值最大值 + var minimum = Constants.MinimumDateTimeValues[kind]; + var maximum = Constants.MaximumDateTimeValues[kind]; + if (steps <= 0 || steps > maximum) + { + throw new TimeCrontabException(string.Format("Steps = {0} is out of bounds for <{1}> field.", steps, Enum.GetName(typeof(CrontabFieldKind), kind))); + } + + Start = start; + Steps = steps; + Kind = kind; + + // 控制循环起始值,并不一定从 Start 开始 + var loopStart = Math.Max(start, minimum); + + // 计算所有满足间隔步长计算的解析器 + var parsers = new List(); + for (var evalValue = loopStart; evalValue <= maximum; evalValue++) + { + if (IsMatch(evalValue)) + { + parsers.Add(new SpecificParser(evalValue, Kind)); + } + } + + SpecificParsers = parsers; + } + + /// + /// Cron 字段种类 + /// + public CrontabFieldKind Kind { get; } + + /// + /// 起始值 + /// + public int Start { get; } + + /// + /// 步长 + /// + public int Steps { get; } + + /// + /// 所有满足间隔步长计算的解析器 + /// + public IEnumerable SpecificParsers { get; } + + /// + /// 判断当前时间是否符合 Cron 字段种类解析规则 + /// + /// 当前时间 + /// + public bool IsMatch(DateTime datetime) + { + // 获取不同 Cron 字段种类对应时间值 + var evalValue = Kind switch + { + CrontabFieldKind.Second => datetime.Second, + CrontabFieldKind.Minute => datetime.Minute, + CrontabFieldKind.Hour => datetime.Hour, + CrontabFieldKind.Day => datetime.Day, + CrontabFieldKind.Month => datetime.Month, + CrontabFieldKind.DayOfWeek => datetime.DayOfWeek.ToCronDayOfWeek(), + CrontabFieldKind.Year => datetime.Year, + _ => throw new ArgumentOutOfRangeException(nameof(datetime), Kind, null), + }; + + return IsMatch(evalValue); + } + + /// + /// 获取 Cron 字段种类当前值的下一个发生值 + /// + /// 时间值 + /// + /// + public int? Next(int currentValue) + { + // 由于天、月、周计算复杂,所以这里排除对它们的处理 + if (Kind == CrontabFieldKind.Day + || Kind == CrontabFieldKind.Month + || Kind == CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException("Cannot call Next for Day, Month or DayOfWeek types."); + } + + // 默认递增步长为 1 + int? newValue = currentValue + 1; + + // 获取下一个匹配的发生值 + var maximum = Constants.MaximumDateTimeValues[Kind]; + while (newValue < maximum && !IsMatch(newValue.Value)) + { + newValue++; + } + + return newValue > maximum ? null : newValue; + } + + /// + /// 存储起始值,避免重复计算 + /// + private int? FirstCache { get; set; } + + /// + /// 获取 Cron 字段种类字段起始值 + /// + /// + /// + public int First() + { + // 判断是否缓存过起始值,如果有则跳过 + if (FirstCache.HasValue) + { + return FirstCache.Value; + } + + // 由于天、月、周计算复杂,所以这里排除对它们的处理 + if (Kind == CrontabFieldKind.Day + || Kind == CrontabFieldKind.Month + || Kind == CrontabFieldKind.DayOfWeek) + { + throw new TimeCrontabException("Cannot call First for Day, Month or DayOfWeek types."); + } + + var maximum = Constants.MaximumDateTimeValues[Kind]; + var newValue = 0; + + // 获取首个符合的起始值 + while (newValue < maximum && !IsMatch(newValue)) + { + newValue++; + } + + // 验证起始值有效性 + if (newValue > maximum) + { + throw new TimeCrontabException( + string.Format("Next value for {0} on field {1} could not be found!", + ToString(), + Enum.GetName(typeof(CrontabFieldKind), Kind)) + ); + } + + // 缓存起始值 + FirstCache = newValue; + return newValue; + } + + /// + /// 将解析器转换成字符串输出 + /// + /// + public override string ToString() + { + return string.Format("{0}/{1}", Start == 0 ? "*" : Start.ToString(), Steps); + } + + /// + /// 判断是否符合间隔或带步长间隔解析规则 + /// + /// 当前值 + /// + private bool IsMatch(int evalValue) + { + return evalValue >= Start && (evalValue - Start) % Steps == 0; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/NonUnifyAttribute.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/NonUnifyAttribute.cs new file mode 100644 index 000000000..611b7e3b6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/NonUnifyAttribute.cs @@ -0,0 +1,20 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 禁止规范化处理 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] +public sealed class NonUnifyAttribute : Attribute +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifyModelAttribute.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifyModelAttribute.cs new file mode 100644 index 000000000..7c9f4b096 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifyModelAttribute.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.UnifyResult; + +/// +/// 规范化模型特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class)] +public sealed class UnifyModelAttribute : Attribute +{ + /// + /// 规范化模型 + /// + /// + public UnifyModelAttribute(Type modelType) + { + ModelType = modelType; + } + + /// + /// 模型类型(泛型) + /// + public Type ModelType { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifyProviderAttribute.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifyProviderAttribute.cs new file mode 100644 index 000000000..adaeeaf19 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifyProviderAttribute.cs @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 规范化提供器特性 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public sealed class UnifyProviderAttribute : Attribute +{ + /// + /// 构造函数 + /// + public UnifyProviderAttribute() + : this(string.Empty) + { + } + + /// + /// 构造函数 + /// + /// + public UnifyProviderAttribute(string name) + { + Name = name; + } + + /// + /// 提供器名称 + /// + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifyResultAttribute.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifyResultAttribute.cs new file mode 100644 index 000000000..575193d63 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifyResultAttribute.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; + +using System.Reflection; + +using ThingsGateway.Extensions; +using ThingsGateway.UnifyResult; + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 规范化结果配置 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = true)] +public sealed class UnifyResultAttribute : ProducesResponseTypeAttribute +{ + /// + /// 构造函数 + /// + /// + public UnifyResultAttribute(int statusCode) : base(statusCode) + { + } + + /// + /// 构造函数 + /// + /// + public UnifyResultAttribute(Type type) : base(type, StatusCodes.Status200OK) + { + WrapType(type); + } + + /// + /// 构造函数 + /// + /// + /// + public UnifyResultAttribute(Type type, int statusCode) : base(type, statusCode) + { + WrapType(type); + } + + /// + /// 构造函数 + /// + /// + /// + /// + internal UnifyResultAttribute(Type type, int statusCode, MethodInfo method) : base(type, statusCode) + { + WrapType(type, method); + } + + /// + /// 包装类型 + /// + /// + /// + private void WrapType(Type type, MethodInfo method = default) + { + if (type != null && UnifyContext.EnabledUnifyHandler) + { + var unityMetadata = UnifyContext.GetMethodUnityMetadata(method); + + if (unityMetadata != null && !type.HasImplementedRawGeneric(unityMetadata.ResultType)) + { + Type = unityMetadata.ResultType.MakeGenericType(type); + } + else Type = default; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifySerializerSettingAttribute.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifySerializerSettingAttribute.cs new file mode 100644 index 000000000..77c3282b2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Attributes/UnifySerializerSettingAttribute.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// 规范化序列化配置 +/// +[SuppressSniffer, AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)] +public sealed class UnifySerializerSettingAttribute : Attribute +{ + /// + /// 构造函数 + /// + /// + public UnifySerializerSettingAttribute(string name) + { + Name = name; + } + + /// + /// 序列化名称 + /// + public string Name { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Extensions/UnifyResultMiddlewareExtensions.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Extensions/UnifyResultMiddlewareExtensions.cs new file mode 100644 index 000000000..e42ce92ca --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Extensions/UnifyResultMiddlewareExtensions.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.UnifyResult; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// 状态码中间件拓展 +/// +[SuppressSniffer] +public static class UnifyResultMiddlewareExtensions +{ + private static readonly string[] DefaultAuthorizedHeaders = { "WWW-Authenticate" }; + + /// + /// 添加状态码拦截中间件 + /// + /// + /// + /// + /// + public static IApplicationBuilder UseUnifyResultStatusCodes(this IApplicationBuilder builder, string[] authorizedHeaders = null, bool withAuthorizationHeaderCheck = false) + { + // 注册中间件 + UnifyContext.EnabledStatusCodesMiddleware = true; // 设置标识 + + // 设置授权验证失败识别头,如果不匹配将不进入规范化处理,主要解决 Windows 域授权或其他授权重新发起失败问题 + var checkAuthorizedHeaders = (authorizedHeaders ?? Array.Empty()).Concat(DefaultAuthorizedHeaders).ToArray(); + + builder.UseMiddleware(new object[] { checkAuthorizedHeaders, withAuthorizationHeaderCheck }); + + return builder; + } +} diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Extensions/UnifyResultServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Extensions/UnifyResultServiceCollectionExtensions.cs new file mode 100644 index 000000000..e53a05805 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Extensions/UnifyResultServiceCollectionExtensions.cs @@ -0,0 +1,171 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +using System.Reflection; + +using ThingsGateway.UnifyResult; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// 规范化结果服务拓展 +/// +[SuppressSniffer] +public static class UnifyResultServiceCollectionExtensions +{ + /// + /// 添加规范化结果服务 + /// + /// + /// + public static IMvcBuilder AddUnifyResult(this IMvcBuilder mvcBuilder) + { + mvcBuilder.Services.AddUnifyResult(); + + return mvcBuilder; + } + + /// + /// 添加规范化结果服务 + /// + /// + /// + public static IServiceCollection AddUnifyResult(this IServiceCollection services) + { + services.AddUnifyResult(); + + return services; + } + + /// + /// 添加规范化结果服务 + /// + /// + /// + /// + public static IMvcBuilder AddUnifyResult(this IMvcBuilder mvcBuilder) + where TUnifyResultProvider : class, IUnifyResultProvider + { + mvcBuilder.Services.AddUnifyResult(); + + return mvcBuilder; + } + + /// + /// 添加规范化结果服务 + /// + /// + /// + /// + public static IServiceCollection AddUnifyResult(this IServiceCollection services) + where TUnifyResultProvider : class, IUnifyResultProvider + { + // 解决服务重复注册问题 + if (services.Any(u => u.ServiceType == typeof(IConfigureOptions))) + { + return services; + } + + // 添加配置 + services.AddConfigurableOptions(); + + // 是否启用规范化结果 + UnifyContext.EnabledUnifyHandler = true; + + // 添加规范化提供器 + services.AddUnifyProvider(string.Empty); + + // 添加成功规范化结果筛选器 + services.AddMvcFilter(); + + return services; + } + + /// + /// 替换默认的规范化结果 + /// + /// + /// + /// + public static IServiceCollection AddUnifyProvider(this IServiceCollection services) + where TUnifyResultProvider : class, IUnifyResultProvider + { + return services.AddUnifyProvider(string.Empty); + } + + /// + /// 添加规范化提供器 + /// + /// + /// + /// + /// + public static IServiceCollection AddUnifyProvider(this IServiceCollection services, string providerName) + where TUnifyResultProvider : class, IUnifyResultProvider + { + providerName ??= string.Empty; + + var providerType = typeof(TUnifyResultProvider); + + // 添加规范化提供器 + services.TryAddSingleton(providerType, providerType); + + // 获取规范化提供器模型,不能为空 + var resultType = providerType.GetCustomAttribute().ModelType; + + // 创建规范化元数据 + var metadata = new UnifyMetadata + { + ProviderName = providerName, + ProviderType = providerType, + ResultType = resultType + }; + + // 添加或替换规范化配置 + UnifyContext.UnifyProviders.AddOrUpdate(providerName, _ => metadata, (_, _) => metadata); + + return services; + } + + /// + /// 添加规范化序列化配置 + /// + /// + /// + /// + /// + public static IMvcBuilder AddUnifyJsonOptions(this IMvcBuilder mvcBuilder, string providerName, object serializerSettings) + { + mvcBuilder.Services.AddUnifyJsonOptions(providerName, serializerSettings); + + return mvcBuilder; + } + + /// + /// 添加规范化序列化配置 + /// + /// + /// + /// + /// + public static IServiceCollection AddUnifyJsonOptions(this IServiceCollection services, string providerName, object serializerSettings) + { + if (string.IsNullOrWhiteSpace(providerName)) throw new ArgumentNullException(nameof(providerName)); + + // 添加或替换规范化序列化配置 + UnifyContext.UnifySerializerSettings.AddOrUpdate(providerName, _ => serializerSettings, (_, _) => serializerSettings); + + return services; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Filters/SucceededUnifyResultFilter.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Filters/SucceededUnifyResultFilter.cs new file mode 100644 index 000000000..bb810215d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Filters/SucceededUnifyResultFilter.cs @@ -0,0 +1,139 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using ThingsGateway.DataValidation; + +namespace ThingsGateway.UnifyResult; + +/// +/// 规范化结构(请求成功)过滤器 +/// +[SuppressSniffer] +public class SucceededUnifyResultFilter : IAsyncActionFilter, IOrderedFilter +{ + /// + /// 过滤器排序 + /// + private const int FilterOrder = 8888; + + /// + /// 排序属性 + /// + public int Order => FilterOrder; + + /// + /// 处理规范化结果 + /// + /// + /// + /// + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + // 执行 Action 并获取结果 + var actionExecutedContext = await next().ConfigureAwait(false); + + // 排除 WebSocket 请求处理 + if (actionExecutedContext.HttpContext.IsWebSocketRequest()) return; + + // 处理已经含有状态码结果的 Result + if (actionExecutedContext.Result is IStatusCodeActionResult statusCodeResult && statusCodeResult.StatusCode != null) + { + // 小于 200 或者 大于 299 都不是成功值,直接跳过 + if (statusCodeResult.StatusCode.Value < 200 || statusCodeResult.StatusCode.Value > 299) + { + // 处理规范化结果 + if (!UnifyContext.CheckExceptionHttpContextNonUnify(context.HttpContext, out var unifyRes)) + { + var httpContext = context.HttpContext; + var statusCode = statusCodeResult.StatusCode.Value; + + // 解决刷新 Token 时间和 Token 时间相近问题 + if (statusCodeResult.StatusCode.Value == StatusCodes.Status401Unauthorized + && httpContext.Response.Headers.ContainsKey("access-token") + && httpContext.Response.Headers.ContainsKey("x-access-token")) + { + httpContext.Response.StatusCode = statusCode = StatusCodes.Status403Forbidden; + } + + // 如果 Response 已经完成输出,则禁止写入 + if (httpContext.Response.HasStarted) return; + + // 检查是否启用状态码拦截中间件 + if (UnifyContext.EnabledStatusCodesMiddleware) + { + // 获取授权失败设置的状态码 + var authorizationFailStatusCode = httpContext.Items[AuthorizationHandlerContextExtensions.FAIL_STATUSCODE_KEY]; + if (authorizationFailStatusCode != null) + { + statusCode = Convert.ToInt32(authorizationFailStatusCode); + } + + await unifyRes.OnResponseStatusCodes(httpContext, statusCode, httpContext.RequestServices.GetService>()?.Value).ConfigureAwait(false); + } + } + + return; + } + } + + // 如果出现异常,则不会进入该过滤器 + if (actionExecutedContext.Exception != null) return; + + // 获取控制器信息 + var actionDescriptor = context.ActionDescriptor as ControllerActionDescriptor; + + // 判断是否支持 MVC 规范化处理或特定检查 + if (!UnifyContext.CheckSupportMvcController(context.HttpContext, actionDescriptor, out _) + || UnifyContext.CheckHttpContextNonUnify(context.HttpContext)) return; + + // 判断是否跳过规范化处理 + if (UnifyContext.CheckSucceededNonUnify(actionDescriptor.MethodInfo, out var unifyResult)) return; + + // 处理 BadRequestObjectResult 类型规范化处理 + if (actionExecutedContext.Result is BadRequestObjectResult badRequestObjectResult) + { + // 解析验证消息 + var validationMetadata = ValidatorContext.GetValidationMetadata(badRequestObjectResult.Value); + var unifyResultSettingsOptions = context.HttpContext.RequestServices.GetService>()?.Value; + validationMetadata.SingleValidationErrorDisplay = unifyResultSettingsOptions.SingleValidationErrorDisplay ?? false; + + var result = unifyResult.OnValidateFailed(context, validationMetadata); + if (result != null) actionExecutedContext.Result = result; + + // 打印验证失败信息 + App.PrintToMiniProfiler("validation", "Failed", $"Validation Failed:\r\n\r\n{validationMetadata.Message}", true); + } + else + { + IActionResult result = default; + + // 检查是否是有效的结果(可进行规范化的结果) + if (UnifyContext.CheckVaildResult(actionExecutedContext.Result, out var data)) + { + result = unifyResult.OnSucceeded(actionExecutedContext, data); + } + + // 如果是不能规范化的结果类型,则跳过 + if (result == null) return; + + actionExecutedContext.Result = result; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Internal/RESTfulResult.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Internal/RESTfulResult.cs new file mode 100644 index 000000000..bd5f262f7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Internal/RESTfulResult.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.UnifyResult; + +/// +/// RESTful 风格结果集 +/// +/// +[SuppressSniffer] +public class RESTfulResult +{ + /// + /// 状态码 + /// + public int? StatusCode { get; set; } + + /// + /// 数据 + /// + public T Data { get; set; } + + /// + /// 执行成功 + /// + public bool Succeeded { get; set; } + + /// + /// 错误信息 + /// + public object Errors { get; set; } + + /// + /// 附加数据 + /// + public object Extras { get; set; } + + /// + /// 时间戳 + /// + public long Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Internal/UnifyMetadata.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Internal/UnifyMetadata.cs new file mode 100644 index 000000000..398f58322 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Internal/UnifyMetadata.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.UnifyResult; + +/// +/// 规范化元数据 +/// +internal sealed class UnifyMetadata +{ + /// + /// 提供器名称 + /// + public string ProviderName { get; set; } + + /// + /// 提供器类型 + /// + public Type ProviderType { get; set; } + + /// + /// 统一的结果类型 + /// + public Type ResultType { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Middlewares/UnifyResultStatusCodesMiddleware.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Middlewares/UnifyResultStatusCodesMiddleware.cs new file mode 100644 index 000000000..40628dbc0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Middlewares/UnifyResultStatusCodesMiddleware.cs @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ThingsGateway.UnifyResult; + +/// +/// 状态码中间件 +/// +[SuppressSniffer] +public class UnifyResultStatusCodesMiddleware +{ + /// + /// 请求委托 + /// + private readonly RequestDelegate _next; + + /// + /// 授权头 + /// + private readonly string[] _authorizedHeaders; + + /// + /// 是否携带授权头判断 + /// + private readonly bool _withAuthorizationHeaderCheck; + + /// + /// 构造函数 + /// + /// + /// + /// + public UnifyResultStatusCodesMiddleware(RequestDelegate next + , string[] authorizedHeaders + , bool withAuthorizationHeaderCheck) + { + _next = next; + _authorizedHeaders = authorizedHeaders; + _withAuthorizationHeaderCheck = withAuthorizationHeaderCheck; + } + + /// + /// 中间件执行方法 + /// + /// + /// + public async Task InvokeAsync(HttpContext context) + { + await _next(context).ConfigureAwait(false); + + // 只有请求错误(短路状态码)和非 WebSocket 才支持规范化处理 + if (context.IsWebSocketRequest() + || context.Response.StatusCode < 400 + || context.Response.StatusCode == 404) return; + + // 仅针对特定的头进行处理 + if (_withAuthorizationHeaderCheck + && context.Response.StatusCode == StatusCodes.Status401Unauthorized + && !context.Response.Headers.Any(h => _authorizedHeaders.Contains(h.Key, StringComparer.OrdinalIgnoreCase))) + { + return; + } + + // 处理规范化结果 + if (!UnifyContext.CheckExceptionHttpContextNonUnify(context, out var unifyResult)) + { + // 解决刷新 Token 时间和 Token 时间相近问题 + if (context.Response.StatusCode == StatusCodes.Status401Unauthorized + && context.Response.Headers.ContainsKey("access-token") + && context.Response.Headers.ContainsKey("x-access-token")) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + } + + // 如果 Response 已经完成输出,则禁止写入 + if (context.Response.HasStarted) return; + + var statusCode = context.Response.StatusCode; + + // 获取授权失败设置的状态码 + var authorizationFailStatusCode = context.Items[AuthorizationHandlerContextExtensions.FAIL_STATUSCODE_KEY]; + if (authorizationFailStatusCode != null) + { + statusCode = Convert.ToInt32(authorizationFailStatusCode); + } + + await unifyResult.OnResponseStatusCodes(context, statusCode, context.RequestServices.GetService>()?.Value).ConfigureAwait(false); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Options/UnifyResultSettingsOptions.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Options/UnifyResultSettingsOptions.cs new file mode 100644 index 000000000..9ff4867e4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Options/UnifyResultSettingsOptions.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.UnifyResult; + +/// +/// 规范化配置选项 +/// +public sealed class UnifyResultSettingsOptions : IConfigurableOptions +{ + /// + /// 设置返回 200 状态码列表 + /// 默认:401,403,如果设置为 null,则标识所有状态码都返回 200 + /// + public int[] Return200StatusCodes { get; set; } + + /// + /// 适配(篡改)Http 状态码(只支持短路状态码,比如 401,403,500 等) + /// + public int[][] AdaptStatusCodes { get; set; } + + /// + /// 是否支持 MVC 控制台规范化处理 + /// + public bool? SupportMvcController { get; set; } + + /// + /// 默认只显示验证错误的首个消息 + /// + public bool? SingleValidationErrorDisplay { get; set; } + + /// + /// 选项后期配置 + /// + /// + /// + public void PostConfigure(UnifyResultSettingsOptions options, IConfiguration configuration) + { + options.Return200StatusCodes ??= new[] { 401, 403 }; + options.SupportMvcController ??= false; + options.SingleValidationErrorDisplay ??= false; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Providers/IUnifyResultProvider.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Providers/IUnifyResultProvider.cs new file mode 100644 index 000000000..86d19e1a6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Providers/IUnifyResultProvider.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +using ThingsGateway.DataValidation; +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.UnifyResult; + +/// +/// 规范化结果提供器 +/// +public interface IUnifyResultProvider +{ + /// + /// JWT 授权异常返回值 + /// + /// + /// + /// + IActionResult OnAuthorizeException(DefaultHttpContext context, ExceptionMetadata metadata); + + /// + /// 异常返回值 + /// + /// + /// + /// + IActionResult OnException(ExceptionContext context, ExceptionMetadata metadata); + + /// + /// 成功返回值 + /// + /// + /// + /// + IActionResult OnSucceeded(ActionExecutedContext context, object data); + + /// + /// 验证失败返回值 + /// + /// + /// + /// + IActionResult OnValidateFailed(ActionExecutingContext context, ValidationMetadata metadata); + + /// + /// 拦截返回状态码 + /// + /// + /// + /// + /// + Task OnResponseStatusCodes(HttpContext context, int statusCode, UnifyResultSettingsOptions unifyResultSettings = default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/Providers/RESTfulResultProvider.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/Providers/RESTfulResultProvider.cs new file mode 100644 index 000000000..6c29723a5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/Providers/RESTfulResultProvider.cs @@ -0,0 +1,126 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +using ThingsGateway.DataValidation; +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.UnifyResult; + +/// +/// RESTful 风格返回值 +/// +[SuppressSniffer, UnifyModel(typeof(RESTfulResult<>))] +public class RESTfulResultProvider : IUnifyResultProvider +{ + /// + /// JWT 授权异常返回值 + /// + /// + /// + /// + public IActionResult OnAuthorizeException(DefaultHttpContext context, ExceptionMetadata metadata) + { + return new JsonResult(RESTfulResult(metadata.StatusCode, data: metadata.Data, errors: metadata.Errors) + , UnifyContext.GetSerializerSettings(context)); + } + + /// + /// 异常返回值 + /// + /// + /// + /// + public IActionResult OnException(ExceptionContext context, ExceptionMetadata metadata) + { + return new JsonResult(RESTfulResult(metadata.StatusCode, data: metadata.Data, errors: metadata.Errors) + , UnifyContext.GetSerializerSettings(context)); + } + + /// + /// 成功返回值 + /// + /// + /// + /// + public IActionResult OnSucceeded(ActionExecutedContext context, object data) + { + return new JsonResult(RESTfulResult(StatusCodes.Status200OK, true, data) + , UnifyContext.GetSerializerSettings(context)); + } + + /// + /// 验证失败/业务异常返回值 + /// + /// + /// + /// + public IActionResult OnValidateFailed(ActionExecutingContext context, ValidationMetadata metadata) + { + return new JsonResult(RESTfulResult(metadata.StatusCode ?? StatusCodes.Status400BadRequest + , data: metadata.Data + , errors: !metadata.SingleValidationErrorDisplay ? metadata.ValidationResult : metadata.FirstErrorMessage) + , UnifyContext.GetSerializerSettings(context)); + } + + /// + /// 特定状态码返回值 + /// + /// + /// + /// + /// + public async Task OnResponseStatusCodes(HttpContext context, int statusCode, UnifyResultSettingsOptions unifyResultSettings) + { + // 设置响应状态码 + UnifyContext.SetResponseStatusCodes(context, statusCode, unifyResultSettings); + + switch (statusCode) + { + // 处理 401 状态码 + case StatusCodes.Status401Unauthorized: + await context.Response.WriteAsJsonAsync(RESTfulResult(statusCode, errors: "401 Unauthorized") + , App.GetOptions()?.JsonSerializerOptions).ConfigureAwait(false); + break; + // 处理 403 状态码 + case StatusCodes.Status403Forbidden: + await context.Response.WriteAsJsonAsync(RESTfulResult(statusCode, errors: "403 Forbidden") + , App.GetOptions()?.JsonSerializerOptions).ConfigureAwait(false); + break; + + default: break; + } + } + + /// + /// 返回 RESTful 风格结果集 + /// + /// + /// + /// + /// + /// + public static RESTfulResult RESTfulResult(int statusCode, bool succeeded = default, object data = default, object errors = default) + { + return new RESTfulResult + { + StatusCode = statusCode, + Succeeded = succeeded, + Data = data, + Errors = errors, + Extras = UnifyContext.Take(), + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + }; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/UnifyResult/UnifyContext.cs b/src/Admin/ThingsGateway.Furion/UnifyResult/UnifyContext.cs new file mode 100644 index 000000000..ad0783e3c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/UnifyResult/UnifyContext.cs @@ -0,0 +1,413 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApiExplorer; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using System.Collections.Concurrent; +using System.Reflection; + +using ThingsGateway.Extensions; +using ThingsGateway.FriendlyException; + +namespace ThingsGateway.UnifyResult; + +/// +/// 规范化结果上下文 +/// +[SuppressSniffer] +public static class UnifyContext +{ + /// + /// 是否启用规范化结果 + /// + internal static bool EnabledUnifyHandler = false; + + /// + /// 是否启用状态码拦截中间件 + /// + internal static bool EnabledStatusCodesMiddleware = false; + + /// + /// 规范化结果额外数据键 + /// + internal static string UnifyResultExtrasKey = "UNIFY_RESULT_EXTRAS"; + + /// + /// 规范化结果提供器 + /// + internal static ConcurrentDictionary UnifyProviders = new(); + + /// + /// 规范化序列化配置 + /// + internal static ConcurrentDictionary UnifySerializerSettings = new(); + + /// + /// 获取异常元数据 + /// + /// + /// + public static ExceptionMetadata GetExceptionMetadata(ActionContext context) + { + object errorCode = default; + object originErrorCode = default; + object errors = default; + object data = default; + var statusCode = StatusCodes.Status500InternalServerError; + var isValidationException = false; // 判断是否是验证异常 + + // 判断是否是 ExceptionContext 或者 ActionExecutedContext + var exception = context is ExceptionContext exContext + ? exContext.Exception + : ( + context is ActionExecutedContext edContext + ? edContext.Exception + : default + ); + + // 判断是否是友好异常 + if (exception is AppFriendlyException friendlyException) + { + errorCode = friendlyException.ErrorCode; + originErrorCode = friendlyException.OriginErrorCode; + statusCode = friendlyException.StatusCode; + isValidationException = friendlyException.ValidationException; + errors = friendlyException.ErrorMessage; + data = friendlyException.Data; + } + + return new ExceptionMetadata + { + StatusCode = statusCode, + ErrorCode = errorCode, + OriginErrorCode = originErrorCode, + Errors = errors, + Data = data, + Exception = exception + }; + } + + /// + /// 填充附加信息 + /// + /// + public static void Fill(object extras) + { + var items = App.HttpContext?.Items; + if (items == null) + { + return; + } + + if (items.ContainsKey(UnifyResultExtrasKey)) items.Remove(UnifyResultExtrasKey); + items.Add(UnifyResultExtrasKey, extras); + } + + /// + /// 读取附加信息 + /// + public static object Take() + { + object extras = null; + App.HttpContext?.Items?.TryGetValue(UnifyResultExtrasKey, out extras); + return extras; + } + + /// + /// 设置响应状态码 + /// + /// + /// + /// + public static void SetResponseStatusCodes(HttpContext context, int statusCode, UnifyResultSettingsOptions unifyResultSettings) + { + if (unifyResultSettings == null) return; + + // 篡改响应状态码 + if (unifyResultSettings.AdaptStatusCodes != null && unifyResultSettings.AdaptStatusCodes.Length > 0) + { + var adaptStatusCode = unifyResultSettings.AdaptStatusCodes.FirstOrDefault(u => u[0] == statusCode); + if (adaptStatusCode != null && adaptStatusCode.Length > 0 && adaptStatusCode[0] > 0) + { + context.Response.StatusCode = adaptStatusCode[1]; + return; + } + } + + // 如果为 null,则所有请求错误的状态码设置为 200 + if (unifyResultSettings.Return200StatusCodes == null) context.Response.StatusCode = 200; + // 否则只有里面的才设置为 200 + else if (unifyResultSettings.Return200StatusCodes.Contains(statusCode)) context.Response.StatusCode = 200; + else { } + } + + /// + /// 获取序列化配置 + /// + /// + /// + public static object GetSerializerSettings(FilterContext context) + { + // 获取控制器信息 + if (context.ActionDescriptor is not ControllerActionDescriptor actionDescriptor) return null; + + // 获取序列化配置 + var unifySerializerSettingAttribute = actionDescriptor.MethodInfo.GetFoundAttribute(true); + if (unifySerializerSettingAttribute == null || string.IsNullOrWhiteSpace(unifySerializerSettingAttribute.Name)) return null; + + // 解析全局配置 + var succeed = UnifySerializerSettings.TryGetValue(unifySerializerSettingAttribute.Name, out var serializerSettings); + return succeed ? serializerSettings : null; + } + + /// + /// 获取序列化配置 + /// + /// + /// + public static object GetSerializerSettings(string name) + { + // 解析全局配置 + var succeed = UnifySerializerSettings.TryGetValue(name, out var serializerSettings); + return succeed ? serializerSettings : null; + } + + /// + /// 获取序列化配置 + /// + /// + /// + public static object GetSerializerSettings(DefaultHttpContext context) + { + // 获取终点路由特性 + var endpointFeature = context?.Features?.Get(); + if (endpointFeature == null) return null; + + // 获取序列化配置 + var unifySerializerSettingAttribute = context.GetMetadata() ?? endpointFeature?.Endpoint?.Metadata?.GetMetadata(); + if (unifySerializerSettingAttribute == null || string.IsNullOrWhiteSpace(unifySerializerSettingAttribute.Name)) return null; + + // 解析全局配置 + var succeed = UnifySerializerSettings.TryGetValue(unifySerializerSettingAttribute.Name, out var serializerSettings); + return succeed ? serializerSettings : null; + } + + /// + /// 检查请求成功是否进行规范化处理 + /// + /// + /// + /// + /// 返回 true 跳过处理,否则进行规范化处理 + internal static bool CheckSucceededNonUnify(MethodInfo method, out IUnifyResultProvider unifyResult, bool isWebRequest = true) + { + // 解析规范化元数据 + var unityMetadata = GetMethodUnityMetadata(method); + + // 判断是否跳过规范化处理 + var isSkip = !EnabledUnifyHandler + || method.GetRealReturnType().HasImplementedRawGeneric(unityMetadata.ResultType) + || method.CustomAttributes.Any(x => typeof(NonUnifyAttribute).IsAssignableFrom(x.AttributeType) || typeof(ProducesResponseTypeAttribute).IsAssignableFrom(x.AttributeType) || typeof(IApiResponseMetadataProvider).IsAssignableFrom(x.AttributeType)) + || method.ReflectedType.IsDefined(typeof(NonUnifyAttribute), true) + || method.DeclaringType.Assembly.GetName().Name.StartsWith("Microsoft.AspNetCore.OData"); + + if (!isWebRequest) + { + unifyResult = null; + return isSkip; + } + + unifyResult = isSkip ? null : App.RootServices.GetService(unityMetadata.ProviderType) as IUnifyResultProvider; + return unifyResult == null || isSkip; + } + + /// + /// 检查请求失败(验证失败、抛异常)是否进行规范化处理 + /// + /// + /// + /// 返回 true 跳过处理,否则进行规范化处理 + internal static bool CheckFailedNonUnify(MethodInfo method, out IUnifyResultProvider unifyResult) + { + // 解析规范化元数据 + var unityMetadata = GetMethodUnityMetadata(method); + + // 判断是否跳过规范化处理 + var isSkip = !EnabledUnifyHandler + || method.CustomAttributes.Any(x => typeof(NonUnifyAttribute).IsAssignableFrom(x.AttributeType)) + || ( + !method.CustomAttributes.Any(x => typeof(ProducesResponseTypeAttribute).IsAssignableFrom(x.AttributeType) || typeof(IApiResponseMetadataProvider).IsAssignableFrom(x.AttributeType)) + && method.ReflectedType.IsDefined(typeof(NonUnifyAttribute), true) + ) + || method.DeclaringType.Assembly.GetName().Name.StartsWith("Microsoft.AspNetCore.OData"); + + unifyResult = isSkip ? null : App.RootServices.GetService(unityMetadata.ProviderType) as IUnifyResultProvider; + return unifyResult == null || isSkip; + } + + /// + /// 检查短路状态码(>=400)是否进行规范化处理 + /// + /// + /// + /// 返回 true 跳过处理,否则进行规范化处理 + internal static bool CheckExceptionHttpContextNonUnify(HttpContext context, out IUnifyResultProvider unifyResult) + { + // 获取终点路由特性 + var endpointFeature = context.Features?.Get(); + if (endpointFeature == null) return (unifyResult = null) == null; + + // 判断是否跳过规范化处理 + var isSkip = !EnabledUnifyHandler + || context.GetMetadata() != null + || endpointFeature?.Endpoint?.Metadata?.GetMetadata() != null + || context.Request.Headers["accept"].ToString().Contains("odata.metadata=", StringComparison.OrdinalIgnoreCase) + || context.Request.Headers["accept"].ToString().Contains("odata.streaming=", StringComparison.OrdinalIgnoreCase) + || ResponseContentTypesOfNonUnify.Any(u => context.Response.Headers["content-type"].ToString().Contains(u, StringComparison.OrdinalIgnoreCase)); + + if (isSkip == true) unifyResult = null; + else + { + // 解析规范化元数据 + var unifyProviderAttribute = endpointFeature?.Endpoint?.Metadata?.GetMetadata(); + UnifyProviders.TryGetValue(unifyProviderAttribute?.Name ?? string.Empty, out var unityMetadata); + + unifyResult = unityMetadata?.ProviderType == null + ? null + : context.RequestServices.GetService(unityMetadata.ProviderType) as IUnifyResultProvider; + } + + return unifyResult == null || isSkip; + } + + /// + /// 判断是否支持 Mvc 控制器规范化处理 + /// + /// + /// + /// + /// + internal static bool CheckSupportMvcController(HttpContext httpContext, ControllerActionDescriptor actionDescriptor, out UnifyResultSettingsOptions unifyResultSettings) + { + // 获取规范化配置选项 + unifyResultSettings = httpContext.RequestServices.GetService>()?.Value; + + // 如果未启用 MVC 规范化处理,则跳过 + if (unifyResultSettings?.SupportMvcController == false && typeof(Controller).IsAssignableFrom(actionDescriptor.ControllerTypeInfo)) return false; + + return true; + } + + /// + /// 跳过规范化处理的 Response Content-Type + /// + internal static string[] ResponseContentTypesOfNonUnify = new[] + { + "text/event-stream", + "application/pdf", + "application/octet-stream", + "image/" + }; + + /// + /// 检查 HttpContext 是否进行规范化处理 + /// + /// + /// 返回 true 跳过处理,否则进行规范化处理 + internal static bool CheckHttpContextNonUnify(HttpContext httpContext) + { + var contentType = httpContext.Response.Headers["content-type"].ToString(); + if (ResponseContentTypesOfNonUnify.Any(u => contentType.Contains(u, StringComparison.OrdinalIgnoreCase))) + { + return true; + } + + return false; + } + + /// + /// 检查是否是有效的结果(可进行规范化的结果) + /// + /// + /// + /// + internal static bool CheckVaildResult(IActionResult result, out object data) + { + data = default; + + // 排除以下结果,跳过规范化处理 + var isDataResult = result switch + { + ViewResult => false, + PartialViewResult => false, + FileResult => false, + ChallengeResult => false, + SignInResult => false, + SignOutResult => false, + RedirectToPageResult => false, + RedirectToRouteResult => false, + RedirectResult => false, + RedirectToActionResult => false, + LocalRedirectResult => false, + ForbidResult => false, + ViewComponentResult => false, + PageResult => false, + NotFoundResult => false, + NotFoundObjectResult => false, + _ => true, + }; + + // 目前支持返回值 ActionResult + if (isDataResult) data = result switch + { + // 处理内容结果 + ContentResult content => content.Content, + // 处理对象结果 + ObjectResult obj => obj.Value, + // 处理 JSON 对象 + JsonResult json => json.Value, + _ => null, + }; + + return isDataResult; + } + + /// + /// 获取方法规范化元数据 + /// + /// 如果追求性能,这里理应缓存起来,避免每次请求去检测 + /// + /// + internal static UnifyMetadata GetMethodUnityMetadata(MethodInfo method) + { + if (method == default) return default; + + var unityProviderAttribute = method.GetFoundAttribute(true); + + // 获取元数据 + var isExists = UnifyProviders.TryGetValue(unityProviderAttribute?.Name ?? string.Empty, out var metadata); + if (!isExists) + { + // 不存在则将默认的返回 + UnifyProviders.TryGetValue(string.Empty, out metadata); + } + + return metadata; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/AspNetCore/Extensions/HttpContextExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/AspNetCore/Extensions/HttpContextExtensions.cs new file mode 100644 index 000000000..3044cd580 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/AspNetCore/Extensions/HttpContextExtensions.cs @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +using System.Net; +using System.Text; + +namespace ThingsGateway.AspNetCore.Extensions; + +/// +/// 拓展类 +/// +public static class HttpContextExtensions +{ + /// + /// 获取完整的请求 URL 地址 + /// + /// + /// + /// + /// + /// + /// + public static string GetFullRequestUrl(this HttpRequest httpRequest) => + new StringBuilder() + .Append(httpRequest.Scheme) + .Append("://") + .Append(httpRequest.Host.Value) + .Append(httpRequest.PathBase) + .Append(httpRequest.Path) + .Append(httpRequest.QueryString) + .ToString(); + + /// + /// 获取响应状态文本 + /// + /// + /// + /// + /// + /// + /// + public static string? GetStatusText(this HttpResponse httpResponse) + { + // 获取响应状态码 + var statusCode = httpResponse.StatusCode; + + // 检查响应状态码是否是预设的 HttpStatusCode 值 + return !Enum.IsDefined(typeof(HttpStatusCode), statusCode) ? null : ((HttpStatusCode)statusCode).ToString(); + } + + /// + /// 配置允许跨域响应头 + /// + /// + /// + /// + public static void AllowCors(this HttpResponse httpResponse) + { + httpResponse.Headers.AccessControlAllowOrigin = "*"; + httpResponse.Headers.AccessControlAllowHeaders = "*"; + } + + /// + /// 添加响应头导出 + /// + /// + /// + /// + /// 键 + /// 值 + public static void AppendExpose(this IHeaderDictionary headers, string key, StringValues value) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + headers.AccessControlExposeHeaders = key; + headers.Append(key, value); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/AspNetCore/Extensions/IApplicationBuilderExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/AspNetCore/Extensions/IApplicationBuilderExtensions.cs new file mode 100644 index 000000000..7659016ef --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/AspNetCore/Extensions/IApplicationBuilderExtensions.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace ThingsGateway.AspNetCore.Extensions; + +/// +/// 拓展类 +/// +public static class IApplicationBuilderExtensions +{ + /// + /// 启用请求正文缓存 + /// + /// + /// 支持 HttpRequest.Body 重复读取。 + /// https://learn.microsoft.com/zh-cn/aspnet/core/fundamentals/use-http-context?view=aspnetcore-8.0#enable-request-body-buffering + /// + /// + /// + /// + /// + /// + /// + public static IApplicationBuilder UseEnableBuffering(this IApplicationBuilder app) => + app.Use(async (context, next) => + { + context.Request.EnableBuffering(); + context.Request.Body.Position = 0; + + await next.Invoke().ConfigureAwait(false); + }); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/AspNetCore/Formatters/TextPlainInputFormatter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/AspNetCore/Formatters/TextPlainInputFormatter.cs new file mode 100644 index 000000000..63fd7c211 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/AspNetCore/Formatters/TextPlainInputFormatter.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Formatters; + +using System.Net.Mime; +using System.Text; + +namespace ThingsGateway.AspNetCore.Formatters; + +/// +/// 从请求正文中读取 text/plain 内容 +/// +/// 参考文献:https://github.com/dotnet/aspnetcore/blob/main/src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonInputFormatter.cs。 +public class TextPlainInputFormatter : TextInputFormatter, IInputFormatterExceptionPolicy +{ + /// + public TextPlainInputFormatter() + { + SupportedEncodings.Add(UTF8EncodingWithoutBOM); + SupportedEncodings.Add(UTF16EncodingLittleEndian); + + SupportedMediaTypes.Add(MediaTypeNames.Text.Plain); + } + + /// + public InputFormatterExceptionPolicy ExceptionPolicy => InputFormatterExceptionPolicy.AllExceptions; + + /// + public sealed override async Task ReadRequestBodyAsync(InputFormatterContext context, + Encoding encoding) + { + // 空检查 + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(encoding); + + // 获取 HttpContext 实例 + var httpContext = context.HttpContext; + + // 获取输入的流 + var (inputStream, usesTranscodingStream) = GetInputStream(httpContext, encoding); + + string? data; + + try + { + // 读取流中的字符串 + using var streamReader = new StreamReader(inputStream); + data = await streamReader.ReadToEndAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + context.ModelState.TryAddModelError(string.Empty, ex, context.Metadata); + + return await InputFormatterResult.FailureAsync().ConfigureAwait(false); + } + finally + { + if (usesTranscodingStream) + { + await inputStream.DisposeAsync().ConfigureAwait(false); + } + } + + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (data is null && !context.TreatEmptyInputAsDefaultValue) + { + return await InputFormatterResult.NoValueAsync().ConfigureAwait(false); + } + + return await InputFormatterResult.SuccessAsync(data).ConfigureAwait(false); + } + + /// + /// 获取输入的流 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static (Stream inputStream, bool usesTranscodingStream) GetInputStream(HttpContext httpContext, + Encoding encoding) + { + if (encoding.CodePage == Encoding.UTF8.CodePage) + { + return (httpContext.Request.Body, false); + } + + var inputStream = Encoding.CreateTranscodingStream(httpContext.Request.Body, encoding, Encoding.UTF8, true); + + return (inputStream, true); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Attributes/AliasAsAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Attributes/AliasAsAttribute.cs new file mode 100644 index 000000000..ad145c208 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Attributes/AliasAsAttribute.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace System; + +/// +/// 设置别名特性 +/// +[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)] +public sealed class AliasAsAttribute : Attribute +{ + /// + /// + /// + /// 别名 + public AliasAsAttribute(string aliasAs) => AliasAs = aliasAs; + + /// + /// 别名 + /// + public string? AliasAs { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Converters/Json/ExpandoObjectJsonConverter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Converters/Json/ExpandoObjectJsonConverter.cs new file mode 100644 index 000000000..5d039e4f3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Converters/Json/ExpandoObjectJsonConverter.cs @@ -0,0 +1,293 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Dynamic; +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingsGateway.Converters.Json; + +/// +/// 类型 JSON 序列化转换器 +/// +public sealed class ExpandoObjectJsonConverter : JsonConverter +{ + /// + public override ExpandoObject? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + // ReSharper disable once SwitchExpressionHandlesSomeKnownEnumValuesWithExceptionInDefault + reader.TokenType switch + { + // 根据当前的 JSON 令牌类型,决定是读取对象还是数组 + JsonTokenType.StartObject => ReadObject(ref reader, options), + JsonTokenType.StartArray => ReadArrayAsExpandoObject(ref reader, options), + _ => throw new JsonException("Unexpected token type.") + }; + + /// + public override void Write(Utf8JsonWriter writer, ExpandoObject value, JsonSerializerOptions options) + { + // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + if (value == null) + { + writer.WriteNullValue(); + return; + } + + // 检查 ExpandoObject 是否包含 "Items" 属性 + if (((IDictionary)value).TryGetValue("Items", out var items) && + items is List itemList) + { + // 写出数组 + writer.WriteStartArray(); + foreach (var item in itemList) + { + WriteValue(writer, item, options); + } + + writer.WriteEndArray(); + } + else + { + // 写出对象 + writer.WriteStartObject(); + foreach (var kvp in value) + { + writer.WritePropertyName(kvp.Key); + WriteValue(writer, kvp.Value, options); + } + + writer.WriteEndObject(); + } + } + + /// + /// 读取 JSON 对象并将其转换为 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal ExpandoObject ReadObject(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var expandoObject = new ExpandoObject(); + IDictionary dictionary = expandoObject; + + // 遍历 JSON 对象的每个属性 + while (reader.Read()) + { + // 结束对象读取 + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name."); + } + + // 获取属性名 + var propertyName = reader.GetString()!; + + if (!reader.Read()) + { + throw new JsonException("Failed to read property value."); + } + + // 递归读取属性值 + var propertyValue = ReadValue(ref reader, options); + + // 将属性名和值添加到 ExpandoObject 中 + dictionary[propertyName] = propertyValue; + } + + return expandoObject; + } + + /// + /// 读取 JSON 值并根据其类型返回相应的对象 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal object? ReadValue(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault + switch (reader.TokenType) + { + // 读取字符串值 + case JsonTokenType.String: + return reader.GetString(); + case JsonTokenType.Number: + // 读取整数值 + if (reader.TryGetInt64(out var intValue)) + { + return intValue; + } + + // 读取浮点数值 + return reader.GetDouble(); + // 读取布尔值 true + case JsonTokenType.True: + return true; + // 读取布尔值 false + case JsonTokenType.False: + return false; + // 读取空值 + case JsonTokenType.Null: + return null; + // 递归读取嵌套对象 + case JsonTokenType.StartObject: + return ReadObject(ref reader, options); + // 读取数组 + case JsonTokenType.StartArray: + return ReadArrayAsList(ref reader, options); + default: + throw new JsonException("Unexpected token type."); + } + } + + /// + /// 读取 JSON 数组并将其转换为 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal List ReadArrayAsList(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var list = new List(); + + // 遍历数组中的每个元素 + while (reader.Read()) + { + // 结束数组读取 + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + + // 递归读取数组元素 + var item = ReadValue(ref reader, options); + + // 将元素添加到列表中 + list.Add(item); + } + + return list; + } + + /// + /// 读取 JSON 数组并将其转换为包含 "Items" 属性的 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal ExpandoObject ReadArrayAsExpandoObject(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var expandoObject = new ExpandoObject(); + IDictionary dictionary = expandoObject; + + // 读取数组 + var list = ReadArrayAsList(ref reader, options); + + // 将数组添加到 ExpandoObject 中 + dictionary["Items"] = list; + + return expandoObject; + } + + /// + /// 写出 JSON 值 + /// + /// + /// + /// + /// + /// 要写出的值 + /// + /// + /// + /// + private void WriteValue(Utf8JsonWriter writer, object? value, JsonSerializerOptions options) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case string stringValue: + writer.WriteStringValue(stringValue); + break; + case int intValue: + writer.WriteNumberValue(intValue); + break; + case long longValue: + writer.WriteNumberValue(longValue); + break; + case float floatValue: + writer.WriteNumberValue(floatValue); + break; + case double doubleValue: + writer.WriteNumberValue(doubleValue); + break; + case decimal decimalValue: + writer.WriteNumberValue(decimalValue); + break; + case bool boolValue: + writer.WriteBooleanValue(boolValue); + break; + case DateTime dateTimeValue: + // ISO 8601 格式 + writer.WriteStringValue(dateTimeValue.ToString("o", CultureInfo.InvariantCulture)); + break; + case ExpandoObject expandoValue: + Write(writer, expandoValue, options); + break; + case IEnumerable enumerableValue: + writer.WriteStartArray(); + foreach (var item in enumerableValue) + { + WriteValue(writer, item, options); + } + + writer.WriteEndArray(); + break; + default: + throw new JsonException($"Unsupported value type: {value.GetType().FullName}."); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Diagnostics/Debugging.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Diagnostics/Debugging.cs new file mode 100644 index 000000000..4e2075420 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Diagnostics/Debugging.cs @@ -0,0 +1,251 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics; + +namespace System; + +/// +/// 向事件管理器中输出事件信息 +/// +internal static class Debugging +{ + /// + /// 输出一行事件信息 + /// + /// + /// 信息级别 + /// + /// + /// 跟踪 + /// + /// + /// 信息 + /// + /// + /// 警告 + /// + /// + /// 错误 + /// + /// + /// 文件 + /// + /// + /// 提示 + /// + /// + /// 搜索 + /// + /// + /// 时钟 + /// + /// + /// + /// 事件信息 + internal static void WriteLine(int level, string message) + { + // 获取信息级别对应的 emoji + var category = GetLevelEmoji(level); + + Debug.WriteLine(message, category); + } + + /// + /// 输出一行事件信息 + /// + /// + /// 信息级别 + /// + /// + /// 跟踪 + /// + /// + /// 信息 + /// + /// + /// 警告 + /// + /// + /// 错误 + /// + /// + /// 文件 + /// + /// + /// 提示 + /// + /// + /// 搜索 + /// + /// + /// 时钟 + /// + /// + /// + /// 事件信息 + /// 格式化参数 + internal static void WriteLine(int level, string message, params object?[] args) => + WriteLine(level, string.Format(message, args)); + + /// + /// 输出跟踪级别事件信息 + /// + /// 事件信息 + internal static void Trace(string message) => WriteLine(1, message); + + /// + /// 输出跟踪级别事件信息 + /// + /// 事件信息 + /// 格式化参数 + internal static void Trace(string message, params object?[] args) => WriteLine(1, message, args); + + /// + /// 输出信息级别事件信息 + /// + /// 事件信息 + internal static void Info(string message) => WriteLine(2, message); + + /// + /// 输出信息级别事件信息 + /// + /// 事件信息 + /// 格式化参数 + internal static void Info(string message, params object?[] args) => WriteLine(2, message, args); + + /// + /// 输出警告级别事件信息 + /// + /// 事件信息 + internal static void Warn(string message) => WriteLine(3, message); + + /// + /// 输出警告级别事件信息 + /// + /// 事件信息 + /// 格式化参数 + internal static void Warn(string message, params object?[] args) => WriteLine(3, message, args); + + /// + /// 输出错误级别事件信息 + /// + /// 事件信息 + internal static void Error(string message) => WriteLine(4, message); + + /// + /// 输出错误级别事件信息 + /// + /// 事件信息 + /// 格式化参数 + internal static void Error(string message, params object?[] args) => WriteLine(4, message, args); + + /// + /// 输出文件级别事件信息 + /// + /// 事件信息 + internal static void File(string message) => WriteLine(5, message); + + /// + /// 输出文件级别事件信息 + /// + /// 事件信息 + /// 格式化参数 + internal static void File(string message, params object?[] args) => WriteLine(5, message, args); + + /// + /// 输出提示级别事件信息 + /// + /// 事件信息 + internal static void Tip(string message) => WriteLine(6, message); + + /// + /// 输出提示级别事件信息 + /// + /// 事件信息 + /// 格式化参数 + internal static void Tip(string message, params object?[] args) => WriteLine(6, message, args); + + /// + /// 输出搜索级别事件信息 + /// + /// 事件信息 + internal static void Search(string message) => WriteLine(7, message); + + /// + /// 输出搜索级别事件信息 + /// + /// 事件信息 + /// 格式化参数 + internal static void Search(string message, params object?[] args) => WriteLine(7, message, args); + + /// + /// 输出时钟级别事件信息 + /// + /// 事件信息 + internal static void Clock(string message) => WriteLine(8, message); + + /// + /// 输出时钟级别事件信息 + /// + /// 事件信息 + /// 格式化参数 + internal static void Clock(string message, params object?[] args) => WriteLine(8, message, args); + + /// + /// 获取信息级别对应的 emoji + /// + /// + /// 信息级别 + /// + /// + /// 跟踪 + /// + /// + /// 信息 + /// + /// + /// 警告 + /// + /// + /// 错误 + /// + /// + /// 文件 + /// + /// + /// 提示 + /// + /// + /// 搜索 + /// + /// + /// 时钟 + /// + /// + /// + /// + /// + /// + internal static string GetLevelEmoji(int level) => + level switch + { + 1 => "🛠️", + 2 => "ℹ️", + 3 => "⚠️", + 4 => "❌", + 5 => "📄", + 6 => "💡", + 7 => "🔍", + 8 => "⏱️", + _ => string.Empty + }; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/DispatchProxy/AsyncDispatchProxyGenerator.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/DispatchProxy/AsyncDispatchProxyGenerator.cs new file mode 100644 index 000000000..687259bb6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/DispatchProxy/AsyncDispatchProxyGenerator.cs @@ -0,0 +1,1262 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// ReSharper disable ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract + +// ReSharper disable NotResolvedInText + +#pragma warning disable + +using System.Diagnostics; +using System.Reflection.Emit; +using System.Runtime.ExceptionServices; + +namespace System.Reflection; + +public class DispatchProxyHandler +{ + public object InvokeHandle(object[] args) => AsyncDispatchProxyGenerator.Invoke(args); + + public Task InvokeAsyncHandle(object[] args) => AsyncDispatchProxyGenerator.InvokeAsync(args); + + public Task InvokeAsyncHandleT(object[] args) => AsyncDispatchProxyGenerator.InvokeAsync(args); +} + +// Helper class to handle the IL EMIT for the generation of proxies. +// Much of this code was taken directly from the Silverlight proxy generation. +// Differences between this and the Silverlight version are: +// 1. This version is based on DispatchProxy from NET Native and CoreCLR, not RealProxy in Silverlight ServiceModel. +// There are several notable differences between them. +// 2. Both DispatchProxy and RealProxy permit the caller to ask for a proxy specifying a pair of types: +// the interface type to implement, and a base type. But they behave slightly differently: +// - RealProxy generates a proxy type that derives from Object and *implements" all the base type's +// interfaces plus all the interface type's interfaces. +// - DispatchProxy generates a proxy type that *derives* from the base type and implements all +// the interface type's interfaces. This is true for both the CLR version in NET Native and this +// version for CoreCLR. +// 3. DispatchProxy and RealProxy use different type hierarchies for the generated proxies: +// - RealProxy type hierarchy is: +// proxyType : proxyBaseType : object +// Presumably the 'proxyBaseType' in the middle is to allow it to implement the base type's interfaces +// explicitly, preventing collision for same name methods on the base and interface types. +// - DispatchProxy hierarchy is: +// proxyType : baseType (where baseType : DispatchProxy) +// The generated DispatchProxy proxy type does not need to generate implementation methods +// for the base type's interfaces, because the base type already must have implemented them. +// 4. RealProxy required a proxy instance to hold a backpointer to the RealProxy instance to mirror +// the .Net Remoting design that required the proxy and RealProxy to be separate instances. +// But the DispatchProxy design encourages the proxy type to *be* an DispatchProxy. Therefore, +// the proxy's 'this' becomes the equivalent of RealProxy's backpointer to RealProxy, so we were +// able to remove an extraneous field and ctor arg from the DispatchProxy proxies. +// +internal static class AsyncDispatchProxyGenerator +{ + private const int InvokeActionFieldAndCtorParameterIndex = 0; + + // Proxies are requested for a pair of types: base type and interface type. + // The generated proxy will subclass the given base type and implement the interface type. + // We maintain a cache keyed by 'base type' containing a dictionary keyed by interface type, + // containing the generated proxy type for that pair. There are likely to be few (maybe only 1) + // base type in use for many interface types. + // Note: this differs from Silverlight's RealProxy implementation which keys strictly off the + // interface type. But this does not allow the same interface type to be used with more than a + // single base type. The implementation here permits multiple interface types to be used with + // multiple base types, and the generated proxy types will be unique. + // This cache of generated types grows unbounded, one element per unique T/ProxyT pair. + // This approach is used to prevent regenerating identical proxy types for identical T/Proxy pairs, + // which would ultimately be a more expensive leak. + // Proxy instances are not cached. Their lifetime is entirely owned by the caller of DispatchProxy.Create. + private static readonly Dictionary> s_baseTypeAndInterfaceToGeneratedProxyType = new(); + + private static readonly ProxyAssembly s_proxyAssembly = new(); + + private static readonly MethodInfo s_dispatchProxyInvokeMethod = + typeof(DispatchProxyAsync).GetTypeInfo().GetDeclaredMethod("Invoke"); + + private static readonly MethodInfo s_dispatchProxyInvokeAsyncMethod = + typeof(DispatchProxyAsync).GetTypeInfo().GetDeclaredMethod("InvokeAsync"); + + private static readonly MethodInfo s_dispatchProxyInvokeAsyncTMethod = + typeof(DispatchProxyAsync).GetTypeInfo().GetDeclaredMethod("InvokeAsyncT"); + + // Returns a new instance of a proxy the derives from 'baseType' and implements 'interfaceType' + internal static object CreateProxyInstance(Type baseType, Type interfaceType) + { + Debug.Assert(baseType != null); + Debug.Assert(interfaceType != null); + + var proxiedType = GetProxyType(baseType, interfaceType); + return Activator.CreateInstance(proxiedType, new DispatchProxyHandler()); + } + + private static Type GetProxyType(Type baseType, Type interfaceType) + { + lock (s_baseTypeAndInterfaceToGeneratedProxyType) + { + if (!s_baseTypeAndInterfaceToGeneratedProxyType.TryGetValue(baseType, out var interfaceToProxy)) + { + interfaceToProxy = new Dictionary(); + s_baseTypeAndInterfaceToGeneratedProxyType[baseType] = interfaceToProxy; + } + + if (!interfaceToProxy.TryGetValue(interfaceType, out var generatedProxy)) + { + generatedProxy = GenerateProxyType(baseType, interfaceType); + interfaceToProxy[interfaceType] = generatedProxy; + } + + return generatedProxy; + } + } + + // Unconditionally generates a new proxy type derived from 'baseType' and implements 'interfaceType' + private static Type GenerateProxyType(Type baseType, Type interfaceType) + { + // Parameter validation is deferred until the point we need to create the proxy. + // This prevents unnecessary overhead revalidating cached proxy types. + var baseTypeInfo = baseType.GetTypeInfo(); + + // The interface type must be an interface, not a class + if (!interfaceType.GetTypeInfo().IsInterface) + { + // "T" is the generic parameter seen via the public contract + throw new ArgumentException($"InterfaceType_Must_Be_Interface, {interfaceType.FullName}", "T"); + } + + // The base type cannot be sealed because the proxy needs to subclass it. + if (baseTypeInfo.IsSealed) + { + // "TProxy" is the generic parameter seen via the public contract + throw new ArgumentException($"BaseType_Cannot_Be_Sealed, {baseTypeInfo.FullName}", "TProxy"); + } + + // The base type cannot be abstract + if (baseTypeInfo.IsAbstract) + { + throw new ArgumentException($"BaseType_Cannot_Be_Abstract {baseType.FullName}", "TProxy"); + } + + // The base type must have a public default ctor + if (!baseTypeInfo.DeclaredConstructors.Any(c => c.IsPublic && c.GetParameters().Length == 0)) + { + throw new ArgumentException($"BaseType_Must_Have_Default_Ctor {baseType.FullName}", "TProxy"); + } + + // Create a type that derives from 'baseType' provided by caller + var pb = s_proxyAssembly.CreateProxy("generatedProxy", baseType); + + foreach (var t in interfaceType.GetTypeInfo().ImplementedInterfaces) + { + pb.AddInterfaceImpl(t); + } + + pb.AddInterfaceImpl(interfaceType); + + var generatedProxyType = pb.CreateType(); + return generatedProxyType; + } + + private static ProxyMethodResolverContext Resolve(object[] args) + { + var packed = new PackedArgs(args); + var method = s_proxyAssembly.ResolveMethodToken(packed.DeclaringType, packed.MethodToken); + if (method.IsGenericMethodDefinition) + { + method = ((MethodInfo)method).MakeGenericMethod(packed.GenericTypes); + } + + return new ProxyMethodResolverContext(packed, method); + } + + public static object Invoke(object[] args) + { + var context = Resolve(args); + + // Call (protected method) DispatchProxyAsync.Invoke() + object returnValue = null; + try + { + Debug.Assert(s_dispatchProxyInvokeMethod != null); + returnValue = s_dispatchProxyInvokeMethod?.Invoke(context.Packed.DispatchProxy, + new object[] { context.Method, context.Packed.Args }); + context.Packed.ReturnValue = returnValue; + } + catch (TargetInvocationException tie) + { + ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + } + + return returnValue; + } + + public static async Task InvokeAsync(object[] args) + { + var context = Resolve(args); + + // Call (protected Task method) NetCoreStackDispatchProxy.InvokeAsync() + try + { + Debug.Assert(s_dispatchProxyInvokeAsyncMethod != null); + await (Task)s_dispatchProxyInvokeAsyncMethod.Invoke(context.Packed.DispatchProxy, + new object[] { context.Method, context.Packed.Args }); + } + catch (TargetInvocationException tie) + { + ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + } + } + + public static async Task InvokeAsync(object[] args) + { + var context = Resolve(args); + + // Call (protected Task method) NetCoreStackDispatchProxy.InvokeAsync() + var returnValue = default(T); + try + { + Debug.Assert(s_dispatchProxyInvokeAsyncTMethod != null); + var genericmethod = s_dispatchProxyInvokeAsyncTMethod.MakeGenericMethod(typeof(T)); + returnValue = await (Task)genericmethod.Invoke(context.Packed.DispatchProxy, + new object[] { context.Method, context.Packed.Args }); + context.Packed.ReturnValue = returnValue; + } + catch (TargetInvocationException tie) + { + ExceptionDispatchInfo.Capture(tie.InnerException).Throw(); + } + + return returnValue; + } + + private class ProxyMethodResolverContext(PackedArgs packed, MethodBase method) + { + public PackedArgs Packed { get; } = packed; + public MethodBase Method { get; } = method; + } + + private class PackedArgs + { + internal const int DispatchProxyPosition = 0; + internal const int DeclaringTypePosition = 1; + internal const int MethodTokenPosition = 2; + internal const int ArgsPosition = 3; + internal const int GenericTypesPosition = 4; + internal const int ReturnValuePosition = 5; + + internal static readonly Type[] PackedTypes = + { + typeof(object), typeof(Type), typeof(int), typeof(object[]), typeof(Type[]), typeof(object) + }; + + private readonly object[] _args; + + internal PackedArgs() : this(new object[PackedTypes.Length]) + { + } + + internal PackedArgs(object[] args) => _args = args; + + internal DispatchProxyAsync DispatchProxy => (DispatchProxyAsync)_args[DispatchProxyPosition]; + + internal Type DeclaringType => (Type)_args[DeclaringTypePosition]; + + internal int MethodToken => (int)_args[MethodTokenPosition]; + + internal object[] Args => (object[])_args[ArgsPosition]; + + internal Type[] GenericTypes => (Type[])_args[GenericTypesPosition]; + + internal object ReturnValue + { + /*get { return args[ReturnValuePosition]; }*/ + set => _args[ReturnValuePosition] = value; + } + } + + private class ProxyAssembly + { + public readonly AssemblyBuilder _ab; + private readonly HashSet _ignoresAccessAssemblyNames = new(); + private readonly ModuleBuilder _mb; + private readonly List _methodsByToken = new(); + + // Maintain a MethodBase-->int, int-->MethodBase mapping to permit generated code + // to pass methods by token + private readonly Dictionary _methodToToken = new(); + private ConstructorInfo _ignoresAccessChecksToAttributeConstructor; + private int _typeId; + + public ProxyAssembly() + { + var access = AssemblyBuilderAccess.Run; + var assemblyName = new AssemblyName("ProxyBuilder2"); + assemblyName.Version = new Version(1, 0, 0); + _ab = AssemblyBuilder.DefineDynamicAssembly(assemblyName, access); + _mb = _ab.DefineDynamicModule("testmod"); + } + + // Gets or creates the ConstructorInfo for the IgnoresAccessChecksAttribute. + // This attribute is both defined and referenced in the dynamic assembly to + // allow access to internal types in other assemblies. + internal ConstructorInfo IgnoresAccessChecksAttributeConstructor + { + get + { + if (_ignoresAccessChecksToAttributeConstructor == null) + { + var attributeTypeInfo = GenerateTypeInfoOfIgnoresAccessChecksToAttribute(); + _ignoresAccessChecksToAttributeConstructor = attributeTypeInfo.DeclaredConstructors.Single(); + } + + return _ignoresAccessChecksToAttributeConstructor; + } + } + + public ProxyBuilder CreateProxy(string name, Type proxyBaseType) + { + var nextId = Interlocked.Increment(ref _typeId); + var tb = _mb.DefineType(name + "_" + nextId, TypeAttributes.Public, proxyBaseType); + return new ProxyBuilder(this, tb, proxyBaseType); + } + + // Generate the declaration for the IgnoresAccessChecksToAttribute type. + // This attribute will be both defined and used in the dynamic assembly. + // Each usage identifies the name of the assembly containing non-public + // types the dynamic assembly needs to access. Normally those types + // would be inaccessible, but this attribute allows them to be visible. + // It works like a reverse InternalsVisibleToAttribute. + // This method returns the TypeInfo of the generated attribute. + private TypeInfo GenerateTypeInfoOfIgnoresAccessChecksToAttribute() + { + var attributeTypeBuilder = + _mb.DefineType("System.Runtime.CompilerServices.IgnoresAccessChecksToAttribute", + TypeAttributes.Public | TypeAttributes.Class, + typeof(Attribute)); + + // Create backing field as: + // private string assemblyName; + var assemblyNameField = + attributeTypeBuilder.DefineField("assemblyName", typeof(string), FieldAttributes.Private); + + // Create ctor as: + // public IgnoresAccessChecksToAttribute(string) + var constructorBuilder = attributeTypeBuilder.DefineConstructor(MethodAttributes.Public, + CallingConventions.HasThis, + new[] { assemblyNameField.FieldType }); + + var il = constructorBuilder.GetILGenerator(); + + // Create ctor body as: + // this.assemblyName = {ctor parameter 0} + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldarg, 1); + il.Emit(OpCodes.Stfld, assemblyNameField); + + // return + il.Emit(OpCodes.Ret); + + // Define property as: + // public string AssemblyName {get { return this.assemblyName; } } + var getterPropertyBuilder = attributeTypeBuilder.DefineProperty( + "AssemblyName", + PropertyAttributes.None, + CallingConventions.HasThis, + typeof(string), + null); + + var getterMethodBuilder = attributeTypeBuilder.DefineMethod( + "get_AssemblyName", + MethodAttributes.Public, + CallingConventions.HasThis, + typeof(string), + null); + + // Generate body: + // return this.assemblyName; + il = getterMethodBuilder.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, assemblyNameField); + il.Emit(OpCodes.Ret); + + // Generate the AttributeUsage attribute for this attribute type: + // [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + var attributeUsageTypeInfo = typeof(AttributeUsageAttribute).GetTypeInfo(); + + // Find the ctor that takes only AttributeTargets + var attributeUsageConstructorInfo = + attributeUsageTypeInfo.DeclaredConstructors + .Single(c => c.GetParameters().Count() == 1 && + c.GetParameters()[0].ParameterType == typeof(AttributeTargets)); + + // Find the property to set AllowMultiple + var allowMultipleProperty = + attributeUsageTypeInfo.DeclaredProperties + .Single(f => string.Equals(f.Name, "AllowMultiple")); + + // Create a builder to construct the instance via the ctor and property + var customAttributeBuilder = + new CustomAttributeBuilder(attributeUsageConstructorInfo, + new object[] { AttributeTargets.Assembly }, + new[] { allowMultipleProperty }, + new object[] { true }); + + // Attach this attribute instance to the newly defined attribute type + attributeTypeBuilder.SetCustomAttribute(customAttributeBuilder); + + // Make the TypeInfo real so the constructor can be used. + return attributeTypeBuilder.CreateTypeInfo(); + } + + // Generates an instance of the IgnoresAccessChecksToAttribute to + // identify the given assembly as one which contains internal types + // the dynamic assembly will need to reference. + internal void GenerateInstanceOfIgnoresAccessChecksToAttribute(string assemblyName) + { + // Add this assembly level attribute: + // [assembly: System.Runtime.CompilerServices.IgnoresAccessChecksToAttribute(assemblyName)] + var attributeConstructor = IgnoresAccessChecksAttributeConstructor; + var customAttributeBuilder = + new CustomAttributeBuilder(attributeConstructor, new object[] { assemblyName }); + _ab.SetCustomAttribute(customAttributeBuilder); + } + + // Ensures the type we will reference from the dynamic assembly + // is visible. Non-public types need to emit an attribute that + // allows access from the dynamic assembly. + internal void EnsureTypeIsVisible(Type type) + { + var typeInfo = type.GetTypeInfo(); + if (!typeInfo.IsVisible) + { + var assemblyName = typeInfo.Assembly.GetName().Name; + if (!_ignoresAccessAssemblyNames.Contains(assemblyName)) + { + GenerateInstanceOfIgnoresAccessChecksToAttribute(assemblyName); + _ignoresAccessAssemblyNames.Add(assemblyName); + } + } + } + + internal void GetTokenForMethod(MethodBase method, out Type type, out int token) + { + type = method.DeclaringType; + token = 0; + if (!_methodToToken.TryGetValue(method, out token)) + { + _methodsByToken.Add(method); + token = _methodsByToken.Count - 1; + _methodToToken[method] = token; + } + } + + internal MethodBase ResolveMethodToken(Type type, int token) + { + Debug.Assert(token >= 0 && token < _methodsByToken.Count); + return _methodsByToken[token]; + } + } + + private class ProxyBuilder + { + private static readonly MethodInfo + s_delegateInvoke = typeof(DispatchProxyHandler).GetMethod("InvokeHandle"); + + private static readonly MethodInfo s_delegateInvokeAsync = + typeof(DispatchProxyHandler).GetMethod("InvokeAsyncHandle"); + + private static readonly MethodInfo s_delegateinvokeAsyncT = + typeof(DispatchProxyHandler).GetMethod("InvokeAsyncHandleT"); + + private static readonly OpCode[] s_convOpCodes = + { + OpCodes.Nop, //Empty = 0, + OpCodes.Nop, //Object = 1, + OpCodes.Nop, //DBNull = 2, + OpCodes.Conv_I1, //Boolean = 3, + OpCodes.Conv_I2, //Char = 4, + OpCodes.Conv_I1, //SByte = 5, + OpCodes.Conv_U1, //Byte = 6, + OpCodes.Conv_I2, //Int16 = 7, + OpCodes.Conv_U2, //UInt16 = 8, + OpCodes.Conv_I4, //Int32 = 9, + OpCodes.Conv_U4, //UInt32 = 10, + OpCodes.Conv_I8, //Int64 = 11, + OpCodes.Conv_U8, //UInt64 = 12, + OpCodes.Conv_R4, //Single = 13, + OpCodes.Conv_R8, //Double = 14, + OpCodes.Nop, //Decimal = 15, + OpCodes.Nop, //DateTime = 16, + OpCodes.Nop, //17 + OpCodes.Nop //String = 18, + }; + + private static readonly OpCode[] s_ldindOpCodes = + { + OpCodes.Nop, //Empty = 0, + OpCodes.Nop, //Object = 1, + OpCodes.Nop, //DBNull = 2, + OpCodes.Ldind_I1, //Boolean = 3, + OpCodes.Ldind_I2, //Char = 4, + OpCodes.Ldind_I1, //SByte = 5, + OpCodes.Ldind_U1, //Byte = 6, + OpCodes.Ldind_I2, //Int16 = 7, + OpCodes.Ldind_U2, //UInt16 = 8, + OpCodes.Ldind_I4, //Int32 = 9, + OpCodes.Ldind_U4, //UInt32 = 10, + OpCodes.Ldind_I8, //Int64 = 11, + OpCodes.Ldind_I8, //UInt64 = 12, + OpCodes.Ldind_R4, //Single = 13, + OpCodes.Ldind_R8, //Double = 14, + OpCodes.Nop, //Decimal = 15, + OpCodes.Nop, //DateTime = 16, + OpCodes.Nop, //17 + OpCodes.Ldind_Ref //String = 18, + }; + + private static readonly OpCode[] s_stindOpCodes = + { + OpCodes.Nop, //Empty = 0, + OpCodes.Nop, //Object = 1, + OpCodes.Nop, //DBNull = 2, + OpCodes.Stind_I1, //Boolean = 3, + OpCodes.Stind_I2, //Char = 4, + OpCodes.Stind_I1, //SByte = 5, + OpCodes.Stind_I1, //Byte = 6, + OpCodes.Stind_I2, //Int16 = 7, + OpCodes.Stind_I2, //UInt16 = 8, + OpCodes.Stind_I4, //Int32 = 9, + OpCodes.Stind_I4, //UInt32 = 10, + OpCodes.Stind_I8, //Int64 = 11, + OpCodes.Stind_I8, //UInt64 = 12, + OpCodes.Stind_R4, //Single = 13, + OpCodes.Stind_R8, //Double = 14, + OpCodes.Nop, //Decimal = 15, + OpCodes.Nop, //DateTime = 16, + OpCodes.Nop, //17 + OpCodes.Stind_Ref //String = 18, + }; + + private readonly ProxyAssembly _assembly; + private readonly List _fields; + private readonly Type _proxyBaseType; + private readonly TypeBuilder _tb; + + internal ProxyBuilder(ProxyAssembly assembly, TypeBuilder tb, Type proxyBaseType) + { + _assembly = assembly; + _tb = tb; + _proxyBaseType = proxyBaseType; + + _fields = new List(); + _fields.Add(tb.DefineField("_handler", typeof(DispatchProxyHandler), FieldAttributes.Private)); + } + + private static bool IsGenericTask(Type type) + { + var current = type; + while (current != null) + { + if (current.GetTypeInfo().IsGenericType && current.GetGenericTypeDefinition() == typeof(Task<>)) + { + return true; + } + + current = current.GetTypeInfo().BaseType; + } + + return false; + } + + private void Complete() + { + var args = new Type[_fields.Count]; + for (var i = 0; i < args.Length; i++) + { + args[i] = _fields[i].FieldType; + } + + var cb = + _tb.DefineConstructor(MethodAttributes.Public, CallingConventions.HasThis, args); + var il = cb.GetILGenerator(); + + // chained ctor call + var baseCtor = _proxyBaseType.GetTypeInfo().DeclaredConstructors + .SingleOrDefault(c => c.IsPublic && c.GetParameters().Length == 0); + Debug.Assert(baseCtor != null); + + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, baseCtor); + + // store all the fields + for (var i = 0; i < args.Length; i++) + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldarg, i + 1); + il.Emit(OpCodes.Stfld, _fields[i]); + } + + il.Emit(OpCodes.Ret); + } + + internal Type CreateType() + { + Complete(); + return _tb.CreateTypeInfo().AsType(); + } + + internal void AddInterfaceImpl(Type iface) + { + // If necessary, generate an attribute to permit visibility + // to internal types. + _assembly.EnsureTypeIsVisible(iface); + + _tb.AddInterfaceImplementation(iface); + + // AccessorMethods -> Metadata mappings. + var propertyMap = new Dictionary(MethodInfoEqualityComparer.Instance); + foreach (var pi in iface.GetRuntimeProperties()) + { + var ai = new PropertyAccessorInfo(pi.GetMethod, pi.SetMethod); + if (pi.GetMethod != null) + { + propertyMap[pi.GetMethod] = ai; + } + + if (pi.SetMethod != null) + { + propertyMap[pi.SetMethod] = ai; + } + } + + var eventMap = new Dictionary(MethodInfoEqualityComparer.Instance); + foreach (var ei in iface.GetRuntimeEvents()) + { + var ai = new EventAccessorInfo(ei.AddMethod, ei.RemoveMethod, ei.RaiseMethod); + if (ei.AddMethod != null) + { + eventMap[ei.AddMethod] = ai; + } + + if (ei.RemoveMethod != null) + { + eventMap[ei.RemoveMethod] = ai; + } + + if (ei.RaiseMethod != null) + { + eventMap[ei.RaiseMethod] = ai; + } + } + + foreach (var mi in iface.GetRuntimeMethods()) + { + var mdb = AddMethodImpl(mi); + PropertyAccessorInfo associatedProperty; + if (propertyMap.TryGetValue(mi, out associatedProperty)) + { + if (MethodInfoEqualityComparer.Instance.Equals(associatedProperty.InterfaceGetMethod, mi)) + { + associatedProperty.GetMethodBuilder = mdb; + } + else + { + associatedProperty.SetMethodBuilder = mdb; + } + } + + EventAccessorInfo associatedEvent; + if (eventMap.TryGetValue(mi, out associatedEvent)) + { + if (MethodInfoEqualityComparer.Instance.Equals(associatedEvent.InterfaceAddMethod, mi)) + { + associatedEvent.AddMethodBuilder = mdb; + } + else if (MethodInfoEqualityComparer.Instance.Equals(associatedEvent.InterfaceRemoveMethod, mi)) + { + associatedEvent.RemoveMethodBuilder = mdb; + } + else + { + associatedEvent.RaiseMethodBuilder = mdb; + } + } + } + + foreach (var pi in iface.GetRuntimeProperties()) + { + var ai = propertyMap[pi.GetMethod ?? pi.SetMethod]; + var pb = _tb.DefineProperty(pi.Name, pi.Attributes, pi.PropertyType, + pi.GetIndexParameters().Select(p => p.ParameterType).ToArray()); + if (ai.GetMethodBuilder != null) + { + pb.SetGetMethod(ai.GetMethodBuilder); + } + + if (ai.SetMethodBuilder != null) + { + pb.SetSetMethod(ai.SetMethodBuilder); + } + } + + foreach (var ei in iface.GetRuntimeEvents()) + { + var ai = eventMap[ei.AddMethod ?? ei.RemoveMethod]; + var eb = _tb.DefineEvent(ei.Name, ei.Attributes, ei.EventHandlerType); + if (ai.AddMethodBuilder != null) + { + eb.SetAddOnMethod(ai.AddMethodBuilder); + } + + if (ai.RemoveMethodBuilder != null) + { + eb.SetRemoveOnMethod(ai.RemoveMethodBuilder); + } + + if (ai.RaiseMethodBuilder != null) + { + eb.SetRaiseMethod(ai.RaiseMethodBuilder); + } + } + } + + private MethodBuilder AddMethodImpl(MethodInfo mi) + { + var parameters = mi.GetParameters(); + var paramTypes = ParamTypes(parameters, false); + + var mdb = _tb.DefineMethod(mi.Name, MethodAttributes.Public | MethodAttributes.Virtual, + mi.ReturnType, paramTypes); + if (mi.ContainsGenericParameters) + { + var ts = mi.GetGenericArguments(); + var ss = new string[ts.Length]; + for (var i = 0; i < ts.Length; i++) + { + ss[i] = ts[i].Name; + } + + var genericParameters = mdb.DefineGenericParameters(ss); + for (var i = 0; i < genericParameters.Length; i++) + { + genericParameters[i] + .SetGenericParameterAttributes(ts[i].GetTypeInfo().GenericParameterAttributes); + } + } + + var il = mdb.GetILGenerator(); + + var args = new ParametersArray(il, paramTypes); + + // object[] args = new object[paramCount]; + il.Emit(OpCodes.Nop); + var argsArr = new GenericArray(il, ParamTypes(parameters, true).Length); + + for (var i = 0; i < parameters.Length; i++) + { + // args[i] = argi; + if (!parameters[i].IsOut) + { + argsArr.BeginSet(i); + args.Get(i); + argsArr.EndSet(parameters[i].ParameterType); + } + } + + // object[] packed = new object[PackedArgs.PackedTypes.Length]; + var packedArr = new GenericArray(il, PackedArgs.PackedTypes.Length); + + // packed[PackedArgs.DispatchProxyPosition] = this; + packedArr.BeginSet(PackedArgs.DispatchProxyPosition); + il.Emit(OpCodes.Ldarg_0); + packedArr.EndSet(typeof(DispatchProxyAsync)); + + // packed[PackedArgs.DeclaringTypePosition] = typeof(iface); + var Type_GetTypeFromHandle = + typeof(Type).GetRuntimeMethod("GetTypeFromHandle", new[] { typeof(RuntimeTypeHandle) }); + int methodToken; + Type declaringType; + _assembly.GetTokenForMethod(mi, out declaringType, out methodToken); + packedArr.BeginSet(PackedArgs.DeclaringTypePosition); + il.Emit(OpCodes.Ldtoken, declaringType); + il.Emit(OpCodes.Call, Type_GetTypeFromHandle); + packedArr.EndSet(typeof(object)); + + // packed[PackedArgs.MethodTokenPosition] = iface method token; + packedArr.BeginSet(PackedArgs.MethodTokenPosition); + il.Emit(OpCodes.Ldc_I4, methodToken); + packedArr.EndSet(typeof(int)); + + // packed[PackedArgs.ArgsPosition] = args; + packedArr.BeginSet(PackedArgs.ArgsPosition); + argsArr.Load(); + packedArr.EndSet(typeof(object[])); + + // packed[PackedArgs.GenericTypesPosition] = mi.GetGenericArguments(); + if (mi.ContainsGenericParameters) + { + packedArr.BeginSet(PackedArgs.GenericTypesPosition); + var genericTypes = mi.GetGenericArguments(); + var typeArr = new GenericArray(il, genericTypes.Length); + for (var i = 0; i < genericTypes.Length; ++i) + { + typeArr.BeginSet(i); + il.Emit(OpCodes.Ldtoken, genericTypes[i]); + il.Emit(OpCodes.Call, Type_GetTypeFromHandle); + typeArr.EndSet(typeof(Type)); + } + + typeArr.Load(); + packedArr.EndSet(typeof(Type[])); + } + + for (var i = 0; i < parameters.Length; i++) + { + if (parameters[i].ParameterType.IsByRef) + { + args.BeginSet(i); + argsArr.Get(i); + args.EndSet(i, typeof(object)); + } + } + + var invokeMethod = s_delegateInvoke; + if (mi.ReturnType == typeof(Task)) + { + invokeMethod = s_delegateInvokeAsync; + } + + if (IsGenericTask(mi.ReturnType)) + { + var returnTypes = mi.ReturnType.GetGenericArguments(); + invokeMethod = s_delegateinvokeAsyncT.MakeGenericMethod(returnTypes); + } + + // Call AsyncDispatchProxyGenerator.Invoke(object[]), InvokeAsync or InvokeAsyncT + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Ldfld, _fields[InvokeActionFieldAndCtorParameterIndex]); + packedArr.Load(); + il.Emit(OpCodes.Callvirt, invokeMethod); + if (mi.ReturnType != typeof(void)) + { + Convert(il, typeof(object), mi.ReturnType, false); + } + else + { + il.Emit(OpCodes.Pop); + } + + il.Emit(OpCodes.Ret); + + _tb.DefineMethodOverride(mdb, mi); + return mdb; + } + + private static Type[] ParamTypes(ParameterInfo[] parms, bool noByRef) + { + var types = new Type[parms.Length]; + for (var i = 0; i < parms.Length; i++) + { + types[i] = parms[i].ParameterType; + if (noByRef && types[i].IsByRef) + { + types[i] = types[i].GetElementType(); + } + } + + return types; + } + + // TypeCode does not exist in ProjectK or ProjectN. + // This lookup method was copied from PortableLibraryThunks\Internal\PortableLibraryThunks\System\TypeThunks.cs + // but returns the integer value equivalent to its TypeCode enum. + private static int GetTypeCode(Type type) + { + if (type == null) + { + return 0; // TypeCode.Empty; + } + + if (type == typeof(bool)) + { + return 3; // TypeCode.Boolean; + } + + if (type == typeof(char)) + { + return 4; // TypeCode.Char; + } + + if (type == typeof(sbyte)) + { + return 5; // TypeCode.SByte; + } + + if (type == typeof(byte)) + { + return 6; // TypeCode.Byte; + } + + if (type == typeof(short)) + { + return 7; // TypeCode.Int16; + } + + if (type == typeof(ushort)) + { + return 8; // TypeCode.UInt16; + } + + if (type == typeof(int)) + { + return 9; // TypeCode.Int32; + } + + if (type == typeof(uint)) + { + return 10; // TypeCode.UInt32; + } + + if (type == typeof(long)) + { + return 11; // TypeCode.Int64; + } + + if (type == typeof(ulong)) + { + return 12; // TypeCode.UInt64; + } + + if (type == typeof(float)) + { + return 13; // TypeCode.Single; + } + + if (type == typeof(double)) + { + return 14; // TypeCode.Double; + } + + if (type == typeof(decimal)) + { + return 15; // TypeCode.Decimal; + } + + if (type == typeof(DateTime)) + { + return 16; // TypeCode.DateTime; + } + + if (type == typeof(string)) + { + return 18; // TypeCode.String; + } + + if (type.GetTypeInfo().IsEnum) + { + return GetTypeCode(Enum.GetUnderlyingType(type)); + } + + return 1; // TypeCode.Object; + } + + private static void Convert(ILGenerator il, Type source, Type target, bool isAddress) + { + Debug.Assert(!target.IsByRef); + if (target == source) + { + return; + } + + var sourceTypeInfo = source.GetTypeInfo(); + var targetTypeInfo = target.GetTypeInfo(); + + if (source.IsByRef) + { + Debug.Assert(!isAddress); + var argType = source.GetElementType(); + Ldind(il, argType); + Convert(il, argType, target, isAddress); + return; + } + + if (targetTypeInfo.IsValueType) + { + if (sourceTypeInfo.IsValueType) + { + var opCode = s_convOpCodes[GetTypeCode(target)]; + Debug.Assert(!opCode.Equals(OpCodes.Nop)); + il.Emit(opCode); + } + else + { + Debug.Assert(sourceTypeInfo.IsAssignableFrom(targetTypeInfo)); + il.Emit(OpCodes.Unbox, target); + if (!isAddress) + { + Ldind(il, target); + } + } + } + else if (targetTypeInfo.IsAssignableFrom(sourceTypeInfo)) + { + if (sourceTypeInfo.IsValueType || source.IsGenericParameter) + { + if (isAddress) + { + Ldind(il, source); + } + + il.Emit(OpCodes.Box, source); + } + } + else + { + Debug.Assert(sourceTypeInfo.IsAssignableFrom(targetTypeInfo) || targetTypeInfo.IsInterface || + sourceTypeInfo.IsInterface); + if (target.IsGenericParameter) + { + il.Emit(OpCodes.Unbox_Any, target); + } + else + { + il.Emit(OpCodes.Castclass, target); + } + } + } + + private static void Ldind(ILGenerator il, Type type) + { + var opCode = s_ldindOpCodes[GetTypeCode(type)]; + if (!opCode.Equals(OpCodes.Nop)) + { + il.Emit(opCode); + } + else + { + il.Emit(OpCodes.Ldobj, type); + } + } + + private static void Stind(ILGenerator il, Type type) + { + var opCode = s_stindOpCodes[GetTypeCode(type)]; + if (!opCode.Equals(OpCodes.Nop)) + { + il.Emit(opCode); + } + else + { + il.Emit(OpCodes.Stobj, type); + } + } + + private class ParametersArray + { + private readonly ILGenerator _il; + private readonly Type[] _paramTypes; + + internal ParametersArray(ILGenerator il, Type[] paramTypes) + { + _il = il; + _paramTypes = paramTypes; + } + + internal void Get(int i) => _il.Emit(OpCodes.Ldarg, i + 1); + + internal void BeginSet(int i) => _il.Emit(OpCodes.Ldarg, i + 1); + + internal void EndSet(int i, Type stackType) + { + Debug.Assert(_paramTypes[i].IsByRef); + var argType = _paramTypes[i].GetElementType(); + Convert(_il, stackType, argType, false); + Stind(_il, argType); + } + } + + private class GenericArray + { + private readonly ILGenerator _il; + private readonly LocalBuilder _lb; + + internal GenericArray(ILGenerator il, int len) + { + _il = il; + _lb = il.DeclareLocal(typeof(T[])); + + il.Emit(OpCodes.Ldc_I4, len); + il.Emit(OpCodes.Newarr, typeof(T)); + il.Emit(OpCodes.Stloc, _lb); + } + + internal void Load() => _il.Emit(OpCodes.Ldloc, _lb); + + internal void Get(int i) + { + _il.Emit(OpCodes.Ldloc, _lb); + _il.Emit(OpCodes.Ldc_I4, i); + _il.Emit(OpCodes.Ldelem_Ref); + } + + internal void BeginSet(int i) + { + _il.Emit(OpCodes.Ldloc, _lb); + _il.Emit(OpCodes.Ldc_I4, i); + } + + internal void EndSet(Type stackType) + { + Convert(_il, stackType, typeof(T), false); + _il.Emit(OpCodes.Stelem_Ref); + } + } + + private sealed class PropertyAccessorInfo + { + public PropertyAccessorInfo(MethodInfo interfaceGetMethod, MethodInfo interfaceSetMethod) + { + InterfaceGetMethod = interfaceGetMethod; + InterfaceSetMethod = interfaceSetMethod; + } + + public MethodInfo InterfaceGetMethod { get; } + public MethodInfo InterfaceSetMethod { get; } + public MethodBuilder GetMethodBuilder { get; set; } + public MethodBuilder SetMethodBuilder { get; set; } + } + + private sealed class EventAccessorInfo + { + public EventAccessorInfo(MethodInfo interfaceAddMethod, MethodInfo interfaceRemoveMethod, + MethodInfo interfaceRaiseMethod) + { + InterfaceAddMethod = interfaceAddMethod; + InterfaceRemoveMethod = interfaceRemoveMethod; + InterfaceRaiseMethod = interfaceRaiseMethod; + } + + public MethodInfo InterfaceAddMethod { get; } + public MethodInfo InterfaceRemoveMethod { get; } + public MethodInfo InterfaceRaiseMethod { get; } + public MethodBuilder AddMethodBuilder { get; set; } + public MethodBuilder RemoveMethodBuilder { get; set; } + public MethodBuilder RaiseMethodBuilder { get; set; } + } + + private sealed class MethodInfoEqualityComparer : EqualityComparer + { + public static readonly MethodInfoEqualityComparer Instance = new(); + + private MethodInfoEqualityComparer() + { + } + + public override bool Equals(MethodInfo left, MethodInfo right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left == null) + { + return right == null; + } + + if (right == null) + { + return false; + } + + // This assembly should work in netstandard1.3, + // so we cannot use MemberInfo.MetadataToken here. + // Therefore, it compares honestly referring ECMA-335 I.8.6.1.6 Signature Matching. + if (!Equals(left.DeclaringType, right.DeclaringType)) + { + return false; + } + + if (!Equals(left.ReturnType, right.ReturnType)) + { + return false; + } + + if (left.CallingConvention != right.CallingConvention) + { + return false; + } + + if (left.IsStatic != right.IsStatic) + { + return false; + } + + if (left.Name != right.Name) + { + return false; + } + + var leftGenericParameters = left.GetGenericArguments(); + var rightGenericParameters = right.GetGenericArguments(); + if (leftGenericParameters.Length != rightGenericParameters.Length) + { + return false; + } + + for (var i = 0; i < leftGenericParameters.Length; i++) + { + if (!Equals(leftGenericParameters[i], rightGenericParameters[i])) + { + return false; + } + } + + var leftParameters = left.GetParameters(); + var rightParameters = right.GetParameters(); + if (leftParameters.Length != rightParameters.Length) + { + return false; + } + + for (var i = 0; i < leftParameters.Length; i++) + { + if (!Equals(leftParameters[i].ParameterType, rightParameters[i].ParameterType)) + { + return false; + } + } + + return true; + } + + public override int GetHashCode(MethodInfo obj) + { + if (obj == null) + { + return 0; + } + + var hashCode = obj.DeclaringType.GetHashCode(); + hashCode ^= obj.Name.GetHashCode(); + foreach (var parameter in obj.GetParameters()) + { + hashCode ^= parameter.ParameterType.GetHashCode(); + } + + return hashCode; + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/DispatchProxy/DispatchProxyAsync.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/DispatchProxy/DispatchProxyAsync.cs new file mode 100644 index 000000000..6f3adc76c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/DispatchProxy/DispatchProxyAsync.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +#pragma warning disable + +using static System.Reflection.AsyncDispatchProxyGenerator; + +namespace System.Reflection; + +public abstract class DispatchProxyAsync +{ + public static T Create() where TProxy : DispatchProxyAsync => + (T)CreateProxyInstance(typeof(TProxy), typeof(T)); + + public static object Create(Type type, Type proxyType) => + CreateProxyInstance(proxyType, type); + + public abstract object Invoke(MethodInfo method, object[] args); + + public abstract Task InvokeAsync(MethodInfo method, object[] args); + + public abstract Task InvokeAsyncT(MethodInfo method, object[] args); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/DispatchProxy/LICENSE b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/DispatchProxy/LICENSE new file mode 100644 index 000000000..4e0190e2f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/DispatchProxy/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 NetCoreStack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/AssemblyExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/AssemblyExtensions.cs new file mode 100644 index 000000000..06a06f88c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/AssemblyExtensions.cs @@ -0,0 +1,94 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class AssemblyExtensions +{ + /// + /// 获取所有类型 + /// + /// + /// + /// + /// 类型导出设置 + /// [] + internal static Type[] GetTypes(this Assembly assembly, bool exported) => + exported + ? assembly.GetExportedTypes() + : assembly.GetTypes(); + + /// + /// 获取程序集描述 + /// + /// + /// + /// + /// + /// + /// + internal static string? GetDescription(this Assembly assembly) + { + var descriptionAttribute = + Attribute.GetCustomAttribute(assembly, + typeof(AssemblyDescriptionAttribute)) as AssemblyDescriptionAttribute; + + return descriptionAttribute?.Description; + } + + /// + /// 获取程序集版本 + /// + /// + /// + /// + /// + /// + /// + internal static Version? GetVersion(this Assembly assembly) => assembly.GetName().Version; + + /// + /// 获取程序集名称和版本 + /// + /// + /// + /// + /// 分隔符 + /// + /// + /// + internal static string GetNameVersion(this Assembly assembly, string separator = "/") => + $"{assembly.GetName().Name}{separator}{assembly.GetVersion()}"; + + /// + /// 将程序集转换成指定类型返回 + /// + /// + /// + /// + /// 自定义配置委托 + /// 结果类型 + /// + /// + /// + internal static TResult ConvertTo(this Assembly assembly, Func configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + return configure.Invoke(assembly); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/ConcurrentDictionaryExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/ConcurrentDictionaryExtensions.cs new file mode 100644 index 000000000..7194f9baf --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/ConcurrentDictionaryExtensions.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class ConcurrentDictionaryExtensions +{ + /// + /// 根据字典键更新对应的值 + /// + /// 字典键类型 + /// 字典值类型 + /// + /// + /// + /// + /// + /// + /// 自定义更新委托 + /// + /// + /// + /// + /// + /// + internal static bool TryUpdate(this ConcurrentDictionary dictionary + , TKey key + , Func updateFactory + , out TValue? value) + where TKey : notnull + { + // 空检查 + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(updateFactory); + + // 查找字典值 + if (!dictionary.TryGetValue(key, out var oldValue)) + { + value = default; + return false; + } + + // 调用自定义更新委托 + var updatedValue = updateFactory(oldValue); + + // 更新字典值 + var result = dictionary.TryUpdate(key, updatedValue, oldValue); + value = result ? updatedValue : oldValue; + + return result; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/CoreServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/CoreServiceCollectionExtensions.cs new file mode 100644 index 000000000..055275c6b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/CoreServiceCollectionExtensions.cs @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; + +using System.Reflection; + +namespace ThingsGateway.Extensions; + +/// +/// 核心模块 拓展类 +/// +public static class CoreServiceCollectionExtensions +{ + /// + /// 添加核心模块选项服务 + /// + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddCoreOptions(this IServiceCollection services) + { + // 添加核心模块选项服务 + services.TryAddSingleton(new CoreOptions()); + + return services; + } + + /// + /// 尝试获取应用环境 + /// + /// + /// + /// + /// + /// + /// + public static IHostEnvironment? TryGetHostEnvironment(this IServiceCollection services) => + services.FirstOrDefault(u => u.ServiceType == typeof(IHostEnvironment))?.ImplementationInstance as + IHostEnvironment; + + /// + /// 获取核心模块选项 + /// + /// + /// + /// + /// + /// + /// + internal static CoreOptions GetCoreOptions(this IServiceCollection services) + { + // 添加核心模块选项服务 + services.AddCoreOptions(); + + // 获取核心模块选项实例 + var coreOptions = services + .Single(s => s.ServiceType == typeof(CoreOptions) && s.ImplementationInstance is not null) + .ImplementationInstance as CoreOptions; + + // 空检查 + ArgumentNullException.ThrowIfNull(coreOptions); + + return coreOptions; + } + + /// + /// 登记组件注册信息 + /// + /// + /// + /// + /// + /// + /// + internal static void RegisterComponent(this IServiceCollection services, Assembly assembly) + { + // 空检查 + ArgumentNullException.ThrowIfNull(assembly); + + // 获取核心模块选项 + var coreOptions = services.GetCoreOptions(); + + // 组件元数据 + var componentMetadata = new ComponentMetadata(assembly.GetName().Name! + , assembly.GetVersion() + , assembly.GetDescription()); + + // 登记组件注册信息 + coreOptions.TryRegisterComponent(componentMetadata); + } + + /// + /// 登记组件注册信息 + /// + /// + /// + /// + /// + /// + /// + internal static void RegisterComponent(this IServiceCollection services, Type typeInAssembly) + { + // 空检查 + ArgumentNullException.ThrowIfNull(typeInAssembly); + + // 登记组件注册信息 + services.RegisterComponent(typeInAssembly.Assembly); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/DataTableAndSetExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/DataTableAndSetExtensions.cs new file mode 100644 index 000000000..a766f4466 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/DataTableAndSetExtensions.cs @@ -0,0 +1,57 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Data; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class DataTableAndSetExtensions +{ + /// + /// 将 转换为字典集合 + /// + /// + /// + /// + /// + /// + /// + internal static List> ToDictionaryList(this DataTable dataTable) + { + // 空检查 + ArgumentNullException.ThrowIfNull(dataTable); + + return dataTable.AsEnumerable().Select(row => + row.Table.Columns.Cast() + .ToDictionary(col => col.ColumnName, col => row[col] != DBNull.Value ? row[col] : null)).ToList(); + } + + /// + /// 将 转换为字典集合 + /// + /// + /// + /// + /// + /// + /// + internal static Dictionary>> ToDictionary(this DataSet dataSet) + { + // 空检查 + ArgumentNullException.ThrowIfNull(dataSet); + + return dataSet.Tables.Cast() + .ToDictionary(table => table.TableName, table => table.ToDictionaryList()); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/DelegateExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/DelegateExtensions.cs new file mode 100644 index 000000000..c992e807c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/DelegateExtensions.cs @@ -0,0 +1,168 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Extensions; + +/// +/// 委托拓展类 +/// +internal static class DelegateExtensions +{ + /// + /// 尝试执行异步委托 + /// + /// 异步委托 + /// 参数 1 + /// 参数 2 + /// 参数类型 + /// 参数类型 + internal static async Task TryInvokeAsync(this Func? func, T1 parameter1, T2 parameter2) + { + // 空检查 + if (func is null) + { + return; + } + + try + { + await func(parameter1, parameter2).ConfigureAwait(false); + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + + /// + /// 尝试执行异步委托 + /// + /// 异步委托 + /// 参数 + /// 参数类型 + internal static async Task TryInvokeAsync(this Func? func, T parameter) + { + // 空检查 + if (func is null) + { + return; + } + + try + { + await func(parameter).ConfigureAwait(false); + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + + /// + /// 尝试执行异步委托 + /// + /// 异步委托 + internal static async Task TryInvokeAsync(this Func? func) + { + // 空检查 + if (func is null) + { + return; + } + + try + { + await func().ConfigureAwait(false); + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + + /// + /// 尝试执行同步委托 + /// + /// 同步委托 + /// 参数 1 + /// 参数 2 + /// 参数类型 + /// 参数类型 + internal static void TryInvoke(this Action? action, T1 parameter1, T2 parameter2) + { + // 空检查 + if (action is null) + { + return; + } + + try + { + action(parameter1, parameter2); + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + + /// + /// 尝试执行同步委托 + /// + /// 同步委托 + /// 参数 + /// 参数类型 + internal static void TryInvoke(this Action? action, T parameter) + { + // 空检查 + if (action is null) + { + return; + } + + try + { + action(parameter); + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + + /// + /// 尝试执行同步委托 + /// + /// 同步委托 + internal static void TryInvoke(this Action? action) + { + // 空检查 + if (action is null) + { + return; + } + + try + { + action(); + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/EnumExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/EnumExtensions.cs new file mode 100644 index 000000000..df01e6e6a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/EnumExtensions.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel; +using System.Reflection; + +namespace ThingsGateway.Extensions; + +/// +/// 枚举拓展类 +/// +internal static class EnumExtensions +{ + /// + /// 获取枚举值描述 + /// + /// 枚举值 + /// + /// + /// + /// + internal static string GetEnumDescription(this object enumValue) + { + // 空检查 + ArgumentNullException.ThrowIfNull(enumValue); + + // 获取枚举类型 + var enumType = enumValue.GetType(); + + // 检查是否是枚举类型 + if (!enumType.IsEnum) + { + throw new ArgumentException("The parameter is not an enumeration type.", nameof(enumValue)); + } + + // 获取枚举名称 + var enumName = Enum.GetName(enumType, enumValue); + + // 空检查 + ArgumentNullException.ThrowIfNull(enumName); + + // 获取枚举字段 + var enumField = enumType.GetField(enumName); + + // 空检查 + ArgumentNullException.ThrowIfNull(enumField); + + // 获取 [Description] 特性描述 + return enumField.GetCustomAttribute(false) + ?.Description ?? enumName; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/EventHandlerExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/EventHandlerExtensions.cs new file mode 100644 index 000000000..eac8a4445 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/EventHandlerExtensions.cs @@ -0,0 +1,50 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class EventHandlerExtensions +{ + /// + /// 尝试执行事件处理程序 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 事件参数 + internal static void TryInvoke(this EventHandler? handler, object? sender, TEventArgs args) + { + // 空检查 + if (handler is null) + { + return; + } + + try + { + handler(sender, args); + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/ICollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/ICollectionExtensions.cs new file mode 100644 index 000000000..c2109943f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/ICollectionExtensions.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class ICollectionExtensions +{ + /// + /// 判断集合是否为空 + /// + /// 对象类型 + /// + /// + /// + /// + /// + /// + internal static bool IsNullOrEmpty([NotNullWhen(false)] this ICollection? collection) => + collection is null + || collection.Count == 0; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/IDictionaryExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/IDictionaryExtensions.cs new file mode 100644 index 000000000..625085295 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/IDictionaryExtensions.cs @@ -0,0 +1,235 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class IDictionaryExtensions +{ + /// + /// 添加或更新 + /// + /// 字典键类型 + /// 字典值类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static void AddOrUpdate(this IDictionary dictionary, TKey key, TValue value) + where TKey : notnull => + dictionary[key] = value; + + /// + /// 添加或更新 + /// + /// 字典键类型 + /// 字典值类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static void AddOrUpdate(this IDictionary> dictionary, TKey key, + TValue value) + where TKey : notnull + { + // 空检查 + ArgumentNullException.ThrowIfNull(value); + + // 检查键是否存在 + if (!dictionary.TryGetValue(key, out var values)) + { + values = []; + dictionary.Add(key, values); + } + + values.Add(value); + } + + /// + /// 添加或更新 + /// + /// 字典键类型 + /// 字典值类型 + /// + /// + /// + /// + /// + /// + internal static void AddOrUpdate(this IDictionary> dictionary, + IDictionary> concatDictionary) + where TKey : notnull + { + // 空检查 + ArgumentNullException.ThrowIfNull(concatDictionary); + + // 逐条遍历合并更新 + foreach (var (key, newValues) in concatDictionary) + { + // 检查键是否存在 + if (!dictionary.TryGetValue(key, out var values)) + { + values = []; + dictionary.Add(key, values); + } + + values.AddRange(newValues); + } + } + + /// + /// 添加或更新 + /// + /// 字典键类型 + /// 字典值类型 + /// + /// + /// + /// + /// + /// + /// 是否允许重复添加。默认值为:true。 + /// 是否值已存在时则采用替换的方式,否则采用追加方式。默认值为 false。 + internal static void AddOrUpdate(this IDictionary> dictionary, + IDictionary concatDictionary, bool allowDuplicates = true, bool replace = false) + where TKey : notnull + { + // 空检查 + ArgumentNullException.ThrowIfNull(concatDictionary); + + // 逐条遍历合并更新 + foreach (var (key, newValue) in concatDictionary) + { + // 检查键是否存在 + if (!dictionary.TryGetValue(key, out var values)) + { + values = []; + dictionary.Add(key, values); + } + + // 检查是否启用重复值检查 + if (!allowDuplicates && values.Contains(newValue)) + { + continue; + } + + // 检查是否采用替换的方式 + if (replace) + { + values.Clear(); + } + + if (newValue is null || !newValue.GetType().IsArrayOrCollection(out _)) + { + values.Add(newValue); + } + else + { + values.AddRange(((IEnumerable)newValue).Cast()); + } + } + } + + /// + /// 添加或更新 + /// + /// 字典键类型 + /// 字典值类型 + /// + /// + /// + /// + /// + /// + internal static void AddOrUpdate(this IDictionary dictionary, + IDictionary concatDictionary) + where TKey : notnull + { + // 空检查 + ArgumentNullException.ThrowIfNull(concatDictionary); + + // 逐条遍历合并更新 + foreach (var (key, value) in concatDictionary) + { + dictionary[key] = value; + } + } + + /// + /// 尝试添加 + /// + /// 字典键类型 + /// 字典值类型 + /// + /// + /// + /// + /// + /// + internal static void TryAdd(this IDictionary dictionary, + IDictionary concatDictionary) + where TKey : notnull + { + // 空检查 + ArgumentNullException.ThrowIfNull(concatDictionary); + + // 逐条遍历合并更新 + foreach (var (key, value) in concatDictionary) + { + dictionary.TryAdd(key, value); + } + } + + /// + /// 尝试添加 + /// + /// 其中键是由值通过给定的选择器函数生成的。 + /// + /// + /// + /// + /// + /// + /// 键选择器 + /// 字典键类型 + /// 字典值类型 + internal static void TryAdd(this IDictionary dictionary, + IEnumerable? values, Func keySelector) + where TKey : notnull + { + // 空检查 + ArgumentNullException.ThrowIfNull(keySelector); + + // 空检查 + if (values is null) + { + return; + } + + // 逐条遍历尝试添加 + foreach (var value in values) + { + dictionary.TryAdd(keySelector(value), value); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/IEnumerableExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/IEnumerableExtensions.cs new file mode 100644 index 000000000..7d76613f5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/IEnumerableExtensions.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class IEnumerableExtensions +{ + /// + /// 根据指定类型筛选 的元素 + /// + /// + /// + /// + /// 筛选的结果类型 + /// + /// + /// + internal static IEnumerable OfType(this IEnumerable source, Type resultType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(resultType); + + foreach (var obj in source) + { + if (resultType.IsInstanceOfType(obj)) + { + yield return obj; + } + } + } + + /// + /// 合并两个集合 + /// + /// + /// + /// + /// + /// + /// + /// 集合元素的类型 + /// + /// + /// + public static IEnumerable ConcatIgnoreNull(this IEnumerable? first, + IEnumerable? second) => + (first ?? []).Concat(second ?? []); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/JsonExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/JsonExtensions.cs new file mode 100644 index 000000000..265b8cdde --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/JsonExtensions.cs @@ -0,0 +1,236 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Runtime.Serialization.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Xml; +using System.Xml.Linq; + +namespace ThingsGateway.Extensions; + +/// +/// System.Text.Json 拓展类 +/// +internal static class JsonExtensions +{ + /// + /// 将 转换为目标类型 + /// + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + internal static TResult? + As(this JsonNode? jsonNode, JsonSerializerOptions? jsonSerializerOptions = null) => + (TResult?)jsonNode.As(typeof(TResult), jsonSerializerOptions); + + /// + /// 将 转换为目标类型 + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + /// + /// + /// + internal static object? As(this JsonNode? jsonNode, Type resultType, + JsonSerializerOptions? jsonSerializerOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(resultType); + + // 空检查 + if (jsonNode is null) + { + return null; + } + + // 处理目标类型为字符串类型 + if (resultType == typeof(string)) + { + // 处理转换为字符串类型出现双引号包裹问题 + return jsonNode.GetValueKind() is JsonValueKind.String + ? jsonNode.GetValue() + : jsonNode.ToJsonString(jsonSerializerOptions); + } + + // 处理目标类型为 bool 且值是 "True" 或 "False" 情况 + if (resultType == typeof(bool) && jsonNode.GetValueKind() is JsonValueKind.String) + { + // 获取字符串值 + var stringValue = jsonNode.GetValue(); + + // 检查字符串是否是 "True" 或 "False" + if (stringValue == bool.TrueString || stringValue == bool.FalseString) + { + return Convert.ToBoolean(stringValue); + } + } + + // 处理目标类型为 XElement 类型 + if (resultType == typeof(XElement)) + { + // 初始化 MemoryStream 实例 + using var ms = new MemoryStream(Encoding.UTF8.GetBytes(jsonNode.ToJsonString(jsonSerializerOptions))); + + // 使用 JsonReaderWriterFactory 创建 JsonReader 实例用于解析 JSON 数据 + using var jsonReader = + JsonReaderWriterFactory.CreateJsonReader(ms, XmlDictionaryReaderQuotas.Max); + + // 将 JsonReader 解析的结果加载到 XElement 实例中 + return XElement.Load(jsonReader); + } + + // 初始化 MemoryStream 实例 + using var memoryStream = new MemoryStream(); + + // 初始化 Utf8JsonWriter 实例 + // 注意:如果使用 using var jsonWriter = ...; 代码方式,则需要手动调用 jsonWriter.Flush(); 方法来确保所有数据都被写入 + using (var jsonWriter = new Utf8JsonWriter(memoryStream)) + { + // 将 jsonNode 的内容写入到 jsonWriter 中 + jsonNode.WriteTo(jsonWriter); + } + + // 反序列化输出目标类型实例 + return JsonSerializer.Deserialize(memoryStream.ToArray(), resultType, jsonSerializerOptions); + } + + /// + /// 将 转换为数值类型的值 + /// + /// + /// + /// + /// + /// + /// + /// + internal static object GetNumericValue(this JsonNode jsonNode) + { + // 空检查 + ArgumentNullException.ThrowIfNull(jsonNode); + + // 定义一个小的误差范围(容差值) + const double epsilon = 1e-10; + + // 将 JsonNode 转换为 JsonValue + var jsonValue = jsonNode.AsValue(); + + // 尝试将 JsonValue 转换为 double 类型 + if (jsonValue.TryGetValue(out var doubleValue)) + { + // 检查双精度浮点数与四舍五入后的整数值之间的差异是否大于容差值,如果是则认定这是一个真正的浮点数 + if (Math.Abs(doubleValue - Math.Round(doubleValue)) >= epsilon) + { + return doubleValue; + } + + // 根据数值范围和精度损失情况决定返回 int, long 还是保持原样返回 double + switch (doubleValue) + { + case >= int.MinValue and <= int.MaxValue: + var intValue = (int)doubleValue; + + if (Math.Abs(intValue - doubleValue) < epsilon) + { + return intValue; + } + + break; + case >= long.MinValue and <= long.MaxValue: + var longValue = (long)doubleValue; + + if (Math.Abs(longValue - doubleValue) < epsilon) + { + return longValue; + } + + break; + } + + return doubleValue; + } + + // 尝试将 JsonValue 转换为 decimal 类型 + if (jsonValue.TryGetValue(out var decimalValue)) + { + return decimalValue; + } + + throw new InvalidCastException( + $"The value `{jsonValue.ToJsonString()}` cannot be converted to a supported numeric type."); + } + + /// + /// 根据提供的命名策略转换 JSON 节点中的对象键名 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static JsonNode? TransformKeysWithNamingPolicy(this JsonNode? jsonNode, JsonNamingPolicy jsonNamingPolicy) + { + // 空检查 + ArgumentNullException.ThrowIfNull(jsonNamingPolicy); + + switch (jsonNode) + { + // 处理 JsonObject 类型 + case JsonObject jsonObject: + // 初始化新的 JsonObject 实例 + var transformedObject = new JsonObject(); + + // 遍历原对象的所有属性,并对每个属性的键名应用命名策略转换 + foreach (var property in jsonObject) + { + // 根据命名策略转换键名 + var transformedKey = jsonNamingPolicy.ConvertName(property.Key); + + transformedObject[transformedKey] = property.Value.TransformKeysWithNamingPolicy(jsonNamingPolicy); + } + + return transformedObject; + // 处理 JsonArray 类型 + case JsonArray jsonArray: + // 初始化新的 JsonArray 实例 + var transformedArray = new JsonArray(); + + // 遍历数组中的每一项并处理可能存在的嵌套对象或数组情况 + foreach (var item in jsonArray) + { + transformedArray.Add(item.TransformKeysWithNamingPolicy(jsonNamingPolicy)); + } + + return transformedArray; + // 其他类型直接返回 + default: + return jsonNode?.DeepClone(); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/LinqExpressionExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/LinqExpressionExtensions.cs new file mode 100644 index 000000000..c704e5164 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/LinqExpressionExtensions.cs @@ -0,0 +1,98 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Linq.Expressions; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class LinqExpressionExtensions +{ + /// + /// 根据条件成立构建 Where 表达式 + /// + /// + /// + /// + /// 条件 + /// Where 表达式 + /// 集合元素类型 + /// + /// + /// + internal static IEnumerable WhereIf(this IEnumerable source + , bool condition + , Func predicate) + { + // 空检查 + ArgumentNullException.ThrowIfNull(predicate); + + return !condition + ? source + : source.Where(predicate); + } + + /// + /// 解析表达式属性名称 + /// + /// 对象类型 + /// 属性类型 + /// + /// + /// + /// + /// + /// + /// + internal static string GetPropertyName(this Expression> propertySelector) => + propertySelector.Body switch + { + // 检查 Lambda 表达式的主体是否是 MemberExpression 类型 + MemberExpression memberExpression => GetPropertyName(memberExpression), + + // 如果主体是 UnaryExpression 类型,则继续解析 + UnaryExpression { Operand: MemberExpression nestedMemberExpression } => GetPropertyName( + nestedMemberExpression), + + _ => throw new ArgumentException("Expression is not valid for property selection.") + }; + + /// + /// 解析表达式属性名称 + /// + /// 对象类型 + /// + /// + /// + /// + /// + /// + /// + internal static string GetPropertyName(MemberExpression memberExpression) + { + // 空检查 + ArgumentNullException.ThrowIfNull(memberExpression); + + // 获取属性声明类型 + var propertyType = memberExpression.Member.DeclaringType; + + // 检查是否越界访问属性 + if (propertyType != typeof(T)) + { + throw new ArgumentException("Invalid property selection."); + } + + // 返回属性名称 + return memberExpression.Member.Name; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/MethodInfoExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/MethodInfoExtensions.cs new file mode 100644 index 000000000..88cf9c698 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/MethodInfoExtensions.cs @@ -0,0 +1,138 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class MethodInfoExtensions +{ + /// + /// 检查是否定义了指定特性 + /// + /// + /// + /// + /// + /// + /// + /// 是否在基类中搜索 + /// + /// + /// + /// + /// + /// + internal static bool IsDefined(this MethodInfo method, [NotNullWhen(true)] out TAttribute? attribute, + bool inherit = false) + where TAttribute : Attribute + { + // 获取指定特性实例 + attribute = method.GetCustomAttribute(inherit); + + // 检查是否定义了指定特性 + var isDefined = attribute != null || method.IsDefined(typeof(TAttribute), inherit); + if (isDefined || !inherit) + { + return isDefined; + } + + // 尝试查找所在声明类是否定义了指定特性 + attribute = method.DeclaringType?.GetCustomAttribute(inherit); + isDefined = attribute != null || method.DeclaringType?.IsDefined(typeof(TAttribute), inherit) == true; + + return isDefined; + } + + /// + /// 获取指定特性的所有实例 + /// + /// + /// + /// + /// 是否在基类中搜索 + /// 是否优先查找 的特性。默认值为:true。 + /// + /// + /// + /// + /// [] + /// + internal static TAttribute[]? GetDefinedCustomAttributes(this MethodInfo method, bool inherit = false, + bool methodScanFirst = true) + where TAttribute : Attribute + { + // 初始化指定特性集合 + var attributes = new List(); + + // 获取指定特性集合 + attributes.AddRange(method.GetCustomAttributes(inherit)); + + // 尝试获取所在声明类上指定特性集合 + // ReSharper disable once InvertIf + if (inherit && method.DeclaringType is not null) + { + var declaringAttributes = method.DeclaringType.GetCustomAttributes(inherit); + + // 是否优先查找方法特性 + if (methodScanFirst) + { + attributes.AddRange(declaringAttributes); + } + // 否则添加到头部 + else + { + attributes.InsertRange(0, declaringAttributes); + } + } + + return attributes.Count > 0 ? attributes.ToArray() : null; + } + + /// + /// 输出方法签名的友好字符串 + /// + /// + /// + /// + /// + /// + /// + internal static string? ToFriendlyString(this MethodInfo? method) + { + // 空检查 + if (method is null) + { + return default; + } + + // 获取方法的基本信息 + var methodName = method.Name; + var returnType = method.ReturnType.ToFriendlyString(); + + // 处理泛型方法 + var genericArguments = method.IsGenericMethod + ? method.GetGenericArguments().Select(t => t.ToFriendlyString()).ToArray() + : []; + + // 获取参数列表 + var parameters = method.GetParameters().Select(p => p.ParameterType.ToFriendlyString()); + + // 组合字符串 + var genericPart = genericArguments.Length != 0 ? $"<{string.Join(',', genericArguments)}>" : string.Empty; + + return $"{returnType} {methodName}{genericPart}({string.Join(", ", parameters)})"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/NumberExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/NumberExtensions.cs new file mode 100644 index 000000000..f96475c04 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/NumberExtensions.cs @@ -0,0 +1,63 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.Extensions; + +/// +/// 数值类型拓展类 +/// +public static class NumberExtensions +{ + /// + /// 根据指定的单位将字节数进行转换 + /// + /// 字节数 + /// 单位。可选值为:B, KB, MB, GB, TB, PB, EB。 + /// + /// + /// + /// + public static double ToSizeUnits(this double byteSize, string unit) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(unit); + + // 非负检查 + if (byteSize < 0) + { + throw new ArgumentOutOfRangeException(nameof(byteSize), + $"The `{nameof(byteSize)}` must be non-negative."); + } + + return unit.ToUpperInvariant() switch + { + "B" => byteSize, + "KB" => byteSize / 1024.0, + "MB" => byteSize / (1024.0 * 1024), + "GB" => byteSize / (1024.0 * 1024 * 1024), + "TB" => byteSize / (1024.0 * 1024 * 1024 * 1024), + "PB" => byteSize / (1024.0 * 1024 * 1024 * 1024 * 1024), + "EB" => byteSize / (1024.0 * 1024 * 1024 * 1024 * 1024 * 1024), + _ => throw new ArgumentOutOfRangeException(nameof(unit), $"Unsupported `{unit}` unit.") + }; + } + + /// + /// 根据指定的单位将字节数进行转换 + /// + /// 字节数 + /// 单位。可选值为:B, KB, MB, GB, TB, PB, EB。 + /// + /// + /// + /// + public static double ToSizeUnits(this long byteSize, string unit) => ToSizeUnits((double)byteSize, unit); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/ObjectExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/ObjectExtensions.cs new file mode 100644 index 000000000..5eb9c0324 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/ObjectExtensions.cs @@ -0,0 +1,330 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Collections.Specialized; +using System.Globalization; +using System.Reflection; +using System.Text.Json; + +using ThingsGateway.Utilities; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class NewObjectExtensions +{ + /// + /// 获取对象所在的程序集 + /// + /// + /// + /// + /// + /// + /// + internal static Assembly? GetAssembly(this object? obj) => obj?.GetType().Assembly; + + /// + /// 将对象转换为基于特定文化的字符串表示形式 + /// + /// + /// + /// + /// + /// + /// + /// 指示是否将枚举类型的值作为名称输出,默认值为:true。若为 false,则输出枚举的值 + /// 集合类型分隔符 + /// + /// + /// + internal static string? ToCultureString(this object? obj, CultureInfo culture, bool enumAsString = true, + string separator = ",") + { + // 空检查 + ArgumentNullException.ThrowIfNull(culture); + + return obj switch + { + null => null, + string s => s, + DateTime dt => dt.ToString("o", culture), + DateTimeOffset df => df.ToString("o", culture), + DateOnly od => od.ToString("yyyy-MM-dd", culture), + TimeOnly ot => ot.ToString("HH':'mm':'ss", culture), + Enum e when enumAsString => e.ToString(), + Enum e => Convert.ChangeType(e, Enum.GetUnderlyingType(e.GetType())).ToString(), + IEnumerable e and not string when typeof(IEnumerable<>).IsDefinitionEquals(e.GetType()) => string.Join( + separator, e.Cast()), + _ => obj.ToString() + }; + } + + /// + /// 尝试获取对象的数量 + /// + /// + /// + /// + /// 数量 + /// + /// + /// + internal static bool TryGetCount(this object obj, out int count) + { + // 处理可直接获取长度的类型 + switch (obj) + { + // 检查对象是否是字符类型 + case char: + count = 1; + return true; + // 检查对象是否是字符串类型 + case string text: + count = text.Length; + return true; + // 检查对象是否实现了 ICollection 接口 + case ICollection collection: + count = collection.Count; + return true; + } + + // 反射查找是否存在 Count 属性 + var runtimeProperty = obj.GetType() + .GetRuntimeProperty("Count"); + + // 反射获取 Count 属性值 + if (runtimeProperty is not null + && runtimeProperty.CanRead + && runtimeProperty.PropertyType == typeof(int)) + { + count = (int)runtimeProperty.GetValue(obj)!; + return true; + } + + count = -1; + return false; + } + + /// + /// 将对象转换为 类型对象 + /// + /// + /// + /// + /// + /// + /// + /// + internal static IDictionary? ObjectToDictionary(this object? obj) + { + // 空检查 + if (obj is null) + { + return null; + } + + // 获取对象类型 + var objType = obj.GetType(); + + // 初始化不受支持的类型转换的异常消息字符串 + var notSupportedExceptionMessage = + $"Conversion of parameter 'obj' from type `{objType}` to type `IDictionary` is not supported."; + + // 检查类型是否是基本类型或 void 类型 + if (objType.IsBasicType() || objType == typeof(void)) + { + throw new NotSupportedException(notSupportedExceptionMessage); + } + + // 检查类型是否是枚举类型 + if (objType.IsEnum) + { + // 转换为字典类型并返回 + return new Dictionary { { Enum.GetName(objType, obj)!, Convert.ToInt32(obj) } }; + } + + // 检查类型是否是 KeyValuePair<,> 单个类型 + if (objType.IsKeyValuePair()) + { + // 获取 Key 和 Value 属性值访问器 + var getters = objType.GetKeyValuePairOrJPropertyGetters(); + + // 转换为字典类型并返回 + return new Dictionary { { getters.KeyGetter(obj)!, getters.ValueGetter(obj) } }; + } + + // 处理 System.Text.Json 类型 + switch (obj) + { + case JsonDocument jsonDocument: + return jsonDocument.RootElement.ObjectToDictionary(); + case JsonElement { ValueKind: JsonValueKind.Object } jsonElement: + // 转换为字典类型并返回 + return jsonElement.EnumerateObject().ToDictionary( + jsonProperty => jsonProperty.Name, + jsonProperty => jsonProperty.Value); + } + + // 检查类型是否是键值对集合类型 + if (objType.IsKeyValueCollection(out var isKeyValuePairCollection)) + { + // === 处理 Hashtable 和 NameValueCollection 集合类型 === + switch (obj) + { + case Hashtable hashtable: + return hashtable.Cast().ToDictionary(entry => entry.Key, entry => entry.Value); + case NameValueCollection nameValueCollection: + return nameValueCollection + .AllKeys + .ToDictionary( + object (key) => key!, object? (key) => nameValueCollection[key]); + } + + // === 处理非 KeyValuePair<,> 集合类型 === + if (!isKeyValuePairCollection) + { + // 将对象转化为 IDictionary 接口对象 + var dictionaryObj = (IDictionary)obj; + + // 转换为字典类型并返回 + return dictionaryObj.Count == 0 + ? new Dictionary() + : dictionaryObj.Keys + .Cast() + .ToDictionary(key => key!, key => dictionaryObj[key!]); + } + + // === 处理 KeyValuePair<,> 集合类型 === + var keyValuePairs = ((IEnumerable)obj).Cast().ToArray(); + + // 空检查 + if (keyValuePairs.Length == 0) + { + return new Dictionary(); + } + + // 获取 KeyValuePair<,> 集合中元素类型 + var keyValuePairType = keyValuePairs.First()?.GetType()!; + + // 获取 Key 和 Value 属性值访问器 + var getters = keyValuePairType.GetKeyValuePairOrJPropertyGetters(); + + // 转换为字典类型并返回 + return keyValuePairs.ToDictionary(keyValuePair => getters.KeyGetter(keyValuePair!)!, + keyValuePair => getters.ValueGetter(keyValuePair!)); + } + + try + { + // 初始化反射搜索成员方式 + const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public; + + // 尝试查找对象类型的所有公开且可读的实例属性集合并转换为字典类型并返回 + return objType.GetProperties(bindingFlags) + .Where(property => property.CanRead) + .ToDictionary(object (property) => AliasAsUtility.GetPropertyName(property, out _), + property => property.GetValue(obj)); + } + catch (Exception e) + { + throw new AggregateException( + new NotSupportedException(notSupportedExceptionMessage), e); + } + } + + /// + /// 根据模板路径从对象中获取属性值 + /// + /// + /// + /// + /// 模板路径。支持 {Key}{Key.Property} 或 {Key.Property.NestProperty} 语法格式。 + /// 模板字符串前缀;默认值为:model。 + /// 用于检查是否以 prefix. 开头 + /// + /// + /// + /// + /// + /// + internal static object? GetPropertyValueFromPath(this object? obj, string path, out bool isMatch, + string prefix = "model", BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentException.ThrowIfNullOrWhiteSpace(prefix); + + // 初始化 isMatch 返回值 + isMatch = false; + + // 移除前后空格 + var prefixTrim = prefix.Trim(); + + // 如果 templatePath 与 prefix 相等则直接返回 obj + if (path.Trim() == prefixTrim) + { + return obj; + } + + // 根据 . 将路径分割成多个部分 + var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries).Select(u => u.Trim()).ToArray(); + + // 检查首个元素是否等于 prefix 的值,如果是则跳过首元素 + if (parts.Length > 0 && parts[0] == prefixTrim) + { + isMatch = true; + parts = parts.Skip(1).ToArray(); + } + + // 空检查 + if (obj is null) + { + return obj; + } + + // 初始化当前对象作为传入的模型对象 + var current = obj; + + // 遍历路径中的每一部分 + foreach (var part in parts) + { + // 获取当前对象类型中指定名称的属性信息 + var property = current.GetType().GetProperty(part, bindingFlags); + + // 空检查 + if (property is null || !property.CanRead) + { + return null; + } + + // 获取属性的实际值并作为下一个部分的模型对象 + current = property.GetValue(current); + + // 空检查 + if (current is null) + { + return null; + } + } + + // 处理 IEnumerable 类型,使用 string.Join 进行拼接 + if (current is IEnumerable enumerable and not string && + typeof(IEnumerable<>).IsDefinitionEquals(current.GetType())) + { + current = string.Join(',', enumerable.Cast()); + } + + return current; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/StringExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/StringExtensions.cs new file mode 100644 index 000000000..528f332fc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/StringExtensions.cs @@ -0,0 +1,353 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; + +using System.Globalization; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static partial class StringExtensions +{ + /// + /// 为字符串前后添加双引号 + /// + /// + /// + /// + /// + /// + /// + internal static string? AddQuotes(this string? input) + { + // 空检查 + if (input is null) + { + return input; + } + + // 检查是否已经有双引号,防止重复添加 + if (input.StartsWith('"') && input.EndsWith('"')) + { + return input; + } + + return $"\"{input}\""; + } + + /// + /// 将字符串首字母转换为小写 + /// + /// + /// + /// + /// + /// + /// + internal static string? ToLowerFirstLetter(this string? input) + { + // 空检查 + if (string.IsNullOrWhiteSpace(input)) + { + return input; + } + + // 初始化字符串构建器 + var stringBuilder = new StringBuilder(input); + + // 设置字符串构建器首个字符为小写 + stringBuilder[0] = char.ToLower(stringBuilder[0]); + + return stringBuilder.ToString(); + } + + /// + /// 将字符串进行转义 + /// + /// + /// + /// + /// 是否转义字符串 + /// + /// + /// + internal static string? EscapeDataString(this string? input, bool escape) + { + // 空检查 + if (string.IsNullOrWhiteSpace(input)) + { + return input; + } + + return !escape ? input : Uri.EscapeDataString(input); + } + + /// + /// 检查字符串是否存在于给定的集合中 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static bool IsIn(this string? input, IEnumerable collection, + IEqualityComparer? comparer = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(collection); + + // 使用默认或提供的比较器 + comparer ??= EqualityComparer.Default; + + return input is null ? collection.Any(u => u is null) : collection.Any(u => comparer.Equals(input, u)); + } + + /// + /// 解析符合键值对格式的字符串为键值对列表 + /// + /// 键值对格式的字符串 + /// 分隔符字符数组 + /// 要删除的前导字符 + /// + /// + /// + internal static List> ParseFormatKeyValueString(this string keyValueString, + char[]? separators = null, char? trimChar = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(keyValueString); + + // 空检查 + if (string.IsNullOrWhiteSpace(keyValueString)) + { + return []; + } + + // 默认隔符为 & + separators ??= ['&']; + + var pairs = (trimChar is null ? keyValueString : keyValueString.TrimStart(trimChar.Value)).Split(separators); + return (from pair in pairs + select pair.Split('=') + into keyValue + where keyValue.Length == 2 + select new KeyValuePair(keyValue[0].Trim(), keyValue[1])).ToList(); + } + + /// + /// 基于 GBK 编码将字符串右填充至指定的字节数 + /// + /// 调用之前需确保上下文存在 Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); 代码。 + /// 字符串 + /// 目标字节数 + /// + /// + /// + /// + internal static string? PadStringToByteLength(this string? output, int totalByteCount) + { + // 空检查 + if (string.IsNullOrWhiteSpace(output)) + { + return output; + } + + // 小于或等于 0 检查 + if (totalByteCount <= 0) + { + throw new ArgumentOutOfRangeException(nameof(totalByteCount), + "Total byte count must be greater than zero."); + } + + // 获取 GBK 编码实例 + var coding = Encoding.GetEncoding("gbk"); + + // 获取字符串的字节数组 + var bytes = coding.GetBytes(output); + var currentByteCount = bytes.Length; + + // 如果当前字节长度已经等于或超过目标字节长度,则直接返回原字符串 + if (currentByteCount >= totalByteCount) + { + return output; + } + + // 计算需要添加的空格数量 + var spaceBytes = coding.GetByteCount(" "); + var paddingSpaces = (totalByteCount - currentByteCount) / spaceBytes; + + // 确保填充不会超出范围 + if (currentByteCount + (paddingSpaces * spaceBytes) > totalByteCount) + { + paddingSpaces--; + } + + // 创建新的字符串并进行填充 + var paddedChars = new char[output.Length + paddingSpaces]; + output.CopyTo(0, paddedChars, 0, output.Length); + + // 填充剩余部分 + for (var i = output.Length; i < output.Length + paddingSpaces; i++) + { + paddedChars[i] = ' '; + } + + return new string(paddedChars); + } + + /// + /// 替换字符串中的占位符为实际值 + /// + /// 包含占位符的模板字符串 + /// + /// + /// + /// + /// + /// + internal static string? ReplacePlaceholders(this string? template, IDictionary replacementSource) + { + // 空检查 + ArgumentNullException.ThrowIfNull(replacementSource); + + return template is null + ? null + : PlaceholderRegex().Replace(template, + match => replacementSource.TryGetValue(match.Groups[1].Value.Trim(), out var replacement) + // 如果找到匹配则替换 + ? replacement ?? string.Empty + // 否则保留原样 + : match.ToString()); + } + + /// + /// 替换字符串中的占位符为实际值 + /// + /// 包含占位符的模板字符串 + /// + /// + /// + /// 模板字符串前缀;默认值为:model。 + /// + /// + /// + /// + /// + /// + internal static string? ReplacePlaceholders(this string? template, object? replacementSource, + string prefix = "model", + BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public) => + template is null + ? null + : PlaceholderRegex().Replace(template, + match => + { + // 获取模板解析后的值 + var replacement = + replacementSource.GetPropertyValueFromPath(match.Groups[1].Value.Trim(), out var isMatch, + prefix, bindingFlags); + + return isMatch + // 如果找到匹配则替换 + ? replacement?.ToCultureString(CultureInfo.InvariantCulture) ?? string.Empty + // 否则保留原样 + : match.ToString(); + }); + + /// + /// 替换字符串中的占位符为实际值 + /// + /// 包含占位符的模板字符串 + /// + /// + /// + /// + /// + /// + internal static string? ReplacePlaceholders(this string? template, IConfiguration? replacementSource) + { + // 空检查 + if (replacementSource is null) + { + return template; + } + + return template is null + ? null + : ConfigurationKeyRegex().Replace(template, + match => + { + // 获取主键、备用键和默认值 + var mainKey = match.Groups[1].Value.Trim(); + var backupKeysRaw = match.Groups[2].Value.Trim(); + var defaultValue = match.Groups[3].Success ? match.Groups[3].Value.Trim() : null; + + // 分割并清理备用键列表 + var backupKeys = backupKeysRaw.Split(['|'], StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList(); + + // 合并主键和备用键列表 + var allKeys = new List { mainKey }; + allKeys.AddRange(backupKeys); + + // 逐个匹配键,一旦找到有效的配置项,立即返回并停止查找 + foreach (var section in allKeys.Select(replacementSource.GetSection) + .Where(section => section.Exists())) + { + return section.Value!; + } + + return !string.IsNullOrEmpty(defaultValue) + // 如果所有备用键都没有找到,则使用默认值 + ? defaultValue + // 如果找不到配置项且没有默认值,则保留原样 + : match.Value; + }); + } + + /// + /// 占位符匹配正则表达式 + /// + /// 占位符格式:{Key}{Key.Property}{Key.Property.NestProperty} + /// + /// + /// + [GeneratedRegex(@"\{\s*(\w+\s*(\.\s*\w+\s*)*)\s*\}")] + private static partial Regex PlaceholderRegex(); + + /// + /// 配置键匹配正则表达式 + /// + /// + /// 占位符格式:[[Key]][[Key:Sub]][[Key:Sub:Nest]][[Key | Key2 | Key3]] 或 + /// [Key | Key2 || 默认值]]。 + /// + /// + /// + /// + [GeneratedRegex(@"\[\[\s*([\w\-:]+)((?:\s*\|\s*[\w\-:]+)*)\s*(?:\|\|\s*([^\]]*))?\s*\]\]")] + private static partial Regex ConfigurationKeyRegex(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/TypeExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/TypeExtensions.cs new file mode 100644 index 000000000..d9ff4ac72 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/TypeExtensions.cs @@ -0,0 +1,874 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Reflection.Emit; +using System.Runtime.CompilerServices; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class TypeExtensions +{ + /// + /// 检查类型是否是数组或集合类型 + /// + /// + /// + /// + /// 元素类型 + /// + /// + /// + internal static bool IsArrayOrCollection(this Type type, [NotNullWhen(true)] out Type? underlyingType) + { + underlyingType = null; + + // 检查类型是否是数组类型 + if (type.IsArray) + { + underlyingType = type.GetElementType()!; + return true; + } + + // 如果不是泛型类型 + if (!type.IsGenericType) + { + return false; + } + + // 获取泛型参数 + var genericArguments = type.GetGenericArguments(); + + // 检查类型是否是为单个泛型参数类型且实现了 IEnumerable<> 接口 + if (genericArguments.Length != 1 || !typeof(IEnumerable<>).IsDefinitionEquals(type)) + { + return false; + } + + underlyingType = genericArguments[0]; + return true; + } + + /// + /// 检查类型是否是基本类型 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsBasicType(this Type type) + { + while (true) + { + // 如果是基元类型则直接返回 + if (type.IsPrimitive) + { + return true; + } + + // 处理可空类型 + if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(Nullable<>)) + { + return type == typeof(string) || type == typeof(decimal) || + type == typeof(Guid) || + type == typeof(DateTime) || + type == typeof(DateTimeOffset) || type == typeof(DateOnly) || type == typeof(TimeSpan) || + type == typeof(TimeOnly) || type == typeof(char) || type == typeof(IntPtr) || + type == typeof(UIntPtr); + } + + var underlyingType = type.GetGenericArguments()[0]; + type = underlyingType; + } + } + + /// + /// 检查类型是否是基本类型或枚举类型或由它们组成的数组或集合类型 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsBaseTypeOrEnumOrCollection(this Type type) => + type.IsBasicType() || type.IsEnum || (type.IsArrayOrCollection(out var underlyingType) && + underlyingType.IsBaseTypeOrEnumOrCollection()); + + /// + /// 检查类型是否是静态类型 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsStatic(this Type type) => type is { IsSealed: true, IsAbstract: true }; + + /// + /// 检查类型是否是匿名类型 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsAnonymous(this Type type) + { + // 检查是否贴有 [CompilerGenerated] 特性 + if (!type.IsDefined(typeof(CompilerGeneratedAttribute), false)) + { + return false; + } + + // 类型限定名是否以 <> 开头且以 AnonymousType 结尾 + return type.FullName is not null + && type.FullName.StartsWith("<>") + && type.FullName.Contains("AnonymousType"); + } + + /// + /// 检查类型是否可实例化 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsInstantiable(this Type type) => + type is { IsClass: true, IsAbstract: false } + && !type.IsStatic(); + + /// + /// 检查类型是否是结构类型 + /// + /// 唯有如 public struct StructName {} 类型定义才符合验证要求。 + /// + /// + /// + /// + /// + /// + internal static bool IsStruct(this Type type) => type is { IsValueType: true, IsPrimitive: false, IsEnum: false }; + + /// + /// 检查类型是否派生自指定类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static bool IsAlienAssignableTo(this Type type, Type fromType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fromType); + + return fromType != type + && fromType.IsAssignableFrom(type); + } + + /// + /// 获取指定特性实例 + /// + /// 若特性不存在则返回 null + /// 特性类型 + /// + /// + /// + /// 是否在基类中搜索 + /// + /// + /// + internal static TAttribute? GetDefinedCustomAttribute(this Type type, bool inherit = false) + where TAttribute : Attribute => + // 检查是否定义 + !type.IsDefined(typeof(TAttribute), inherit) + ? null + : type.GetCustomAttribute(inherit); + + /// + /// 检查类型是否定义了公开无参构造函数 + /// + /// 用于 实例化 + /// + /// + /// + /// + /// + /// + internal static bool HasDefinePublicParameterlessConstructor(this Type type) => + type.IsInstantiable() + && type.GetConstructor(BindingFlags.Instance | BindingFlags.Public, Type.EmptyTypes) is not null; + + /// + /// 检查类型和指定类型定义是否相等 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static bool IsDefinitionEqual(this Type type, Type? compareType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(compareType); + + return type == compareType + || (type.IsGenericType + && compareType.IsGenericType + && type.IsGenericTypeDefinition // 💡 + && type == compareType.GetGenericTypeDefinition()); + } + + /// + /// 检查类型和指定类型定义是否相等 + /// + /// 将查找所有派生的基类和实现的接口。 + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static bool IsDefinitionEquals(this Type type, Type? compareType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(compareType); + + // 检查类型和指定类型定义是否相等 + if (type.IsDefinitionEqual(compareType)) + { + return true; + } + + // 递归查找所有基类 + var baseType = compareType.BaseType; + while (baseType is not null && baseType != typeof(object)) + { + // 检查类型和指定类型定义是否相等 + if (type.IsDefinitionEqual(baseType)) + { + return true; + } + + baseType = baseType.BaseType; + } + + // 检查所有实现的接口定义是否一致 + return compareType.GetInterfaces().Any(type.IsDefinitionEqual); + } + + /// + /// 检查类型和指定继承类型是否兼容 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static bool IsCompatibilityTo(this Type type, Type? inheritType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(inheritType); + + return inheritType != typeof(object) + && inheritType.IsAssignableFrom(type) + && (!type.IsGenericType + || (type.IsGenericType + && inheritType.IsGenericType + && type.GetTypeInfo().GenericTypeParameters.SequenceEqual(inheritType.GenericTypeArguments))); + } + + /// + /// 检查类型是否定义了指定方法 + /// + /// + /// + /// + /// 方法名称 + /// 可访问性成员绑定标记 + /// + /// + /// + /// + /// + /// + internal static bool IsDeclarationMethod(this Type type + , string name + , BindingFlags accessibilityBindingFlags + , out MethodInfo? methodInfo) + { + // 空检查 + ArgumentNullException.ThrowIfNull(type); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + methodInfo = type.GetMethod(name, + accessibilityBindingFlags | BindingFlags.Instance | BindingFlags.DeclaredOnly); + return methodInfo is not null; + } + + /// + /// 检查类型是否是整数类型 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsInteger(this Type type) + { + // 如果是枚举或浮点类型则直接返回 + if (type.IsEnum || type.IsDecimal()) + { + return false; + } + + // 检查 TypeCode + return Type.GetTypeCode(type) is TypeCode.Byte + or TypeCode.SByte + or TypeCode.Int16 + or TypeCode.Int32 + or TypeCode.Int64 + or TypeCode.UInt16 + or TypeCode.UInt32 + or TypeCode.UInt64; + } + + /// + /// 检查类型是否是小数类型 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsDecimal(this Type type) + { + // 如果是浮点类型则直接返回 + if (type == typeof(decimal) + || type == typeof(double) + || type == typeof(float)) + { + return true; + } + + // 检查 TypeCode + return Type.GetTypeCode(type) is TypeCode.Double or TypeCode.Decimal; + } + + /// + /// 检查类型是否是数值类型 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsNumeric(this Type type) => + type.IsInteger() + || type.IsDecimal(); + + /// + /// 检查类型是否是 类型 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsKeyValuePair(this Type type) => + type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>); + + /// + /// 检查类型是否是键值对集合类型 + /// + /// + /// + /// + /// 是否是 集合类型 + /// + /// + /// + internal static bool IsKeyValueCollection(this Type type, out bool isKeyValuePairCollection) + { + isKeyValuePairCollection = false; + + // 如果类型不是一个集合类型则直接返回 + if (!typeof(IEnumerable).IsAssignableFrom(type)) + { + return false; + } + + // 如果是 Hashtable 或 NameValueCollection 则直接返回 + if (type == typeof(Hashtable) || type == typeof(NameValueCollection)) + { + return true; + } + + // 如果是 IDictionary<,> 类型则直接返回 + if ((type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IDictionary<,>)) + || type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>))) + { + isKeyValuePairCollection = type.GetInterfaces() + .Any(i => i.IsGenericType && + ((i.GetGenericTypeDefinition() == typeof(ICollection<>) && + i.GetGenericArguments()[0].IsKeyValuePair()) || + (i.GetGenericTypeDefinition() == typeof(IEnumerable<>) && + i.GetGenericArguments()[0].IsKeyValuePair()))); + return true; + } + + // 检查是否是 KeyValuePair<,> 数组类型 + if (type.IsArray) + { + // 获取数组元素类型 + var elementType = type.GetElementType(); + + // 检查元素类型是否是 KeyValuePair<,> 类型 + if (elementType is null || !elementType.IsKeyValuePair()) + { + return false; + } + + isKeyValuePairCollection = true; + return true; + } + + // 检查是否是 KeyValuePair<,> 集合类型 + if (type is not { IsGenericType: true, GenericTypeArguments.Length: 1 } + || !type.GenericTypeArguments[0].IsKeyValuePair()) + { + return false; + } + + isKeyValuePairCollection = true; + return true; + } + + /// + /// 获取 Newtonsoft.Json.Linq.JProperty 类型键值属性值访问器 + /// + /// + /// + /// + /// + /// + /// + /// + internal static (Func KeyGetter, Func ValueGetter) + GetKeyValuePairOrJPropertyGetters( + this Type keyValuePairType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(keyValuePairType); + + // 检查类型是否是 KeyValuePair<,> 类型或者是 Newtonsoft.Json.Linq.JProperty 类型 + if (keyValuePairType.IsKeyValuePair() || keyValuePairType.FullName == "Newtonsoft.Json.Linq.JProperty") + { + // 反射搜索成员方式 + const BindingFlags bindingAttr = BindingFlags.Public | BindingFlags.Instance; + + // 创建 Key/Name 和 Value 属性值访问器 + var keyGetter = + keyValuePairType.CreatePropertyGetter(keyValuePairType.GetProperty("Key", bindingAttr) ?? + keyValuePairType.GetProperty("Name", bindingAttr)!); + var valueGetter = + keyValuePairType.CreatePropertyGetter(keyValuePairType.GetProperty("Value", + bindingAttr)!); + + return (keyGetter, valueGetter); + } + + throw new InvalidOperationException( + $"The type `{keyValuePairType}` is not a `KeyValuePair<,>` or `Newtonsoft.Json.Linq.JProperty` type."); + } + + /// + /// 创建实例属性值设置器 + /// + /// + /// + /// + /// 不支持 struct 类型设置属性值。 + /// + /// + /// + /// + /// + /// + internal static Action CreatePropertySetter(this Type type, PropertyInfo propertyInfo) + { + // 空检查 + ArgumentNullException.ThrowIfNull(propertyInfo); + + // 创建一个新的动态方法,并为其命名,命名格式为类型全名_设置_属性名 + var setterMethod = new DynamicMethod( + $"{type.FullName}_Set_{propertyInfo.Name}", + null, + [typeof(object), typeof(object)], + typeof(TypeExtensions).Module, + true + ); + + // 获取动态方法的 IL 生成器 + var ilGenerator = setterMethod.GetILGenerator(); + + // 获取属性的设置方法,并允许非公开访问 + var setMethod = propertyInfo.GetSetMethod(true); + + // 空检查 + ArgumentNullException.ThrowIfNull(setMethod); + + // 将目标对象加载到堆栈上,并将其转换为所需的类型 + ilGenerator.Emit(OpCodes.Ldarg_0); + ilGenerator.Emit(OpCodes.Castclass, type); + + // 将要分配的值加载到堆栈上 + ilGenerator.Emit(OpCodes.Ldarg_1); + + // 检查属性类型是否为值类型 + // 将值转换为属性类型 + // 对值进行拆箱,转换为适当的值类型 + ilGenerator.Emit(propertyInfo.PropertyType.IsValueType ? OpCodes.Unbox_Any : OpCodes.Castclass, + propertyInfo.PropertyType); + + // 在目标对象上调用设置方法 + ilGenerator.Emit(OpCodes.Callvirt, setMethod); + + // 从动态方法返回 + ilGenerator.Emit(OpCodes.Ret); + + // 创建一个委托并将其转换为适当的 Action 类型 + return (Action)setterMethod.CreateDelegate(typeof(Action)); + } + + /// + /// 创建实例字段值设置器 + /// + /// 不支持 struct 类型设置字段值 + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static Action CreateFieldSetter(this Type type, FieldInfo fieldInfo) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fieldInfo); + + // 创建一个新的动态方法,并为其命名,命名格式为类型全名_设置_字段名 + var setterMethod = new DynamicMethod( + $"{type.FullName}_Set_{fieldInfo.Name}", + null, + [typeof(object), typeof(object)], + typeof(TypeExtensions).Module, + true + ); + + // 获取动态方法的 IL 生成器 + var ilGenerator = setterMethod.GetILGenerator(); + + // 将目标对象加载到堆栈上,并将其转换为所需的类型 + ilGenerator.Emit(OpCodes.Ldarg_0); + ilGenerator.Emit(OpCodes.Castclass, type); + + // 将要分配的值加载到堆栈上 + ilGenerator.Emit(OpCodes.Ldarg_1); + + // 检查字段类型是否为值类型 + // 将值转换为字段类型 + // 对值进行拆箱,转换为适当的值类型 + ilGenerator.Emit(fieldInfo.FieldType.IsValueType ? OpCodes.Unbox_Any : OpCodes.Castclass, fieldInfo.FieldType); + + // 将堆栈上的值存储到字段中 + ilGenerator.Emit(OpCodes.Stfld, fieldInfo); + + // 从动态方法返回 + ilGenerator.Emit(OpCodes.Ret); + + // 创建一个委托并将其转换为适当的 Action 类型 + return (Action)setterMethod.CreateDelegate(typeof(Action)); + } + + /// + /// 创建实例属性值访问器 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static Func CreatePropertyGetter(this Type type, PropertyInfo propertyInfo) + { + // 空检查 + ArgumentNullException.ThrowIfNull(propertyInfo); + + // 创建一个新的动态方法,并为其命名,命名格式为类型全名_获取_属性名 + var dynamicMethod = new DynamicMethod( + $"{type.FullName}_Get_{propertyInfo.Name}", + typeof(object), + [typeof(object)], + typeof(TypeExtensions).Module, + true + ); + + // 获取动态方法的 IL 生成器 + var ilGenerator = dynamicMethod.GetILGenerator(); + + // 获取属性的获取方法,并允许非公开访问 + var getMethod = propertyInfo.GetGetMethod(true); + + // 空检查 + ArgumentNullException.ThrowIfNull(getMethod); + + // 将目标对象加载到堆栈上 + ilGenerator.Emit(OpCodes.Ldarg_0); + ilGenerator.Emit(type.IsValueType ? OpCodes.Unbox : OpCodes.Castclass, type); + + // 调用获取方法 + ilGenerator.EmitCall(OpCodes.Callvirt, getMethod, null); + + // 如果属性类型为值类型,则装箱为 object 类型 + if (propertyInfo.PropertyType.IsValueType) + { + ilGenerator.Emit(OpCodes.Box, propertyInfo.PropertyType); + } + + // 从动态方法返回 + ilGenerator.Emit(OpCodes.Ret); + + // 创建一个委托并将其转换为适当的 Func 类型 + return (Func)dynamicMethod.CreateDelegate(typeof(Func)); + } + + /// + /// 创建静态属性值访问器 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static Func CreateStaticPropertyGetter(this Type type, PropertyInfo propertyInfo) + { + // 空检查 + ArgumentNullException.ThrowIfNull(propertyInfo); + + // 获取属性的获取方法,并允许非公开访问 + var getMethod = propertyInfo.GetGetMethod(true); + + // 空检查 + ArgumentNullException.ThrowIfNull(getMethod); + + // 创建一个新的动态方法,并为其命名,命名格式为类型全名_获取_属性名 + var dynamicMethod = new DynamicMethod( + $"{type.FullName}_Get_{propertyInfo.Name}", + typeof(object), + Type.EmptyTypes, // 没有参数 + typeof(TypeExtensions).Module, + true + ); + + // 获取动态方法的 IL 生成器 + var ilGenerator = dynamicMethod.GetILGenerator(); + + // 调用静态属性的获取方法 + ilGenerator.EmitCall(OpCodes.Call, getMethod, null); + + // 如果属性类型为值类型,则装箱为 object 类型 + if (propertyInfo.PropertyType.IsValueType) + { + ilGenerator.Emit(OpCodes.Box, propertyInfo.PropertyType); + } + + // 从动态方法返回 + ilGenerator.Emit(OpCodes.Ret); + + // 创建一个委托并将其转换为适当的 Func 类型 + return (Func)dynamicMethod.CreateDelegate(typeof(Func)); + } + + /// + /// 创建实例字段值访问器 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static Func CreateFieldGetter(this Type type, FieldInfo fieldInfo) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fieldInfo); + + // 创建一个新的动态方法,并为其命名,命名格式为类型全名_获取_字段名 + var dynamicMethod = new DynamicMethod( + $"{type.FullName}_Get_{fieldInfo.Name}", + typeof(object), + [typeof(object)], + typeof(TypeExtensions).Module, + true + ); + + // 获取动态方法的 IL 生成器 + var ilGenerator = dynamicMethod.GetILGenerator(); + + // 将目标对象加载到堆栈上,并将其转换为字段的声明类型 + ilGenerator.Emit(OpCodes.Ldarg_0); + ilGenerator.Emit(type.IsValueType ? OpCodes.Unbox : OpCodes.Castclass, type); + + // 加载字段的值到堆栈上 + ilGenerator.Emit(OpCodes.Ldfld, fieldInfo); + + // 如果字段类型为值类型,则装箱为 object 类型 + if (fieldInfo.FieldType.IsValueType) + { + ilGenerator.Emit(OpCodes.Box, fieldInfo.FieldType); + } + + // 从动态方法返回 + ilGenerator.Emit(OpCodes.Ret); + + // 创建一个委托并将其转换为适当的 Func 类型 + return (Func)dynamicMethod.CreateDelegate(typeof(Func)); + } + + /// + /// 创建静态字段值访问器 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static Func CreateStaticFieldGetter(this Type type, FieldInfo fieldInfo) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fieldInfo); + + // 创建一个新的动态方法,并为其命名,命名格式为类型全名_获取_字段名 + var dynamicMethod = new DynamicMethod( + $"{type.FullName}_Get_{fieldInfo.Name}", + typeof(object), + Type.EmptyTypes, + typeof(TypeExtensions).Module, + true + ); + + // 获取动态方法的 IL 生成器 + var ilGenerator = dynamicMethod.GetILGenerator(); + + // 加载静态字段的值到堆栈上 + ilGenerator.Emit(OpCodes.Ldsfld, fieldInfo); + + // 如果字段类型为值类型,则装箱为 object 类型 + if (fieldInfo.FieldType.IsValueType) + { + ilGenerator.Emit(OpCodes.Box, fieldInfo.FieldType); + } + + // 从动态方法返回 + ilGenerator.Emit(OpCodes.Ret); + + // 创建一个委托并将其转换为适当的 Func 类型 + return (Func)dynamicMethod.CreateDelegate(typeof(Func)); + } + + /// + /// 输出类型的友好字符串 + /// + /// + /// + /// + /// + /// + /// + internal static string? ToFriendlyString(this Type? type) + { + // 空检查 + if (type is null) + { + return null; + } + + // 检查是否是泛型类型 + if (type.IsGenericType) + { + // 获取类型名称 + var typeName = type.Name.Split('`')[0]; + + // 获取泛型参数 + var genericArguments = type.GetGenericArguments().Select(ToFriendlyString).ToArray(); + + return $"{type.Namespace}.{typeName}<{string.Join(',', genericArguments)}>"; + } + + // 检查是否是非泛型且不是数组类型 + // ReSharper disable once InvertIf + if (type.IsArray) + { + var rank = new string(',', type.GetArrayRank() - 1); + return $"{ToFriendlyString(type.GetElementType()!)}[{rank}]"; + } + + return type.FullName ?? type.Name; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/Utf8JsonReaderExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/Utf8JsonReaderExtensions.cs new file mode 100644 index 000000000..8c6180782 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Extensions/Utf8JsonReaderExtensions.cs @@ -0,0 +1,37 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text.Json; + +namespace ThingsGateway.Extensions; + +/// +/// 拓展类 +/// +internal static class Utf8JsonReaderExtensions +{ + /// + /// 获取 JSON 原始输入数据 + /// + /// + /// + /// + /// + /// + /// + internal static string GetRawText(this ref Utf8JsonReader reader) + { + // 将 Utf8JsonReader 转换为 JsonDocument + using var jsonDocument = JsonDocument.ParseValue(ref reader); + + return jsonDocument.RootElement.Clone().GetRawText(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Models/ComponentMetadata.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Models/ComponentMetadata.cs new file mode 100644 index 000000000..48fac9d29 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Models/ComponentMetadata.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +/// 组件元数据 +/// +internal readonly struct ComponentMetadata +{ + /// + /// + /// + /// 组件名称 + /// 版本号 + /// 描述 + internal ComponentMetadata(string name, Version? version, string? description) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + Version = version?.ToString(); + Description = description; + + } + + /// + /// 组件名称 + /// + internal string Name { get; init; } + + /// + /// 版本号 + /// + internal string? Version { get; init; } + + /// + /// 描述 + /// + internal string? Description { get; init; } + +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Options/CoreOptions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Options/CoreOptions.cs new file mode 100644 index 000000000..933b6ce9c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Options/CoreOptions.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace ThingsGateway; + +/// +/// 核心模块选项 +/// +internal sealed class CoreOptions +{ + /// + /// 已注册的组件元数据集合 + /// + internal readonly ConcurrentDictionary _metadataOfRegistered; + + /// + /// 子选项集合 + /// + internal readonly ConcurrentDictionary _optionsInstances; + + /// + /// + /// + internal CoreOptions() + { + _optionsInstances = new ConcurrentDictionary(); + _metadataOfRegistered = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + EntryComponentTypes = []; + } + + /// + /// 入口组件类型集合 + /// + internal HashSet EntryComponentTypes { get; init; } + + /// + /// 获取子选项 + /// + /// 选项类型 + /// + /// + /// + internal TOptions GetOrAdd() + where TOptions : class, new() + { + // 获取子选项类型 + var optionsType = typeof(TOptions); + + // 如果不存在则添加 + _ = _optionsInstances.TryAdd(optionsType, new TOptions()); + + return (TOptions)_optionsInstances[optionsType]; + } + + /// + /// 移除子选项 + /// + /// 选项类型 + internal void Remove() + where TOptions : class, new() + { + // 获取子选项类型 + var optionsType = typeof(TOptions); + + // 如果存在则移除 + _ = _optionsInstances.TryRemove(optionsType, out _); + } + + /// + /// 登记组件注册信息 + /// + /// + /// + /// + internal void TryRegisterComponent(ComponentMetadata metadata) => + _metadataOfRegistered.TryAdd(metadata.Name, metadata); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Reflection/ObjectPropertyGetter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Reflection/ObjectPropertyGetter.cs new file mode 100644 index 000000000..34c0c4729 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Reflection/ObjectPropertyGetter.cs @@ -0,0 +1,136 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.Reflection; + +/// +/// 创建对象类型实例属性值访问器 +/// +/// 对象类型 +public sealed class ObjectPropertyGetter where T : class +{ + /// + /// 反射搜索成员方式 + /// + internal const BindingFlags _defaultBindingFlags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + /// + /// 对象类型实例属性值访问器集合 + /// + internal readonly ConcurrentDictionary> _propertyGetters = new(); + + /// + /// + /// + /// 反射搜索成员方式 + public ObjectPropertyGetter(BindingFlags? bindingFlags = null) => Initialize(bindingFlags); + + /// + /// 初始化对象类型实例属性值访问器 + /// + /// 反射搜索成员方式 + internal void Initialize(BindingFlags? bindingFlags = null) + { + // 获取对象类型 + var type = typeof(T); + var bindingAttr = bindingFlags ?? _defaultBindingFlags; + + // 获取所有符合反射搜索成员方式的属性集合 + var properties = type.GetProperties(bindingAttr).Where(u => u.CanRead).ToList(); + + // 遍历属性集合创建属性值访问器并存储到集合中 + foreach (var property in properties) + { + _propertyGetters.TryAdd(property.Name, type.CreatePropertyGetter(property)); + } + } + + /// + /// 尝试获取属性值访问器 + /// + /// 属性名称 + /// 属性值访问器 + /// + /// + /// + public bool TryGetPropertyGetter(string propertyName, [NotNullWhen(true)] out Func? propertyGetter) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + + return _propertyGetters.TryGetValue(propertyName, out propertyGetter); + } + + /// + /// 获取属性值访问器 + /// + /// 属性名称 + /// + /// + /// + /// + public Func GetPropertyGetter(string propertyName) + { + // 尝试获取属性值访问器 + if (!TryGetPropertyGetter(propertyName, out var propertyGetter)) + { + throw new ArgumentException( + $"Property `{propertyName}` not found on type `{typeof(T)}`. Ensure that the BindingFlags used allow access to this property.", + nameof(propertyName)); + } + + return propertyGetter; + } + + /// + /// 获取属性值访问器集合 + /// + /// + /// + /// + public IDictionary> GetPropertyGetters() => + _propertyGetters.ToDictionary(u => u.Key, u => u.Value); + + /// + /// 获取属性值 + /// + /// 类型实例 + /// 属性名称 + /// + /// + /// + public object? GetPropertyValue(object instance, string propertyName) + { + // 空检查 + ArgumentNullException.ThrowIfNull(instance); + + return GetPropertyGetter(propertyName)(instance); + } + + /// + /// 获取属性值 + /// + /// 类型实例 + /// 属性名称 + /// 属性值目标类型 + /// + /// + /// + public TProperty? GetPropertyValue(object instance, string propertyName) => + (TProperty?)GetPropertyValue(instance, propertyName); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Reflection/ObjectPropertySetter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Reflection/ObjectPropertySetter.cs new file mode 100644 index 000000000..1ca8f0ee2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Reflection/ObjectPropertySetter.cs @@ -0,0 +1,123 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.Reflection; + +/// +/// 创建对象类型实例属性值设置器 +/// +/// 对象类型 +public sealed class ObjectPropertySetter where T : class +{ + /// + /// 反射搜索成员方式 + /// + internal const BindingFlags _defaultBindingFlags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + /// + /// 对象类型实例属性值设置器集合 + /// + internal readonly ConcurrentDictionary> _propertySetters = new(); + + /// + /// + /// + /// 反射搜索成员方式 + public ObjectPropertySetter(BindingFlags? bindingFlags = null) => Initialize(bindingFlags); + + /// + /// 初始化对象类型实例属性值设置器 + /// + /// 反射搜索成员方式 + internal void Initialize(BindingFlags? bindingFlags = null) + { + // 获取对象类型 + var type = typeof(T); + var bindingAttr = bindingFlags ?? _defaultBindingFlags; + + // 获取所有符合反射搜索成员方式的属性集合 + var properties = type.GetProperties(bindingAttr).Where(u => u.CanRead).ToList(); + + // 遍历属性集合创建属性值设置器并存储到集合中 + foreach (var property in properties) + { + _propertySetters.TryAdd(property.Name, type.CreatePropertySetter(property)); + } + } + + /// + /// 尝试获取属性值设置器 + /// + /// 属性名称 + /// 属性值设置器 + /// + /// + /// + public bool TryGetPropertySetter(string propertyName, + [NotNullWhen(true)] out Action? propertySetter) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(propertyName); + + return _propertySetters.TryGetValue(propertyName, out propertySetter); + } + + /// + /// 获取属性值设置器 + /// + /// 属性名称 + /// + /// + /// + /// + public Action GetPropertySetter(string propertyName) + { + // 尝试获取属性值设置器 + if (!TryGetPropertySetter(propertyName, out var propertySetter)) + { + throw new ArgumentException( + $"Property `{propertyName}` not found on type `{typeof(T)}`. Ensure that the BindingFlags used allow access to this property.", + nameof(propertyName)); + } + + return propertySetter; + } + + /// + /// 获取属性值设置器集合 + /// + /// + /// + /// + public IDictionary> GetPropertySetters() => + _propertySetters.ToDictionary(u => u.Key, u => u.Value); + + /// + /// 获取属性值 + /// + /// 类型实例 + /// 属性名称 + /// 属性值 + public void SetPropertyValue(object instance, string propertyName, object? propertyValue) + { + // 空检查 + ArgumentNullException.ThrowIfNull(instance); + + GetPropertySetter(propertyName)(instance, propertyValue); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/AliasAsUtility.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/AliasAsUtility.cs new file mode 100644 index 000000000..52ec8dd6c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/AliasAsUtility.cs @@ -0,0 +1,96 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Utilities; + +/// +/// 提供别名获取实用方法 +/// +public static class AliasAsUtility +{ + /// + /// 获取属性名 + /// + /// + /// + /// + /// 是否定义特性 + /// + /// + /// + public static string GetPropertyName(PropertyInfo property, out bool isDefined) + { + // 空检查 + ArgumentNullException.ThrowIfNull(property); + + // 获取属性名 + var name = property.Name; + + // 检查属性是否贴有 [AliasAs] 特性 + if (!property.IsDefined(typeof(AliasAsAttribute))) + { + isDefined = false; + return name; + } + + // 获取 AliasAsAttribute 实例的 AliasAs 属性值 + var aliasAs = property.GetCustomAttribute()!.AliasAs; + + // 空检查 + if (!string.IsNullOrWhiteSpace(aliasAs)) + { + name = aliasAs.Trim(); + } + + isDefined = true; + return name; + } + + /// + /// 获取参数名 + /// + /// + /// + /// + /// 是否定义特性 + /// + /// + /// + public static string GetParameterName(ParameterInfo parameter, out bool isDefined) + { + // 空检查 + ArgumentNullException.ThrowIfNull(parameter); + + // 获取参数名 + var name = parameter.Name!; + + // 检查参数是否贴有 [AliasAs] 特性 + if (!parameter.IsDefined(typeof(AliasAsAttribute))) + { + isDefined = false; + return name; + } + + // 获取 AliasAsAttribute 实例的 AliasAs 属性值 + var aliasAs = parameter.GetCustomAttribute()!.AliasAs; + + // 空检查 + if (!string.IsNullOrWhiteSpace(aliasAs)) + { + name = aliasAs.Trim(); + } + + isDefined = true; + return name; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/FileUtility.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/FileUtility.cs new file mode 100644 index 000000000..0efa8cf38 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/FileUtility.cs @@ -0,0 +1,179 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.Utilities; + +/// +/// 提供文件实用方法 +/// +public static class FileUtility +{ + /// + /// 尝试验证文件拓展名 + /// + /// 特别说明:不支持拓展名中包含通配符,如 * + /// 文件的名称 + /// 允许的文件扩展名字符串,用分号分隔 + /// 有效的文件拓展名集合 + /// + /// + /// + public static bool TryValidateExtension(string fileName, [NotNullWhen(false)] string? allowedFileExtensions, + [NotNullWhen(false)] out HashSet? validFileExtensions) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + + // 初始化 out 返回值 + validFileExtensions = null; + + return string.IsNullOrWhiteSpace(allowedFileExtensions) || TryValidateExtension(fileName, + allowedFileExtensions.Split(';', StringSplitOptions.RemoveEmptyEntries), out validFileExtensions); + } + + /// + /// 尝试验证文件拓展名 + /// + /// 特别说明:不支持拓展名中包含通配符,如 * + /// 文件的名称 + /// 允许的文件拓展名数组 + /// 有效的文件拓展名集合 + /// + /// + /// + public static bool TryValidateExtension(string fileName, [NotNullWhen(false)] string[]? allowedFileExtensions, + [NotNullWhen(false)] out HashSet? validFileExtensions) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + + // 初始化 out 返回值 + validFileExtensions = null; + + // 空检查 + if (allowedFileExtensions.IsNullOrEmpty()) + { + return true; + } + + // 获取有效的文件拓展名集合 + validFileExtensions = GetValidFileExtensions(allowedFileExtensions); + + // 获取文件拓展名 + var extension = Path.GetExtension(fileName); + + return validFileExtensions.Contains(extension); + } + + /// + /// 验证文件拓展名 + /// + /// 特别说明:不支持拓展名中包含通配符,如 * + /// 文件的名称 + /// 允许的文件扩展名字符串,用分号分隔 + /// + public static void ValidateExtension(string fileName, string? allowedFileExtensions) => + ValidateExtension(fileName, + (allowedFileExtensions ?? string.Empty).Split(';', StringSplitOptions.RemoveEmptyEntries)); + + /// + /// 验证文件拓展名 + /// + /// 特别说明:不支持拓展名中包含通配符,如 * + /// 文件的名称 + /// 允许的文件拓展名数组 + /// + public static void ValidateExtension(string fileName, string[]? allowedFileExtensions) + { + if (!TryValidateExtension(fileName, allowedFileExtensions, out var validFileExtensions)) + { + throw new InvalidOperationException( + $"The file type is not allowed. Only the following file types are supported: `{string.Join(", ", validFileExtensions)}`."); + } + } + + /// + /// 尝试验证文件大小 + /// + /// 文件路径 + /// 允许的文件大小。以字节为单位。 + /// + /// + /// + /// + public static bool TryValidateSize(string filePath, long maxFileSizeInBytes) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + // 小于或等于 0 检查 + if (maxFileSizeInBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxFileSizeInBytes), + "Max file size in bytes must be greater than zero."); + } + + // 初始化 FileInfo 实例 + var fileInfo = new FileInfo(filePath); + + return fileInfo.Length <= maxFileSizeInBytes; + } + + /// + /// 验证文件大小 + /// + /// 文件路径 + /// 允许的文件大小。以字节为单位。 + /// + public static void ValidateSize(string filePath, long maxFileSizeInBytes) + { + var unit = maxFileSizeInBytes < 1024 ? "KB" : "MB"; + + if (!TryValidateSize(filePath, maxFileSizeInBytes)) + { + throw new InvalidOperationException( + $"The file size exceeds the maximum allowed size of `{maxFileSizeInBytes.ToSizeUnits(unit):F2} {unit}`."); + } + } + + /// + /// 获取有效的文件拓展名集合 + /// + /// 允许的文件拓展名数组 + /// + /// + /// + internal static HashSet GetValidFileExtensions(string[] allowedFileExtensions) + { + // 空检查 + ArgumentNullException.ThrowIfNull(allowedFileExtensions); + + // 初始化 HashSet 实例 + var hashSet = new HashSet(StringComparer.OrdinalIgnoreCase); + + // 逐条添加到 HashSet 中 + foreach (var extension in allowedFileExtensions) + { + // 空检查 + if (!string.IsNullOrWhiteSpace(extension)) + { + // 确保拓展名以 . 开头 + hashSet.Add('.' + extension.TrimStart('.')); + } + } + + return hashSet; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/JsonUtility.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/JsonUtility.cs new file mode 100644 index 000000000..a6bfe0db5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/JsonUtility.cs @@ -0,0 +1,82 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace ThingsGateway.Utilities; + +/// +/// 提供 JSON 实用方法 +/// +public static class JsonUtility +{ + /// + /// 解析 JSON 字符串 + /// + /// JSON 字符串 + /// + /// + /// + /// + public static JsonDocument Parse(string jsonString) + { + // 空检查 + ArgumentNullException.ThrowIfNull(jsonString); + + return JsonDocument.Parse(jsonString); + } + + /// + /// 尝试解析 JSON 字符串 + /// + /// JSON 字符串 + /// + /// + /// + /// + /// + /// + public static bool TryParse(string jsonString, [NotNullWhen(true)] out JsonDocument? jsonDocument) + { + // 空检查 + ArgumentNullException.ThrowIfNull(jsonString); + + try + { + jsonDocument = Parse(jsonString); + return true; + } + catch (JsonException) + { + jsonDocument = null; + return false; + } + } + + /// + /// 检查 ValueKind 属性值是否是 ObjectArrayNull + /// + /// + /// + /// + /// + /// + /// + public static bool IsObjectOrArrayOrNull(JsonDocument jsonDocument) + { + // 空检查 + ArgumentNullException.ThrowIfNull(jsonDocument); + + var root = jsonDocument.RootElement; + return root.ValueKind is JsonValueKind.Object or JsonValueKind.Array or JsonValueKind.Null; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/NetworkUtility.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/NetworkUtility.cs new file mode 100644 index 000000000..299ba8658 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/NetworkUtility.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.NetworkInformation; +using System.Security.Cryptography; + +namespace ThingsGateway.Utilities; + +/// +/// 提供网络相关的实用方法 +/// +public static class NetworkUtility +{ + // 定义一个私有的静态锁对象用于同步访问 + internal static readonly object PortLock = new(); + + /// + /// 查找一个可用的 TCP 端口 + /// + /// + /// + /// + public static int FindAvailableTcpPort() + { + // 定义端口可用范围 + const int fromPort = 10000; + const int toPort = 65535; + + do + { + // 使用锁来确保线程安全地生成和检查端口 + lock (PortLock) + { + var randomPort = RandomNumberGenerator.GetInt32(fromPort, toPort + 1); + + // 检查端口是否已经在使用 + if (!IsPortInUse(randomPort)) + { + // 如果端口空闲,直接返回 + return randomPort; + } + } + + // 等待一小段时间以避免忙等 + Thread.Sleep(10); + } while (true); + } + + /// + /// 检查 URL 是否是一个互联网地址 + /// + /// URL 地址 + /// + /// + /// + public static bool IsWebUrl(string url) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(url); + + return Uri.TryCreate(url, UriKind.Absolute, out var result) && + (result.Scheme == Uri.UriSchemeHttp || result.Scheme == Uri.UriSchemeHttps); + } + + /// + /// 检查指定端口是否正在使用 + /// + /// 如果端口正在使用则返回 true,否则返回 false + /// 要检查的端口号。 + /// + /// + /// + internal static bool IsPortInUse(int port) => + IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners().Any(p => p.Port == port); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/RuntimeUtility.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/RuntimeUtility.cs new file mode 100644 index 000000000..012e6992e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/RuntimeUtility.cs @@ -0,0 +1,54 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Runtime.InteropServices; + +namespace ThingsGateway.Utilities; + +/// +/// 提供运行时实用方法 +/// +public static class RuntimeUtility +{ + /// + /// 获取操作系统描述 + /// + public static string OSDescription => RuntimeInformation.OSDescription; + + /// + /// 获取操作系统基本名称 + /// + /// + /// + /// + public static string GetOSName() + { + // 检查操作系统是否是 Windows 平台 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "Windows"; + } + + // 检查操作系统是否是 Linux 平台 + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "Linux"; + } + + // 检查操作系统是否是 macOS 平台 + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "macOS"; + } + + return "Unknown"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/StringUtility.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/StringUtility.cs new file mode 100644 index 000000000..67ced3b8a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/Core/Utilities/StringUtility.cs @@ -0,0 +1,117 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.Utilities; + +/// +/// 提供字符串实用方法 +/// +public static class StringUtility +{ + /// + /// 格式化键值集合摘要 + /// + /// 键值集合 + /// 摘要 + /// + /// + /// + public static string? FormatKeyValuesSummary(IEnumerable>> keyValues, + string? summary = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(keyValues); + + // 获取键值集合数量 + var keyValuePairs = keyValues as KeyValuePair>[] ?? keyValues.ToArray(); + var count = keyValuePairs.Length; + + // 空检查 + if (count == 0) + { + return null; + } + + // 注册 CodePagesEncodingProvider,使得程序能够识别并使用 Windows 代码页中的各种编码 + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + // 获取最长键名长度用于对齐键名字符串 + var totalByteCount = keyValuePairs.Max(h => h.Key.Length) + 5; + + // 初始化 StringBuilder 实例 + var stringBuilder = new StringBuilder(); + + // 检查是否设置了摘要 + var hasSummary = !string.IsNullOrWhiteSpace(summary); + + // 逐条构建摘要信息 + var index = 0; + foreach (var (key, value) in keyValuePairs) + { + // 检查是否包含摘要,如果有则添加制表符 + if (hasSummary) + { + stringBuilder.Append('\t'); + } + + // 获取格式化后的值 + var formatValue = AddTabToEachLine(string.Join(", ", value), true); + + // 处理空 Key 问题 + if (!string.IsNullOrWhiteSpace(key)) + { + stringBuilder.Append($"{(key + ':').PadStringToByteLength(totalByteCount)} {formatValue}"); + } + else + { + stringBuilder.Append($"{string.Join(", ", formatValue)}"); + } + + // 处理最后一行空行问题 + if (index < count - 1) + { + stringBuilder.Append("\r\n"); + } + + index++; + } + + // 获取字符串 + var formatString = stringBuilder.ToString(); + + return hasSummary ? $"{summary}: \r\n{formatString}" : formatString; + } + + /// + /// 在字符串每一行添加制表符 + /// + /// 文本 + /// 是否跳过第一行 + /// + /// + /// + internal static string? AddTabToEachLine(string? input, bool skipFirstLine = false) + { + // 空检查 + if (input is null) + { + return input; + } + + // 使用 Environment.NewLine 以确保跨平台兼容性 + return string.Join(Environment.NewLine, input.Split([Environment.NewLine, "\n"], StringSplitOptions.None) + .Select((line, i) => (skipFirstLine && i == 0 ? string.Empty : " ") + line)); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpContextForwardBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpContextForwardBuilder.cs new file mode 100644 index 000000000..4b3306aac --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpContextForwardBuilder.cs @@ -0,0 +1,484 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +using System.Net.Mime; + +using ThingsGateway.AspNetCore.Extensions; +using ThingsGateway.Extensions; + +using MediaTypeHeaderValue = System.Net.Http.Headers.MediaTypeHeaderValue; + +namespace ThingsGateway.HttpRemote; + +/// +/// 转发构建器 +/// +public sealed class HttpContextForwardBuilder +{ + /// + /// 实例 + /// + internal static readonly Lazy _actionResultContentConverterInstance = + new(() => new IActionResultContentConverter()); + + /// + /// 忽略在转发时需要跳过的请求标头列表 + /// + internal static HashSet _ignoreRequestHeaders = + [ + Constants.X_FORWARD_TO_HEADER, "Host", "Accept", "Accept-CH", "Accept-Charset", "Accept-Encoding", + "Accept-Language", "Accept-Patch", "Accept-Post", "Accept-Ranges" + ]; + + /// + /// + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// + /// + /// + internal HttpContextForwardBuilder(HttpContext? httpContext, HttpMethod httpMethod, Uri? requestUri = null, + HttpContextForwardOptions? forwardOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpMethod); + ArgumentNullException.ThrowIfNull(httpContext); + + HttpContext = httpContext; + Method = httpMethod; + + RequestUri = GetTargetUri(httpContext, requestUri); + ForwardOptions = GetForwardOptions(httpContext, forwardOptions); + } + + /// + /// 转发地址 + /// + public Uri? RequestUri { get; } + + /// + /// 转发方式 + /// + public HttpMethod Method { get; } + + /// + public HttpContext HttpContext { get; } + + /// + public HttpContextForwardOptions ForwardOptions { get; } + + /// + /// 获取目标地址 + /// + /// + /// + /// + /// 请求地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// + /// + /// + internal static Uri? GetTargetUri(HttpContext httpContext, Uri? requestUri = null) + { + // 空检查 + if (requestUri is not null) + { + return requestUri; + } + + // 尝试从请求标头 X-Forward-To 中获取目标地址 + var targetUrl = httpContext.Request.Headers[Constants.X_FORWARD_TO_HEADER].ToString(); + + return string.IsNullOrWhiteSpace(targetUrl) ? null : new Uri(targetUrl, UriKind.RelativeOrAbsolute); + } + + /// + /// 获取 实例 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static HttpContextForwardOptions GetForwardOptions(HttpContext httpContext, + HttpContextForwardOptions? forwardOptions) => + forwardOptions ?? + httpContext.RequestServices.GetService>() + ?.Value ?? new HttpContextForwardOptions(); + + /// + /// 构建 实例 + /// + /// 自定义配置委托 + /// + /// + /// + internal HttpRequestBuilder Build(Action? configure = null) + { + // 初始化 HttpRequestBuilder 实例 + var httpRequestBuilder = HttpRequestBuilder.Create(Method, RequestUri, configure) + .AddHttpContentConverters(() => [_actionResultContentConverterInstance.Value]).DisableCache(); + + // 复制查询参数和路由参数 + CopyQueryAndRouteValues(httpRequestBuilder); + + // 复制请求标头 + CopyHeaders(httpRequestBuilder); + + // 复制请求内容 + CopyBodyAsync(httpRequestBuilder).Wait(HttpContext.RequestAborted); + + return httpRequestBuilder; + } + + /// + /// 构建 实例 + /// + /// 自定义配置委托 + /// + /// + /// + internal async Task BuildAsync(Action? configure = null) + { + // 初始化 HttpRequestBuilder 实例 + var httpRequestBuilder = HttpRequestBuilder.Create(Method, RequestUri, configure) + .AddHttpContentConverters(() => [new IActionResultContentConverter()]).DisableCache(); + + // 复制查询参数和路由参数 + CopyQueryAndRouteValues(httpRequestBuilder); + + // 复制请求标头 + CopyHeaders(httpRequestBuilder); + + // 复制请求内容 + await CopyBodyAsync(httpRequestBuilder).ConfigureAwait(false); + + return httpRequestBuilder; + } + + /// + /// 复制查询参数和路由参数 + /// + /// + /// + /// + internal void CopyQueryAndRouteValues(HttpRequestBuilder httpRequestBuilder) + { + // 获取查询参数集合 + var queryValues = HttpContext.Request.Query.ToArray(); + + // 空检查 + if (queryValues.Length > 0) + { + // 检查是否转发查询参数(URL 参数) + if (ForwardOptions.WithQueryParameters) + { + // 将查询参数添加到查询参数集合中 + httpRequestBuilder.WithQueryParameters(queryValues); + } + + // 将查询参数添加到路径参数集合中 + httpRequestBuilder.WithPathParameters(queryValues); + } + + // 获取路由参数集合 + var routeValues = HttpContext.Request.RouteValues; + + // 空检查 + if (routeValues.Count > 0) + { + // 将路由参数添加到路径参数集合中 + httpRequestBuilder.WithPathParameters(routeValues); + } + } + + /// + /// 复制请求标头 + /// + /// + /// + /// + internal void CopyHeaders(HttpRequestBuilder httpRequestBuilder) + { + // 获取 HttpRequest 实例 + var httpRequest = HttpContext.Request; + + // 添加原始请求地址标头 + httpRequestBuilder.WithHeader(Constants.X_ORIGINAL_URL_HEADER, httpRequest.GetFullRequestUrl(), replace: true); + + // 检查是否转发请求标头 + if (!ForwardOptions.WithRequestHeaders) + { + return; + } + + // 初始化忽略在转发时需要跳过的请求标头列表 + var ignoreRequestHeaders = + _ignoreRequestHeaders.ConcatIgnoreNull(ForwardOptions.IgnoreRequestHeaders).Distinct().ToArray(); + + // 忽略特定请求标头列表 + httpRequestBuilder.WithHeaders( + httpRequest.Headers.Where(u => !u.Key.IsIn(ignoreRequestHeaders, StringComparer.OrdinalIgnoreCase)), + replace: true); + + // 检查是否需要重新设置 Host 请求标头 + if (ForwardOptions.ResetHostRequestHeader) + { + httpRequestBuilder.WithHeader(HeaderNames.Host, + $"{RequestUri?.Host}{(RequestUri?.IsDefaultPort != true ? $":{RequestUri?.Port}" : string.Empty)}", + replace: true); + } + } + + /// + /// 复制请求内容 + /// + /// + /// + /// + internal async Task CopyBodyAsync(HttpRequestBuilder httpRequestBuilder) + { + // 获取 HttpRequest 实例 + var httpRequest = HttpContext.Request; + + // 检查是否包含请求内容 + if (httpRequest.ContentLength is null or 0) + { + return; + } + + // 获取原始内容类型 + var rawContentType = httpRequest.ContentType; + + // 空检查 + ArgumentException.ThrowIfNullOrEmpty(rawContentType); + + // 解析原始内容类型 + var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(rawContentType); + + // 获取内容类型 + var contentType = mediaTypeHeaderValue.MediaType; + + // 空检查 + ArgumentNullException.ThrowIfNull(contentType); + + // 读取 HttpContext 请求体流 + var bodyStream = await ReadBodyAsync(httpRequestBuilder).ConfigureAwait(false); + + // 检查请求内容类型是否为 multipart/form-data + if (!contentType.IsIn([MediaTypeNames.Multipart.FormData], StringComparer.OrdinalIgnoreCase)) + { + // 复制非多部分表单内容 + CopyNonMultipartFormData(bodyStream, contentType, httpRequestBuilder); + } + else + { + // 复制多部分表单内容 + await CopyMultipartFormDataAsync(bodyStream, rawContentType, httpRequestBuilder, + HttpContext.RequestAborted).ConfigureAwait(false); + } + + // 将请求体流的位置重置回起始位置 + httpRequest.Body.Position = 0; + } + + /// + /// 复制非多部分表单内容 + /// + /// + /// + /// + /// 内容类型 + /// + /// + /// + internal static void CopyNonMultipartFormData(Stream bodyStream, string contentType, + HttpRequestBuilder httpRequestBuilder) + { + // 初始化 StreamContent 实例 + var streamContent = new StreamContent(bodyStream); + + // 设置请求内容 + httpRequestBuilder.SetContent(streamContent, contentType); + } + + /// + /// 复制多部分表单内容 + /// + /// + /// + /// + /// 原始内容类型 + /// + /// + /// + /// + /// + /// + internal static async Task CopyMultipartFormDataAsync(Stream bodyStream, string rawContentType, + HttpRequestBuilder httpRequestBuilder, CancellationToken cancellationToken) + { + // 获取多部分表单内容的边界;注意:这里可能出现前后双引号问题 + var boundary = rawContentType.Split('=')[1].TrimStart('"').TrimEnd('"'); + + // 初始化 HttpMultipartFormDataBuilder 实例 + var httpMultipartFormDataBuilder = + new HttpMultipartFormDataBuilder(httpRequestBuilder) { Boundary = boundary }; + + // 初始化 MultipartReader 实例 + var multipartReader = new MultipartReader(boundary, bodyStream); + + while ((await multipartReader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false))is { } multipartSection) + { + // 检查当前节是否为文件节 + if (multipartSection.AsFileSection() is not null) + { + // 复制多部分表单内容文件节内容 + await CopyFileMultipartSectionAsync(multipartSection, httpMultipartFormDataBuilder, httpRequestBuilder, + cancellationToken).ConfigureAwait(false); + } + else + { + // 复制多部分表单内容文本节内容 + await CopyTextMultipartSectionAsync(multipartSection, httpMultipartFormDataBuilder, cancellationToken).ConfigureAwait(false); + } + + + } + + // 设置多部分表单内容 + httpRequestBuilder.SetMultipartContent(httpMultipartFormDataBuilder); + } + + /// + /// 复制多部分表单内容文本节内容 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static async Task CopyTextMultipartSectionAsync(MultipartSection multipartSection, + HttpMultipartFormDataBuilder httpMultipartFormDataBuilder, CancellationToken cancellationToken) + { + // 获取 ContentDispositionHeaderValue 实例 + var contentDispositionHeaderValue = multipartSection.GetContentDispositionHeader(); + + // 获取表单名称 + var name = contentDispositionHeaderValue?.Name.Value; + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // 读取文本 + var text = await multipartSection.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + // 添加文本 + httpMultipartFormDataBuilder.AddText(text, name); + } + + /// + /// 复制多部分表单内容文件节内容 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static async Task CopyFileMultipartSectionAsync(MultipartSection multipartSection, + HttpMultipartFormDataBuilder httpMultipartFormDataBuilder, HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken) + { + // 初始化 MemoryStream 实例 + var memoryStream = new MemoryStream(); + + // 将多部分表单内容流复制到内存流 + await multipartSection.Body.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + + // 将内存流的位置重置到起始位置 + memoryStream.Position = 0; + + // 将 multipartSection 转换为 MultipartSection 类型 + var fileMultipartSection = multipartSection.AsFileSection()!; + + // 添加文件流 + httpMultipartFormDataBuilder.AddStream(memoryStream, fileMultipartSection.Name, fileMultipartSection.FileName); + + // 添加文件流到请求结束时需要释放的集合中 + httpRequestBuilder.AddDisposable(memoryStream); + } + + /// + /// 读取 请求体流 + /// + /// + /// + /// + /// + /// + /// + /// + internal async Task ReadBodyAsync(HttpRequestBuilder httpRequestBuilder) + { + try + { + // 获取 HttpRequest 实例 + var httpRequest = HttpContext.Request; + + // 将请求体流的位置重置回起始位置 + httpRequest.Body.Position = 0; + + // 初始化 MemoryStream 实例 + var memoryStream = new MemoryStream(); + + // 将请求体流复制到内存流 + await httpRequest.Body.CopyToAsync(memoryStream, HttpContext.RequestAborted).ConfigureAwait(false); + + // 将内存流的位置重置到起始位置 + memoryStream.Position = 0; + + // 添加内存流到请求结束时需要释放的集合中 + httpRequestBuilder.AddDisposable(memoryStream); + + return memoryStream; + } + // 捕获不支持 Body 流重复读异常 + catch (NotSupportedException e) + { + throw new InvalidOperationException( + "Please ensure that the `app.UseEnableBuffering()` middleware is registered.", e); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpFileDownloadBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpFileDownloadBuilder.cs new file mode 100644 index 000000000..bcc7bb809 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpFileDownloadBuilder.cs @@ -0,0 +1,339 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 文件下载构建器 +/// +/// 使用 HttpRequestBuilder.DownloadFile(requestUri, destinationPath) 静态方法创建。 +public sealed class HttpFileDownloadBuilder +{ + /// + /// + /// + /// 请求方式 + /// 请求地址 + internal HttpFileDownloadBuilder(HttpMethod httpMethod, Uri? requestUri) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpMethod); + + Method = httpMethod; + RequestUri = requestUri; + } + + /// + /// 请求地址 + /// + public Uri? RequestUri { get; } + + /// + /// 请求方式 + /// + public HttpMethod Method { get; } + + /// + /// 用于传输操作的缓冲区大小 + /// + /// 以字节为单位,默认值为 80 KB + public int BufferSize { get; private set; } = 80 * 1024; + + /// + /// 文件保存的目标路径 + /// + public string? DestinationPath { get; private set; } + + /// + /// 当目标文件已存在时的行为 + /// + /// 默认值为创建新文件,如果文件已存在则抛出异常。 + public FileExistsBehavior FileExistsBehavior { get; private set; } = FileExistsBehavior.CreateNew; + + /// + /// 进度更新(通知)的间隔时间 + /// + /// 默认值为 1 秒。 + public TimeSpan ProgressInterval { get; private set; } = TimeSpan.FromSeconds(1); + + /// + /// 用于处理在文件开始传输时的操作 + /// + public Action? OnTransferStarted { get; private set; } + + /// + /// 用于处理在文件传输完成时的操作 + /// + public Action? OnTransferCompleted { get; private set; } + + /// + /// 用于处理在文件传输发生异常时的操作 + /// + public Action? OnTransferFailed { get; private set; } + + /// + /// 用于处理在文件存在且配置为跳过时的操作 + /// + public Action? OnFileExistAndSkip { get; private set; } + + /// + /// 用于传输进度发生变化时的操作 + /// + public Func? OnProgressChanged { get; private set; } + + /// + /// 实现 的类型 + /// + internal Type? FileTransferEventHandlerType { get; private set; } + + /// + /// 设置用于传输操作的缓冲区大小 + /// + /// 用于传输操作的缓冲区大小 + /// 以字节为单位,默认值为 80 KB + /// + /// + /// + public HttpFileDownloadBuilder SetBufferSize(int bufferSize) + { + // 小于或等于 0 检查 + if (bufferSize <= 0) + { + throw new ArgumentException("Buffer size must be greater than 0.", nameof(bufferSize)); + } + + BufferSize = bufferSize; + + return this; + } + + /// + /// 设置文件保存的目标路径 + /// + /// 文件保存的目标路径 + /// + /// 如果设置为 null,则尝试获取 HTTP 模块的 构建器的 DefaultFileDownloadDirectory + /// 的属性配置。 + /// + /// + /// + /// + public HttpFileDownloadBuilder SetDestinationPath(string? destinationPath) + { + // 跳过空检查 + if (destinationPath is null) + { + return this; + } + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + DestinationPath = destinationPath; + + return this; + } + + /// + /// 设置当目标文件已存在时的行为 + /// + /// + /// + /// + /// + /// + /// + public HttpFileDownloadBuilder SetFileExistsBehavior(FileExistsBehavior fileExistsBehavior) + { + FileExistsBehavior = fileExistsBehavior; + + return this; + } + + /// + /// 设置文件传输进度(通知)的间隔时间 + /// + /// 进度更新(通知)的间隔时间 + /// + /// + /// + public HttpFileDownloadBuilder SetProgressInterval(TimeSpan progressInterval) + { + // 小于或等于 0 检查 + if (progressInterval <= TimeSpan.Zero) + { + throw new ArgumentException("Progress interval must be greater than 0.", nameof(progressInterval)); + } + + ProgressInterval = progressInterval; + + return this; + } + + /// + /// 设置在文件开始传输时的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpFileDownloadBuilder SetOnTransferStarted(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnTransferStarted = configure; + + return this; + } + + /// + /// 设置用于传输进度发生变化时执行的委托 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpFileDownloadBuilder SetOnProgressChanged(Func configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnProgressChanged = configure; + + return this; + } + + /// + /// 设置在文件传输完成时的操作 + /// + /// 自定义配置委托;委托参数为文件传输总花费时间(毫秒) + /// + /// + /// + public HttpFileDownloadBuilder SetOnTransferCompleted(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnTransferCompleted = configure; + + return this; + } + + /// + /// 设置在文件传输发生异常时的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpFileDownloadBuilder SetOnTransferFailed(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnTransferFailed = configure; + + return this; + } + + /// + /// 设置在文件存在且配置为跳过时的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpFileDownloadBuilder SetOnFileExistAndSkip(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnFileExistAndSkip = configure; + + return this; + } + + /// + /// 设置 HTTP 文件传输事件处理程序 + /// + /// 实现 接口的类型 + /// + /// + /// + /// + public HttpFileDownloadBuilder SetEventHandler(Type fileTransferEventHandlerType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fileTransferEventHandlerType); + + // 检查类型是否实现了 IHttpFileTransferEventHandler 接口 + if (!typeof(IHttpFileTransferEventHandler).IsAssignableFrom(fileTransferEventHandlerType)) + { + throw new ArgumentException( + $"`{fileTransferEventHandlerType}` type is not assignable from `{typeof(IHttpFileTransferEventHandler)}`.", + nameof(fileTransferEventHandlerType)); + } + + FileTransferEventHandlerType = fileTransferEventHandlerType; + + return this; + } + + /// + /// 设置 HTTP 文件传输事件处理程序 + /// + /// + /// + /// + /// + /// + /// + public HttpFileDownloadBuilder SetEventHandler() + where TFileTransferEventHandler : IHttpFileTransferEventHandler => + SetEventHandler(typeof(TFileTransferEventHandler)); + + /// + /// 构建 实例 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + internal HttpRequestBuilder Build(HttpRemoteOptions httpRemoteOptions, Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteOptions); + + // 检查是否设置了文件保存的目标路径,如果没有则设置为默认文件下载保存目录 + DestinationPath ??= httpRemoteOptions.DefaultFileDownloadDirectory; + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(DestinationPath); + + // 初始化 HttpRequestBuilder 实例;如果请求失败,则应抛出异常。 + var httpRequestBuilder = HttpRequestBuilder.Create(Method, RequestUri, configure).PerformanceOptimization() + .EnsureSuccessStatusCode(); + + // 检查是否设置了事件处理程序且该处理程序实现了 IHttpRequestEventHandler 接口,如果有则设置给 httpRequestBuilder + if (FileTransferEventHandlerType is not null && + typeof(IHttpRequestEventHandler).IsAssignableFrom(FileTransferEventHandlerType)) + { + httpRequestBuilder.SetEventHandler(FileTransferEventHandlerType); + } + + return httpRequestBuilder; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpFileUploadBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpFileUploadBuilder.cs new file mode 100644 index 000000000..eadf056f0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpFileUploadBuilder.cs @@ -0,0 +1,383 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.Http.Headers; +using System.Threading.Channels; + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 文件上传构建器 +/// +/// 使用 HttpRequestBuilder.UploadFile(requestUri, filePath, name) 静态方法创建。 +public sealed class HttpFileUploadBuilder +{ + /// + /// + /// + /// 请求方式 + /// 请求地址 + /// 文件路径 + /// 表单名称 + /// 文件的名称 + internal HttpFileUploadBuilder(HttpMethod httpMethod, Uri? requestUri, string filePath, string name, + string? fileName = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpMethod); + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Method = httpMethod; + RequestUri = requestUri; + + FilePath = filePath; + Name = name; + FileName = fileName; + } + + /// + /// 请求地址 + /// + public Uri? RequestUri { get; } + + /// + /// 请求方式 + /// + public HttpMethod Method { get; } + + /// + /// 文件路径 + /// + public string FilePath { get; } + + /// + /// 文件的名称 + /// + public string? FileName { get; } + + /// + /// 表单名称 + /// + public string Name { get; } + + /// + /// 内容类型 + /// + public string? ContentType { get; private set; } + + /// + /// 允许的文件拓展名 + /// + public string[]? AllowedFileExtensions { get; private set; } + + /// + /// 允许的文件大小。以字节为单位 + /// + public long? MaxFileSizeInBytes { get; private set; } + + /// + /// 进度更新(通知)的间隔时间 + /// + /// 默认值为 1 秒。 + public TimeSpan ProgressInterval { get; private set; } = TimeSpan.FromSeconds(1); + + /// + /// 用于处理在文件开始传输时的操作 + /// + public Action? OnTransferStarted { get; private set; } + + /// + /// 用于处理在文件传输完成时的操作 + /// + public Action? OnTransferCompleted { get; private set; } + + /// + /// 用于处理在文件传输发生异常时的操作 + /// + public Action? OnTransferFailed { get; private set; } + + /// + /// 用于传输进度发生变化时的操作 + /// + public Func? OnProgressChanged { get; private set; } + + /// + /// 实现 的类型 + /// + internal Type? FileTransferEventHandlerType { get; private set; } + + /// + /// 设置内容类型(文件类型) + /// + /// 内容类型 + /// + /// + /// + public HttpFileUploadBuilder SetContentType(string contentType) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(contentType); + + // 解析内容类型字符串 + var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType); + + ContentType = mediaTypeHeaderValue.MediaType; + + return this; + } + + /// + /// 设置允许的文件拓展名 + /// + /// 允许的文件拓展名 + /// + /// + /// + public HttpFileUploadBuilder SetAllowedFileExtensions(string[] allowedFileExtensions) + { + // 空检查 + ArgumentNullException.ThrowIfNull(allowedFileExtensions); + + AllowedFileExtensions = allowedFileExtensions; + + return this; + } + + /// + /// 设置允许的文件拓展名 + /// + /// 允许的文件扩展名字符串,用分号分隔 + /// + /// + /// + public HttpFileUploadBuilder SetAllowedFileExtensions(string allowedFileExtensions) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(allowedFileExtensions); + + AllowedFileExtensions = allowedFileExtensions.Split(';', StringSplitOptions.RemoveEmptyEntries); + + return this; + } + + /// + /// 设置允许的文件大小 + /// + /// 允许的文件大小。以字节为单位。 + /// + /// + /// + /// + public HttpFileUploadBuilder SetMaxFileSizeInBytes(long maxFileSizeInBytes) + { + // 小于或等于 0 检查 + if (maxFileSizeInBytes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxFileSizeInBytes), + "Max file size in bytes must be greater than zero."); + } + + MaxFileSizeInBytes = maxFileSizeInBytes; + + return this; + } + + /// + /// 设置文件传输进度(通知)的间隔时间 + /// + /// 进度更新(通知)的间隔时间 + /// + /// + /// + public HttpFileUploadBuilder SetProgressInterval(TimeSpan progressInterval) + { + // 小于或等于 0 检查 + if (progressInterval <= TimeSpan.Zero) + { + throw new ArgumentException("Progress interval must be greater than 0.", nameof(progressInterval)); + } + + ProgressInterval = progressInterval; + + return this; + } + + /// + /// 设置在文件开始传输时的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpFileUploadBuilder SetOnTransferStarted(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnTransferStarted = configure; + + return this; + } + + /// + /// 设置用于上传进度发生变化时执行的委托 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpFileUploadBuilder SetOnProgressChanged(Func configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnProgressChanged = configure; + + return this; + } + + /// + /// 设置在文件传输完成时的操作 + /// + /// 自定义配置委托;委托参数为文件传输总花费时间(毫秒) + /// + /// + /// + public HttpFileUploadBuilder SetOnTransferCompleted(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnTransferCompleted = configure; + + return this; + } + + /// + /// 设置在文件传输发生异常时的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpFileUploadBuilder SetOnTransferFailed(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnTransferFailed = configure; + + return this; + } + + /// + /// 设置 HTTP 文件传输事件处理程序 + /// + /// 实现 接口的类型 + /// + /// + /// + /// + public HttpFileUploadBuilder SetEventHandler(Type fileTransferEventHandlerType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fileTransferEventHandlerType); + + // 检查类型是否实现了 IHttpFileTransferEventHandler 接口 + if (!typeof(IHttpFileTransferEventHandler).IsAssignableFrom(fileTransferEventHandlerType)) + { + throw new ArgumentException( + $"`{fileTransferEventHandlerType}` type is not assignable from `{typeof(IHttpFileTransferEventHandler)}`.", + nameof(fileTransferEventHandlerType)); + } + + FileTransferEventHandlerType = fileTransferEventHandlerType; + + return this; + } + + /// + /// 设置 HTTP 文件传输事件处理程序 + /// + /// + /// + /// + /// + /// + /// + public HttpFileUploadBuilder SetEventHandler() + where TFileTransferEventHandler : IHttpFileTransferEventHandler => + SetEventHandler(typeof(TFileTransferEventHandler)); + + /// + /// 构建 实例 + /// + /// + /// + /// + /// 文件传输进度信息的通道 + /// 自定义配置委托 + /// + /// + /// + internal HttpRequestBuilder Build(HttpRemoteOptions httpRemoteOptions, + Channel progressChannel, + Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteOptions); + ArgumentNullException.ThrowIfNull(progressChannel); + + // 检查文件拓展名和大小合法性 + EnsureLegalData(FilePath, AllowedFileExtensions, MaxFileSizeInBytes); + + // 初始化 HttpRequestBuilder 实例 + var httpRequestBuilder = HttpRequestBuilder.Create(Method, RequestUri, configure).SetMultipartContent(builder => + builder.AddFileWithProgressAsStream(FilePath, progressChannel, Name, FileName, ContentType)); + + // 检查是否设置了事件处理程序且该处理程序实现了 IHttpRequestEventHandler 接口,如果有则设置给 httpRequestBuilder + if (FileTransferEventHandlerType is not null && + typeof(IHttpRequestEventHandler).IsAssignableFrom(FileTransferEventHandlerType)) + { + httpRequestBuilder.SetEventHandler(FileTransferEventHandlerType); + } + + return httpRequestBuilder; + } + + /// + /// 检查文件拓展名和大小合法性 + /// + /// 文件路径 + /// 允许的文件拓展名 + /// 允许的文件大小。以字节为单位 + internal static void EnsureLegalData(string filePath, string[]? allowedFileExtensions, long? maxFileSizeInBytes) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + // 空检查 + if (!allowedFileExtensions.IsNullOrEmpty()) + { + FileUtility.ValidateExtension(filePath, allowedFileExtensions); + } + + // 空检查 + if (maxFileSizeInBytes is not null) + { + FileUtility.ValidateSize(filePath, maxFileSizeInBytes.Value); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpLongPollingBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpLongPollingBuilder.cs new file mode 100644 index 000000000..623646c3a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpLongPollingBuilder.cs @@ -0,0 +1,281 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 长轮询构建器 +/// +/// 使用 HttpRequestBuilder.LongPolling(httpMethod, requestUri, onDataReceived) 静态方法创建。 +public sealed class HttpLongPollingBuilder +{ + /// + /// + /// + /// 请求方式 + /// 请求地址 + internal HttpLongPollingBuilder(HttpMethod httpMethod, Uri? requestUri) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpMethod); + + Method = httpMethod; + RequestUri = requestUri; + } + + /// + /// 请求地址 + /// + public Uri? RequestUri { get; } + + /// + /// 请求方式 + /// + public HttpMethod Method { get; } + + /// + /// 超时时间 + /// + /// 可为单次请求设置超时时间。 + public TimeSpan? Timeout { get; private set; } + + /// + /// 轮询重试间隔 + /// + /// 默认值为 2 秒。 + public TimeSpan RetryInterval { get; private set; } = TimeSpan.FromSeconds(2); + + /// + /// 最大重试次数 + /// + /// 默认最大重试次数为 100。 + public int MaxRetries { get; private set; } = 100; + + /// + /// 用于接收服务器返回 200~299 状态码的数据的操作 + /// + public Func? OnDataReceived { get; private set; } + + /// + /// 用于接收服务器返回非 200~299 状态码的数据的操作 + /// + public Func? OnError { get; private set; } + + /// + /// 用于响应标头包含 X-End-Of-Stream 时触发的操作 + /// + public Func? OnEndOfStream { get; private set; } + + /// + /// 实现 的类型 + /// + internal Type? LongPollingEventHandlerType { get; private set; } + + /// + /// 设置轮询重试间隔 + /// + /// 轮询重试间隔 + /// + /// + /// + /// + public HttpLongPollingBuilder SetRetryInterval(TimeSpan retryInterval) + { + // 小于或等于 0 检查 + if (retryInterval <= TimeSpan.Zero) + { + throw new ArgumentException("Retry interval must be greater than 0.", nameof(retryInterval)); + } + + RetryInterval = retryInterval; + + return this; + } + + /// + /// 设置最大重试次数 + /// + /// 最大重试次数 + /// + /// + /// + /// + public HttpLongPollingBuilder SetMaxRetries(int maxRetries) + { + // 小于或等于 0 检查 + if (maxRetries <= 0) + { + throw new ArgumentException("Max retries must be greater than 0.", nameof(maxRetries)); + } + + MaxRetries = maxRetries; + + return this; + } + + /// + /// 设置超时时间 + /// + /// 超时时间 + /// + /// + /// + public HttpLongPollingBuilder SetTimeout(TimeSpan timeout) + { + Timeout = timeout; + + return this; + } + + /// + /// 设置超时时间 + /// + /// 超时时间(毫秒) + /// + /// + /// + public HttpLongPollingBuilder SetTimeout(double timeoutMilliseconds) + { + // 检查参数是否小于 0 + if (timeoutMilliseconds < 0) + { + throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds), "Timeout value must be non-negative."); + } + + Timeout = TimeSpan.FromMilliseconds(timeoutMilliseconds); + + return this; + } + + /// + /// 设置在接收服务器返回 200~299 状态码的数据的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpLongPollingBuilder SetOnDataReceived(Func configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnDataReceived = configure; + + return this; + } + + /// + /// 设置在接收服务器返回非 200~299 状态码的数据的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpLongPollingBuilder SetOnError(Func configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnError = configure; + + return this; + } + + /// + /// 设置在响应标头包含 X-End-Of-Stream 时触发的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpLongPollingBuilder SetOnEndOfStream(Func configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnEndOfStream = configure; + + return this; + } + + /// + /// 设置长轮询事件处理程序 + /// + /// 实现 接口的类型 + /// + /// + /// + /// + public HttpLongPollingBuilder SetEventHandler(Type longPollingEventHandlerType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(longPollingEventHandlerType); + + // 检查类型是否实现了 IHttpLongPollingEventHandler 接口 + if (!typeof(IHttpLongPollingEventHandler).IsAssignableFrom(longPollingEventHandlerType)) + { + throw new ArgumentException( + $"`{longPollingEventHandlerType}` type is not assignable from `{typeof(IHttpLongPollingEventHandler)}`.", + nameof(longPollingEventHandlerType)); + } + + LongPollingEventHandlerType = longPollingEventHandlerType; + + return this; + } + + /// + /// 设置长轮询事件处理程序 + /// + /// + /// + /// + /// + /// + /// + public HttpLongPollingBuilder SetEventHandler() + where TLongPollingEventHandler : IHttpLongPollingEventHandler => + SetEventHandler(typeof(TLongPollingEventHandler)); + + /// + /// 构建 实例 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + internal HttpRequestBuilder Build(HttpRemoteOptions httpRemoteOptions, Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteOptions); + + // 初始化 HttpRequestBuilder 实例 + var httpRequestBuilder = HttpRequestBuilder.Create(Method, RequestUri, configure).DisableCache(); + + // 设置超时时间 + if (Timeout is not null) + { + httpRequestBuilder.SetTimeout(Timeout.Value); + } + + // 检查是否设置了事件处理程序且该处理程序实现了 IHttpRequestEventHandler 接口,如果有则设置给 httpRequestBuilder + if (LongPollingEventHandlerType is not null && + typeof(IHttpRequestEventHandler).IsAssignableFrom(LongPollingEventHandlerType)) + { + httpRequestBuilder.SetEventHandler(LongPollingEventHandlerType); + } + + return httpRequestBuilder; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpMultipartFormDataBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpMultipartFormDataBuilder.cs new file mode 100644 index 000000000..97c84ab6b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpMultipartFormDataBuilder.cs @@ -0,0 +1,837 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using System.Threading.Channels; + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// 构建器 +/// +public sealed class HttpMultipartFormDataBuilder +{ + /// + internal readonly HttpRequestBuilder _httpRequestBuilder; + + /// + /// 集合 + /// + internal readonly List _partContents; + + /// + /// + /// + /// + /// + /// + internal HttpMultipartFormDataBuilder(HttpRequestBuilder httpRequestBuilder) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRequestBuilder); + + _httpRequestBuilder = httpRequestBuilder; + _partContents = []; + } + + /// + /// 多部分表单内容的边界 + /// + public string? Boundary { get; set; } = $"--------------------------{DateTime.Now.Ticks:x}"; + + /// + /// 是否移除默认的多部分内容的 Content-Type + /// + /// 默认值为:true + public bool OmitContentType { get; set; } = true; + + /// + /// 用于处理在添加 表单项内容时的操作 + /// + internal Action? OnPreAddContent { get; private set; } + + /// + /// 设置多部分表单内容的边界 + /// + /// 多部分表单内容的边界 + /// + /// + /// + public HttpMultipartFormDataBuilder SetBoundary(string? boundary) + { + Boundary = boundary; + + return this; + } + + /// + /// 设置用于处理在添加 表单项内容时的操作 + /// + /// 支持多次调用。 + /// 自定义配置委托 + /// + /// + /// + public HttpMultipartFormDataBuilder SetOnPreAddContent(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + // 如果 OnPreAddContent 未设置则直接赋值 + if (OnPreAddContent is null) + { + OnPreAddContent = configure; + } + // 否则创建级联调用委托 + else + { + // 复制一个新的委托避免死循环 + var originalOnPreAddContent = OnPreAddContent; + + OnPreAddContent = (content, name) => + { + originalOnPreAddContent.Invoke(content, name); + configure.Invoke(content, name); + }; + } + + return this; + } + + /// + /// 添加 JSON 内容 + /// + /// JSON 字符串/原始对象 + /// 表单名称。该值不为空时作为表单的一项。否则将遍历对象类型的每一个公开属性作为表单的项。 + /// 内容编码 + /// + /// + /// + /// + public HttpMultipartFormDataBuilder AddJson(object rawJson, string? name = null, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(rawJson); + + // 检查是否配置表单名或不是字符串类型 + if (!string.IsNullOrWhiteSpace(name) || rawJson is not string rawString) + { + return AddObject(rawJson, name, MediaTypeNames.Application.Json, contentEncoding); + } + + // 尝试验证并获取 JsonDocument 实例(需 using) + var jsonDocument = JsonUtility.Parse(rawString); + + // 添加请求结束时需要释放的对象 + _httpRequestBuilder.AddDisposable(jsonDocument); + + return AddObject(jsonDocument, name, MediaTypeNames.Application.Json, contentEncoding); + } + + /// + /// 添加单个表单项内容 + /// + /// 表单值 + /// 表单名称 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddFormItem(object? value, string name, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return AddObject(value, name, MediaTypeNames.Text.Plain, contentEncoding); + } + + /// + /// 添加 HTML 内容 + /// + /// HTML 字符串 + /// 表单名称 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddHtml(string? htmlString, string name, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return AddObject(htmlString, name, MediaTypeNames.Text.Html, contentEncoding); + } + + /// + /// 添加 XML 内容 + /// + /// XML 字符串 + /// 表单名称 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddXml(string? xmlString, string name, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return AddObject(xmlString, name, MediaTypeNames.Application.Xml, contentEncoding); + } + + /// + /// 添加文本内容 + /// + /// 文本 + /// 表单名称 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddText(string? text, string name, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return AddObject(text, name, MediaTypeNames.Text.Plain, contentEncoding); + } + + /// + /// 添加对象内容 + /// + /// 原始对象 + /// 表单名称。该值不为空时作为表单的一项。否则将遍历对象类型的每一个公开属性作为表单的项。 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddObject(object? rawObject, string? name = null, string? contentType = null, + Encoding? contentEncoding = null) + { + // 解析内容类型字符串 + Encoding? encoding = null; + var mediaType = string.IsNullOrWhiteSpace(contentType) + ? Constants.TEXT_PLAIN_MIME_TYPE + : ParseContentType(contentType, contentEncoding, out encoding); + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(mediaType); + + // 检查是否配置表单名 + if (!string.IsNullOrWhiteSpace(name)) + { + _partContents.Add(new MultipartFormDataItem(name) + { + ContentType = mediaType, + RawContent = rawObject, + ContentEncoding = encoding + }); + + return this; + } + + // 空检查 + ArgumentNullException.ThrowIfNull(rawObject); + + // 将对象转换为 MultipartFormDataItem 集合再追加 + _partContents.AddRange(rawObject.ObjectToDictionary()!.Select(u => + new MultipartFormDataItem(u.Key.ToCultureString(CultureInfo.InvariantCulture)!) + { + ContentType = MediaTypeNames.Text.Plain, + RawContent = u.Value, + ContentEncoding = encoding + })); + + return this; + } + + /// + /// 从互联网 URL 中添加文件 + /// + /// 文件大小限制在 100MB 以内。 + /// 互联网 URL 地址 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + /// + /// + public HttpMultipartFormDataBuilder AddFileFromRemote(string url, string name = "file", string? fileName = null, + string? contentType = null, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(url); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // 尝试获取文件的名称 + var newFileName = fileName ?? Helpers.GetFileNameFromUri(new Uri(url, UriKind.Absolute)); + + // 从互联网 URL 地址中加载流 + var fileStream = Helpers.GetStreamFromRemote(url); + + // 添加文件流到请求结束时需要释放的集合中 + _httpRequestBuilder.AddDisposable(fileStream); + + return AddStream(fileStream, name, newFileName, contentType, contentEncoding); + } + + /// + /// 从 Base64 字符串中添加文件 + /// + /// 文件大小限制在 100MB 以内。 + /// Base64 字符串 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + /// + public HttpMultipartFormDataBuilder AddFileFromBase64String(string base64String, string name = "file", + string? fileName = null, string? contentType = null, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(base64String); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // 将 Base64 字符串转换成字节数组 + var bytes = Convert.FromBase64String(base64String); + + // 获取字节数组长度 + var fileLength = bytes.Length; + + // 限制文件字节数组大小在 100MB 以内 + const long maxFileSizeInBytes = 104857600L; + if (fileLength > maxFileSizeInBytes) + { + throw new InvalidOperationException( + $"The file size exceeds the maximum allowed size of `{maxFileSizeInBytes.ToSizeUnits("MB"):F2} MB`."); + } + + return AddByteArray(bytes, name, fileName, contentType, contentEncoding); + } + + /// + /// 从本地路径中添加文件 + /// + /// 文件路径 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddFileAsStream(string filePath, string name = "file", string? fileName = null, + string? contentType = null, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // 检查文件是否存在 + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"The specified file `{filePath}` does not exist."); + } + + // 获取文件的名称 + var newFileName = fileName ?? Path.GetFileName(filePath); + + // 读取文件流(没有 using) + var fileStream = File.OpenRead(filePath); + + // 添加文件流到请求结束时需要释放的集合中 + _httpRequestBuilder.AddDisposable(fileStream); + + return AddStream(fileStream, name, newFileName, contentType, contentEncoding); + } + + /// + /// 从本地路径中添加文件(带文件传输进度) + /// + /// 文件路径 + /// 文件传输进度信息的通道 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddFileWithProgressAsStream(string filePath, + Channel progressChannel, string name = "file", string? fileName = null, + string? contentType = null, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(progressChannel); + + // 检查文件是否存在 + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"The specified file `{filePath}` does not exist."); + } + + // 获取文件的名称 + var newFileName = fileName ?? Path.GetFileName(filePath); + + // 读取文件流(没有 using) + var fileStream = File.OpenRead(filePath); + + // 初始化带读写进度的文件流 + var progressFileStream = new ProgressFileStream(fileStream, filePath, progressChannel, newFileName); + + // 添加文件流到请求结束时需要释放的集合中 + _httpRequestBuilder.AddDisposable(progressFileStream); + + return AddStream(progressFileStream, name, newFileName, contentType, contentEncoding); + } + + /// + /// 从本地路径中添加文件 + /// + /// 文件路径 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddFileAsByteArray(string filePath, string name = "file", + string? fileName = null, string? contentType = null, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // 检查文件是否存在 + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"The specified file `{filePath}` does not exist."); + } + + // 获取文件的名称 + var newFileName = fileName ?? Path.GetFileName(filePath); + + // 读取文件字节数组 + var bytes = File.ReadAllBytes(filePath); + + return AddByteArray(bytes, name, newFileName, contentType, contentEncoding); + } + + /// + /// 添加文件 + /// + /// 使用 MultipartFile.CreateFrom[Source] 静态方法创建。 + /// + /// + /// + /// + /// + /// + public HttpMultipartFormDataBuilder AddFile(MultipartFile multipartFile) + { + // 空检查 + ArgumentNullException.ThrowIfNull(multipartFile); + + switch (multipartFile.FileSourceType) + { + // 字节数组 + case FileSourceType.ByteArray: + return AddByteArray((byte[])multipartFile.Source!, multipartFile.Name!, multipartFile.FileName, + multipartFile.ContentType, multipartFile.ContentEncoding); + // Stream + case FileSourceType.Stream: + return AddStream((Stream)multipartFile.Source!, multipartFile.Name!, multipartFile.FileName, + multipartFile.ContentType, multipartFile.ContentEncoding); + // 本地文件路径 + case FileSourceType.Path: + return AddFileAsStream((string)multipartFile.Source!, multipartFile.Name!, multipartFile.FileName, + multipartFile.ContentType, multipartFile.ContentEncoding); + // Base64 字符串文件 + case FileSourceType.Base64String: + return AddFileFromBase64String((string)multipartFile.Source!, multipartFile.Name!, + multipartFile.FileName, multipartFile.ContentType, multipartFile.ContentEncoding); + // 互联网文件地址 + case FileSourceType.Remote: + return AddFileFromRemote((string)multipartFile.Source!, multipartFile.Name!, multipartFile.FileName, + multipartFile.ContentType, multipartFile.ContentEncoding); + // 不做处理 + case FileSourceType.None: + default: + return this; + } + } + + /// + /// 添加流 + /// + /// + /// + /// + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddStream(Stream stream, string name = "file", string? fileName = null, + string? contentType = null, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(stream); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // 解析内容类型字符串 + var mediaType = ParseContentType(contentType, contentEncoding, out var encoding); + + // 获取文件 MIME 类型 + var mimeType = !string.IsNullOrWhiteSpace(mediaType) ? mediaType : + string.IsNullOrWhiteSpace(fileName) ? MediaTypeNames.Application.Octet : + FileTypeMapper.GetContentType(fileName); + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(mimeType); + + _partContents.Add(new MultipartFormDataItem(name) + { + ContentType = mimeType, + RawContent = stream, + ContentEncoding = encoding, + FileName = fileName + }); + + return this; + } + + /// + /// 添加字节数组 + /// + /// 字节数组 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddByteArray(byte[] byteArray, string name = "file", string? fileName = null, + string? contentType = null, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(byteArray); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + // 解析内容类型字符串 + var mediaType = ParseContentType(contentType, contentEncoding, out var encoding); + + // 获取文件 MIME 类型 + var mimeType = !string.IsNullOrWhiteSpace(mediaType) ? mediaType : + string.IsNullOrWhiteSpace(fileName) ? MediaTypeNames.Application.Octet : + FileTypeMapper.GetContentType(fileName); + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(mimeType); + + _partContents.Add(new MultipartFormDataItem(name) + { + ContentType = mimeType, + RawContent = byteArray, + ContentEncoding = encoding, + FileName = fileName + }); + + return this; + } + + /// + /// 添加 URL 编码表单 + /// + /// 原始对象 + /// 表单名称 + /// 内容编码 + /// + /// 是否使用 构建 + /// 。默认 false。 + /// + /// + /// + /// + public HttpMultipartFormDataBuilder AddFormUrlEncoded(object? rawObject, string name, + Encoding? contentEncoding = null, bool useStringContent = false) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + _partContents.Add(new MultipartFormDataItem(name) + { + ContentType = MediaTypeNames.Application.FormUrlEncoded, + RawContent = rawObject, + ContentEncoding = contentEncoding + }); + + // 检查是否启用 StringContent 方式构建 application/x-www-form-urlencoded 请求内容 + if (useStringContent) + { + _httpRequestBuilder.AddStringContentForFormUrlEncodedContentProcessor(); + } + + return this; + } + + /// + /// 添加多部分表单内容 + /// + /// 原始对象 + /// 表单名称 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder AddMultipartFormData(object? rawObject, string name, + Encoding? contentEncoding = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + _partContents.Add(new MultipartFormDataItem(name) + { + ContentType = MediaTypeNames.Multipart.FormData, + RawContent = rawObject, + ContentEncoding = contentEncoding + }); + + return this; + } + + /// + /// 添加 + /// + /// + /// + /// + /// 表单名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public HttpMultipartFormDataBuilder Add(HttpContent httpContent, string? name, string? contentType = null, + Encoding? contentEncoding = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContent); + + // 尝试从 ContentDisposition 中解析 Name + var formName = string.IsNullOrWhiteSpace(name) ? httpContent.Headers.ContentDisposition?.Name : name; + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(formName, nameof(name)); + + string? mediaType; + Encoding? encoding = null; + MediaTypeHeaderValue? mediaTypeHeaderValue = null; + + // 空检查 + if (!string.IsNullOrWhiteSpace(contentType)) + { + mediaType = ParseContentType(contentType, contentEncoding, out encoding); + } + else + { + mediaTypeHeaderValue = httpContent.Headers.ContentType; + mediaType = mediaTypeHeaderValue?.MediaType; + } + + // 尝试从 FileName 中解析 MediaType + if (string.IsNullOrWhiteSpace(mediaType)) + { + mediaType = FileTypeMapper.GetContentType( + httpContent.Headers.ContentDisposition?.FileName?.TrimStart('"').TrimEnd('"')!, + null!); + } + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(mediaType, nameof(contentType)); + + // 设置或解析内容编码 + encoding = contentEncoding ?? encoding ?? (string.IsNullOrWhiteSpace(mediaTypeHeaderValue?.CharSet) + ? null + : Encoding.GetEncoding(mediaTypeHeaderValue.CharSet)); + + _partContents.Add(new MultipartFormDataItem(formName) + { + ContentType = mediaType, + RawContent = httpContent, + ContentEncoding = encoding + }); + + return this; + } + + /// + /// 构建 实例 + /// + /// + /// + /// + /// + /// + /// + /// 集合 + /// + /// + /// + internal MultipartFormDataContent? Build(HttpRemoteOptions httpRemoteOptions, + IHttpContentProcessorFactory httpContentProcessorFactory, + params IHttpContentProcessor[]? processors) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteOptions); + ArgumentNullException.ThrowIfNull(httpContentProcessorFactory); + + // 空检查 + if (_partContents.IsNullOrEmpty()) + { + return null; + } + + // 获取多部分表单内容的边界;注意:这里可能出现前后双引号问题 + var boundary = Boundary?.TrimStart('"').TrimEnd('"'); + + // 初始化 multipartFormDataContent 实例 + var multipartFormDataContent = string.IsNullOrWhiteSpace(boundary) + ? new MultipartFormDataContent() + : new MultipartFormDataContent(boundary); + + // 处理 OSS 对象存储服务必须设置 Content-Type 问题 + if (!string.IsNullOrWhiteSpace(boundary)) + { + multipartFormDataContent.Headers.ContentType = + MediaTypeHeaderValue.Parse($"{MediaTypeNames.Multipart.FormData}; boundary={boundary}"); + } + + // 逐条遍历添加 + foreach (var dataItem in _partContents) + { + // 构建 HttpContent 实例 + var httpContent = BuildHttpContent(dataItem, httpContentProcessorFactory, processors); + + // 空检查 + if (httpContent is null) + { + continue; + } + + // 检查是否移除默认的多部分内容的 Content-Type,解决对接 Java 程序时可能出现失败问题 + if (OmitContentType) + { + httpContent.Headers.ContentType = null; + } + + // 调用用于处理在添加 HttpContent 表单项内容时的操作 + OnPreAddContent?.Invoke(httpContent, dataItem.Name); + + // 添加 HttpContent 表单项内容 + multipartFormDataContent.Add(httpContent, dataItem.Name); + } + + return multipartFormDataContent; + } + + /// + /// 构建 实例 + /// + /// + /// + /// + /// + /// + /// + /// 集合 + /// + /// + /// + internal static HttpContent? BuildHttpContent(MultipartFormDataItem multipartFormDataItem, + IHttpContentProcessorFactory httpContentProcessorFactory, params IHttpContentProcessor[]? processors) + { + // 空检查 + ArgumentNullException.ThrowIfNull(multipartFormDataItem); + ArgumentNullException.ThrowIfNull(httpContentProcessorFactory); + + // 空检查 + var contentType = multipartFormDataItem.ContentType; + ArgumentException.ThrowIfNullOrWhiteSpace(contentType); + + // 构建 HttpContent 实例 + var httpContent = httpContentProcessorFactory.Build(multipartFormDataItem.RawContent, contentType, + multipartFormDataItem.ContentEncoding, processors); + + // 空检查 + if (httpContent is not null && httpContent.Headers.ContentDisposition is null) + { + // 设置表单项内容 Content-Disposition 标头 + httpContent.Headers.ContentDisposition = + new ContentDispositionHeaderValue(Constants.FORM_DATA_DISPOSITION_TYPE) + { + Name = multipartFormDataItem.Name.AddQuotes(), + FileName = multipartFormDataItem.FileName.AddQuotes() + }; + } + + return httpContent; + } + + /// + /// 解析内容类型字符串 + /// + /// 内容类型 + /// 内容编码 + /// 内容编码 + /// + /// + /// + internal static string? ParseContentType(string? contentType, Encoding? contentEncoding, out Encoding? encoding) + { + // 空检查 + if (string.IsNullOrWhiteSpace(contentType)) + { + encoding = null; + return null; + } + + // 解析内容类型字符串 + var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType); + + // 解析/设置内容编码 + encoding = contentEncoding ?? (!string.IsNullOrWhiteSpace(mediaTypeHeaderValue.CharSet) + ? Encoding.GetEncoding(mediaTypeHeaderValue.CharSet) + : contentEncoding); + + return mediaTypeHeaderValue.MediaType; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRemoteBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRemoteBuilder.cs new file mode 100644 index 000000000..889dd5690 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRemoteBuilder.cs @@ -0,0 +1,353 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +using System.Reflection; +using System.Text; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求构建器 +/// +public sealed class HttpRemoteBuilder +{ + /// + /// 集合 + /// + internal IList>>? _httpContentConverterProviders; + + /// + /// 集合 + /// + internal IList>>? _httpContentProcessorProviders; + + /// + /// 集合 + /// + internal IList>>? _httpDeclarativeExtractors; + + /// + /// 类型集合 + /// + internal HashSet? _httpDeclarativeTypes; + + /// + /// 实现类型 + /// + internal Type? _objectContentConverterFactoryType; + + /// + /// 添加 请求内容处理器 + /// + /// 支持多次调用。 + /// 实例提供器 + /// + /// + /// + public HttpRemoteBuilder AddHttpContentProcessors(Func> configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + _httpContentProcessorProviders ??= new List>>(); + + _httpContentProcessorProviders.Add(configure); + + return this; + } + + /// + /// 添加 响应内容转换器 + /// + /// 支持多次调用。 + /// 实例提供器 + /// + /// + /// + public HttpRemoteBuilder AddHttpContentConverters(Func> configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + _httpContentConverterProviders ??= new List>>(); + + _httpContentConverterProviders.Add(configure); + + return this; + } + + /// + /// 设置 对象内容转换器工厂 + /// + /// + /// + /// + /// + /// + /// + public HttpRemoteBuilder UseObjectContentConverterFactory() + where TFactory : IObjectContentConverterFactory => + UseObjectContentConverterFactory(typeof(TFactory)); + + /// + /// 设置 对象内容转换器工厂 + /// + /// + /// + /// + /// + /// + /// + /// + public HttpRemoteBuilder UseObjectContentConverterFactory(Type factoryType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(factoryType); + + // 检查类型是否实现了 IObjectContentConverterFactory 接口 + if (!typeof(IObjectContentConverterFactory).IsAssignableFrom(factoryType)) + { + throw new ArgumentException( + $"`{factoryType}` type is not assignable from `{typeof(IObjectContentConverterFactory)}`.", + nameof(factoryType)); + } + + _objectContentConverterFactoryType = factoryType; + + return this; + } + + /// + /// 添加 HTTP 声明式服务 + /// + /// + /// + /// + /// + /// + /// + public HttpRemoteBuilder AddHttpDeclarative() + where TDeclarative : IHttpDeclarative => + AddHttpDeclarative(typeof(TDeclarative)); + + /// + /// 添加 HTTP 声明式服务 + /// + /// + /// + /// + /// + /// + /// + /// + public HttpRemoteBuilder AddHttpDeclarative(Type declarativeType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(declarativeType); + + // 检查类型是否是接口且实现了 IHttpDeclarative 接口 + if (!declarativeType.IsInterface || !typeof(IHttpDeclarative).IsAssignableFrom(declarativeType)) + { + throw new ArgumentException( + $"`{declarativeType}` type is not assignable from `{typeof(IHttpDeclarative)}` or interface.", + nameof(declarativeType)); + } + + _httpDeclarativeTypes ??= []; + + _httpDeclarativeTypes.Add(declarativeType); + + return this; + } + + /// + /// 添加 HTTP 声明式服务 + /// + /// + /// 集合 + /// + /// + /// + /// + /// + public HttpRemoteBuilder AddHttpDeclaratives(params IEnumerable declarativeTypes) + { + // 空检查 + ArgumentNullException.ThrowIfNull(declarativeTypes); + + foreach (var declarativeType in declarativeTypes) + { + AddHttpDeclarative(declarativeType); + } + + return this; + } + + /// + /// 扫描程序集并添加 HTTP 声明式服务 + /// + /// 集合 + /// + /// + /// + public HttpRemoteBuilder AddHttpDeclarativeFromAssemblies(params IEnumerable assemblies) + { + // 空检查 + ArgumentNullException.ThrowIfNull(assemblies); + + AddHttpDeclaratives(assemblies.SelectMany(ass => + (ass?.GetExportedTypes() ?? Enumerable.Empty()).Where(t => + t.IsInterface && typeof(IHttpDeclarative).IsAssignableFrom(t)))); + + return this; + } + + /// + /// 添加 HTTP 声明式 提取器 + /// + /// 支持多次调用。 + /// 实例提供器 + /// + /// + /// + public HttpRemoteBuilder AddHttpDeclarativeExtractors(Func> configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + _httpDeclarativeExtractors ??= new List>>(); + + _httpDeclarativeExtractors.Add(configure); + + return this; + } + + /// + /// 扫描程序集并添加 HTTP 声明式 提取器 + /// + /// 支持多次调用。 + /// 集合 + /// + /// + /// + public HttpRemoteBuilder AddHttpDeclarativeExtractorFromAssemblies(params IEnumerable assemblies) + { + // 空检查 + ArgumentNullException.ThrowIfNull(assemblies); + + return AddHttpDeclarativeExtractors(() => assemblies.SelectMany(ass => + (ass?.GetExportedTypes() ?? Enumerable.Empty()).Where(t => + t.HasDefinePublicParameterlessConstructor() && typeof(IHttpDeclarativeExtractor).IsAssignableFrom(t)) + .Select(t => (IHttpDeclarativeExtractor)Activator.CreateInstance(t)!))); + } + + /// + /// 构建模块服务 + /// + /// + /// + /// + internal void Build(IServiceCollection services) + { + // 注册 CodePagesEncodingProvider,使得程序能够识别并使用 Windows 代码页中的各种编码 + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + // 注册日志服务 + services.AddLogging(); + + // 注册默认 HttpClient 客户端 + if (services.All(u => u.ServiceType != typeof(IHttpClientFactory))) + { + services.AddHttpClient(); + } + + // 检查是否配置(注册)了日志程序 + var isLoggingRegistered = services.Any(u => u.ServiceType == typeof(ILoggerProvider)); + + // 注册并配置 HttpRemoteOptions 选项服务 + services.Configure(options => + { + options.HttpDeclarativeExtractors = _httpDeclarativeExtractors?.AsReadOnly(); + options.IsLoggingRegistered = isLoggingRegistered; + }); + + // 注册 HttpContent 内容处理器工厂 + services.TryAddSingleton(provider => + new HttpContentProcessorFactory(provider, + _httpContentProcessorProviders?.SelectMany(u => u.Invoke()).ToArray())); + + // 注册 HttpContent 内容转换器工厂 + services.TryAddSingleton(provider => + new HttpContentConverterFactory(provider, + _httpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray())); + + // 注册对象内容转换器工厂 + services.TryAddSingleton(); + + // 注册 HTTP 远程请求服务 + services.TryAddSingleton(); + + // 检查是否自定义了对象内容转换器工厂,如果存在则替换 + if (_objectContentConverterFactoryType is not null && + _objectContentConverterFactoryType != typeof(ObjectContentConverterFactory)) + { + services.Replace(ServiceDescriptor.Singleton(typeof(IObjectContentConverterFactory), + _objectContentConverterFactoryType)); + } + + // 构建 HTTP 声明式远程请求服务 + BuildHttpDeclarativeServices(services); + } + + /// + /// 构建 HTTP 声明式远程请求服务 + /// + /// + /// + /// + internal void BuildHttpDeclarativeServices(IServiceCollection services) + { + // 空检查 + if (_httpDeclarativeTypes is null) + { + return; + } + + // 初始化 HTTP 声明式远程请求代理类类型 + var httpDeclarativeDispatchProxyType = typeof(HttpDeclarativeDispatchProxy); + + // 遍历 HTTP 声明式远程请求类型并注册为服务 + foreach (var httpDeclarativeType in _httpDeclarativeTypes) + { + services.TryAddSingleton(httpDeclarativeType, provider => + { + // 创建 HTTP 声明式远程请求代理实例 + var httpDeclarative = + DispatchProxyAsync.Create(httpDeclarativeType, httpDeclarativeDispatchProxyType) as + HttpDeclarativeDispatchProxy; + + // 空检查 + ArgumentNullException.ThrowIfNull(httpDeclarative); + + // 解析 IHttpRemoteService 服务并设置给 RemoteService 属性 + httpDeclarative.RemoteService = provider.GetRequiredService(); + + return httpDeclarative; + }); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.Methods.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.Methods.cs new file mode 100644 index 000000000..937fb5935 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.Methods.cs @@ -0,0 +1,1553 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Net.Http.Headers; + +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; +using System.Text.Json; + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +using MediaTypeHeaderValue = System.Net.Http.Headers.MediaTypeHeaderValue; + +namespace ThingsGateway.HttpRemote; + +/// +/// 构建器 +/// +public sealed partial class HttpRequestBuilder +{ + /// + /// 线程锁 + /// + /// 用于保证 方法调用是线程安全的。 + internal readonly object _lock = new(); + + /// + /// 表示是否已添加了 处理器 + /// + internal bool _isAddedStringContentForFormUrlEncodedContentProcessor; + + /// + /// 设置跟踪标识 + /// + /// 设置跟踪标识 + /// 是否转义字符串,默认 false + /// + /// + /// + public HttpRequestBuilder SetTraceIdentifier(string traceIdentifier, bool escape = false) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(traceIdentifier); + + TraceIdentifier = traceIdentifier.EscapeDataString(escape); + + return this; + } + + /// + /// 设置内容类型 + /// + /// 内容类型 + /// + /// + /// + public HttpRequestBuilder SetContentType(string contentType) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(contentType); + + // 解析内容类型字符串 + var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType); + + ContentType = mediaTypeHeaderValue.MediaType; + + // 检查是否包含 charset 设置 + if (!string.IsNullOrWhiteSpace(mediaTypeHeaderValue.CharSet)) + { + SetContentEncoding(mediaTypeHeaderValue.CharSet); + } + + return this; + } + + /// + /// 设置内容编码 + /// + /// 内容编码 + /// + /// + /// + public HttpRequestBuilder SetContentEncoding(Encoding encoding) + { + // 空检查 + ArgumentNullException.ThrowIfNull(encoding); + + ContentEncoding = encoding; + + return this; + } + + /// + /// 设置内容编码 + /// + /// 内容编码名 + /// + /// + /// + public HttpRequestBuilder SetContentEncoding(string encodingName) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(encodingName); + + SetContentEncoding(Encoding.GetEncoding(encodingName)); + + return this; + } + + /// + /// 设置 JSON 内容 + /// + /// JSON 字符串/原始对象 + /// 内容编码 + /// + /// + /// + /// + public HttpRequestBuilder SetJsonContent(object? rawJson, Encoding? contentEncoding = null) + { + // 检查是否是字符串类型 + if (rawJson is not string rawString) + { + return SetContent(rawJson, MediaTypeNames.Application.Json, contentEncoding); + } + + // 尝试验证并获取 JsonDocument 实例(需 using) + var jsonDocument = JsonUtility.Parse(rawString); + + // 添加请求结束时需要释放的对象 + AddDisposable(jsonDocument); + + return SetContent(jsonDocument, MediaTypeNames.Application.Json, contentEncoding); + } + + /// + /// 设置 HTML 内容 + /// + /// HTML 字符串 + /// 内容编码 + /// + /// + /// + public HttpRequestBuilder SetHtmlContent(string? htmlString, Encoding? contentEncoding = null) => + SetContent(htmlString, MediaTypeNames.Text.Html, contentEncoding); + + /// + /// 设置 XML 内容 + /// + /// XML 字符串 + /// 内容编码 + /// + /// + /// + public HttpRequestBuilder SetXmlContent(string? xmlString, Encoding? contentEncoding = null) => + SetContent(xmlString, MediaTypeNames.Application.Xml, contentEncoding); + + /// + /// 设置文本内容 + /// + /// 文本 + /// 内容编码 + /// + /// + /// + public HttpRequestBuilder SetTextContent(string? text, Encoding? contentEncoding = null) => + SetContent(text, MediaTypeNames.Text.Plain, contentEncoding); + + /// + /// 设置原始字符串内容 + /// + /// 字符串内容将被双引号包围并发送,格式如下:"内容" + /// 文本 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public HttpRequestBuilder SetRawStringContent(string text, string contentType, Encoding? contentEncoding = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(text); + ArgumentException.ThrowIfNullOrWhiteSpace(contentType); + + return SetContent(text.AddQuotes(), contentType, contentEncoding); + } + + /// + /// 设置 URL 编码表单内容 + /// + /// 原始对象 + /// 内容编码 + /// + /// 是否使用 构建 + /// 。默认 false。 + /// + /// + /// + /// + public HttpRequestBuilder SetFormUrlEncodedContent(object? rawObject, Encoding? contentEncoding = null, + bool useStringContent = false) + { + SetContent(rawObject, MediaTypeNames.Application.FormUrlEncoded, contentEncoding); + + // 检查是否启用 StringContent 方式构建 application/x-www-form-urlencoded 请求内容 + if (useStringContent) + { + AddStringContentForFormUrlEncodedContentProcessor(); + } + + return this; + } + + /// + /// 设置请求内容 + /// + /// 原始请求内容 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public HttpRequestBuilder SetContent(object? rawContent, string? contentType = null, + Encoding? contentEncoding = null) + { + // 空检查 + if (!string.IsNullOrWhiteSpace(contentType)) + { + // 解析内容类型字符串 + var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType); + + // 禁止使用该方法设置 multipart/form-data 类型内容 + if (mediaTypeHeaderValue.MediaType == MediaTypeNames.Multipart.FormData && + rawContent is not MultipartContent) + { + throw new NotSupportedException( + $"The method does not support setting the request content type to `{MediaTypeNames.Multipart.FormData}`. Please use the `{nameof(SetMultipartContent)}` method instead. If you are using an HTTP declarative requests, define the parameter with the `Action` type or annotate the parameter with the `MultipartAttribute`."); + } + } + + RawContent = rawContent; + + // 空检查 + if (!string.IsNullOrWhiteSpace(contentType)) + { + SetContentType(contentType); + } + + // 空检查 + if (contentEncoding is not null) + { + SetContentEncoding(contentEncoding); + } + + return this; + } + + /// + /// 设置多部分表单内容,请求类型为 multipart/form-data + /// + /// + /// 该操作将强制覆盖 和 + /// 设置的内容。 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpRequestBuilder SetMultipartContent(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + // 初始化 HttpMultipartFormDataBuilder 实例 + var httpMultipartFormDataBuilder = new HttpMultipartFormDataBuilder(this); + + // 调用自定义配置委托 + configure.Invoke(httpMultipartFormDataBuilder); + + MultipartFormDataBuilder = httpMultipartFormDataBuilder; + + return this; + } + + /// + /// 设置多部分表单内容,请求类型为 multipart/form-data + /// + /// + /// 该操作将强制覆盖 和 + /// 设置的内容。 + /// + /// + /// + /// + /// + /// + /// + internal HttpRequestBuilder SetMultipartContent(HttpMultipartFormDataBuilder httpMultipartFormDataBuilder) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpMultipartFormDataBuilder); + + MultipartFormDataBuilder = httpMultipartFormDataBuilder; + + return this; + } + + /// + /// 设置请求标头 + /// + /// 支持多次调用。 + /// 键 + /// 值 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// 是否替换已存在的请求标头。默认值为 false。 + /// + /// + /// + public HttpRequestBuilder WithHeader(string key, object? value, bool escape = false, CultureInfo? culture = null, + IEqualityComparer? comparer = null, bool replace = false) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + return WithHeaders(new Dictionary { { key, value } }, escape, culture, comparer, replace); + } + + /// + /// 设置请求标头 + /// + /// 支持多次调用。 + /// 请求标头集合 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// 是否替换已存在的请求标头。默认值为 false。 + /// + /// + /// + public HttpRequestBuilder WithHeaders(IDictionary headers, bool escape = false, + CultureInfo? culture = null, IEqualityComparer? comparer = null, bool replace = false) + { + // 空检查 + ArgumentNullException.ThrowIfNull(headers); + + // 初始化请求标头 + Headers ??= new Dictionary>(comparer); + var objectHeaders = new Dictionary>(comparer); + + // 存在则合并否则添加 + objectHeaders.AddOrUpdate(Headers.ToDictionary(u => u.Key, object? (u) => u.Value), false); + objectHeaders.AddOrUpdate(headers, false, replace); + + // 设置请求标头 + Headers = objectHeaders.ToDictionary(kvp => kvp.Key, + kvp => kvp.Value.Select(u => + u.ToCultureString(culture ?? CultureInfo.InvariantCulture)?.EscapeDataString(escape)).ToList(), + comparer); + + return this; + } + + /// + /// 设置请求标头 + /// + /// 支持多次调用。 + /// 请求标头源对象 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// 是否替换已存在的请求标头。默认值为 false。 + /// + /// + /// + public HttpRequestBuilder WithHeaders(object headerSource, bool escape = false, CultureInfo? culture = null, + IEqualityComparer? comparer = null, bool replace = false) + { + // 空检查 + ArgumentNullException.ThrowIfNull(headerSource); + + return WithHeaders( + headerSource.ObjectToDictionary()!.ToDictionary( + u => u.Key.ToCultureString(culture ?? CultureInfo.InvariantCulture)!, u => u.Value), escape, culture, + comparer, replace); + } + + /// + /// 设置需要从请求中移除的标头 + /// + /// 支持多次调用。 + /// 请求标头名集合 + /// + /// + /// + public HttpRequestBuilder RemoveHeaders(params string[] headerNames) + { + // 空检查 + ArgumentNullException.ThrowIfNull(headerNames); + + // 检查是否为空元素数组 + if (headerNames.Length == 0) + { + return this; + } + + HeadersToRemove ??= new HashSet(StringComparer.OrdinalIgnoreCase); + + // 逐条添加到集合中 + foreach (var headerName in headerNames) + { + if (!string.IsNullOrWhiteSpace(headerName)) + { + HeadersToRemove.Add(headerName); + } + } + + return this; + } + + /// + /// 设置片段标识符 + /// + /// 片段标识符 + /// 是否转义字符串,默认 false + /// + /// + /// + public HttpRequestBuilder SetFragment(string fragment, bool escape = false) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(fragment); + + Fragment = fragment.EscapeDataString(escape); + + return this; + } + + /// + /// 设置超时时间 + /// + /// 超时时间 + /// + /// + /// + public HttpRequestBuilder SetTimeout(TimeSpan timeout) + { + Timeout = timeout; + + return this; + } + + /// + /// 设置超时时间 + /// + /// 超时时间(毫秒) + /// + /// + /// + public HttpRequestBuilder SetTimeout(double timeoutMilliseconds) + { + // 检查参数是否小于 0 + if (timeoutMilliseconds < 0) + { + throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds), "Timeout value must be non-negative."); + } + + Timeout = TimeSpan.FromMilliseconds(timeoutMilliseconds); + + return this; + } + + /// + /// 设置查询参数 + /// + /// 支持多次调用。 + /// 键 + /// 值 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// 是否替换已存在的查询参数。默认值为 false。 + /// 是否忽略空值。默认值为 false。 + /// + /// + /// + public HttpRequestBuilder WithQueryParameter(string key, object? value, bool escape = false, + CultureInfo? culture = null, IEqualityComparer? comparer = null, bool replace = false, + bool ignoreNullValues = false) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + return WithQueryParameters(new Dictionary { { key, value } }, escape, culture, comparer, + replace, ignoreNullValues); + } + + /// + /// 设置查询参数 + /// + /// 支持多次调用。 + /// 查询参数集合 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// 是否替换已存在的查询参数。默认值为 false。 + /// 是否忽略空值。默认值为 false。 + /// + /// + /// + public HttpRequestBuilder WithQueryParameters(IDictionary parameters, bool escape = false, + CultureInfo? culture = null, IEqualityComparer? comparer = null, bool replace = false, + bool ignoreNullValues = false) + { + // 空检查 + ArgumentNullException.ThrowIfNull(parameters); + + // 初始化查询参数 + QueryParameters ??= new Dictionary>(comparer); + var objectQueryParameters = new Dictionary>(comparer); + + // 存在则合并否则添加 + objectQueryParameters.AddOrUpdate(QueryParameters.ToDictionary(u => u.Key, object? (u) => u.Value), false); + objectQueryParameters.AddOrUpdate(parameters.WhereIf(ignoreNullValues, u => u.Value is not null).ToDictionary(), + false, replace); + + // 设置查询参数 + QueryParameters = objectQueryParameters.ToDictionary(kvp => kvp.Key, + kvp => kvp.Value.Select(u => + u.ToCultureString(culture ?? CultureInfo.InvariantCulture)?.EscapeDataString(escape)).ToList(), + comparer); + + return this; + } + + /// + /// 设置查询参数 + /// + /// 支持多次调用。 + /// 查询参数集合 + /// 参数前缀。对于对象类型可生成如 prefix.Name=furionprefix.Age=30 参数格式。 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// 是否替换已存在的查询参数。默认值为 false。 + /// 是否忽略空值。默认值为 false。 + /// + /// + /// + public HttpRequestBuilder WithQueryParameters(object parameterSource, string? prefix = null, bool escape = false, + CultureInfo? culture = null, IEqualityComparer? comparer = null, bool replace = false, + bool ignoreNullValues = false) + { + // 空检查 + ArgumentNullException.ThrowIfNull(parameterSource); + + return WithQueryParameters( + parameterSource.ObjectToDictionary()!.ToDictionary( + u => + $"{(string.IsNullOrWhiteSpace(prefix) ? null : $"{prefix}.")}{u.Key.ToCultureString(culture ?? CultureInfo.InvariantCulture)!}", + u => u.Value), escape, culture, comparer, replace, ignoreNullValues); + } + + /// + /// 设置需要从 URL 中移除的查询参数集合 + /// + /// 支持多次调用。 + /// 查询参数键集合 + /// + /// + /// + public HttpRequestBuilder RemoveQueryParameters(params string[] parameterNames) + { + // 空检查 + ArgumentNullException.ThrowIfNull(parameterNames); + + // 检查是否为空元素数组 + if (parameterNames.Length == 0) + { + return this; + } + + QueryParametersToRemove ??= new HashSet(StringComparer.OrdinalIgnoreCase); + + // 逐条添加到集合中 + foreach (var parameterName in parameterNames) + { + if (!string.IsNullOrWhiteSpace(parameterName)) + { + QueryParametersToRemove.Add(parameterName); + } + } + + return this; + } + + /// + /// 设置路径参数 + /// + /// 支持多次调用。 + /// 键 + /// 值 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// + /// + /// + public HttpRequestBuilder WithPathParameter(string key, object? value, bool escape = false, + CultureInfo? culture = null, IEqualityComparer? comparer = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + return WithPathParameters(new Dictionary { { key, value } }, escape, culture, comparer); + } + + /// + /// 设置路径参数 + /// + /// 支持多次调用。 + /// 路径参数集合 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// + /// + /// + public HttpRequestBuilder WithPathParameters(IDictionary parameters, + bool escape = false, + CultureInfo? culture = null, + IEqualityComparer? comparer = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(parameters); + + PathParameters ??= new Dictionary(comparer); + + // 存在则更新否则添加 + PathParameters.AddOrUpdate(parameters.ToDictionary(u => u.Key, + u => u.Value?.ToCultureString(culture ?? CultureInfo.InvariantCulture)?.EscapeDataString(escape), + comparer)); + + return this; + } + + /// + /// 设置路径参数 + /// + /// 支持多次调用。 + /// 路径参数源对象 + /// 模板字符串前缀。若该参数值不为空,则支持 {prefix.Prop.SubProp} 对象路径方式。 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// + /// + /// + public HttpRequestBuilder WithPathParameters(object? parameterSource, string? prefix = null, bool escape = false, + CultureInfo? culture = null, + IEqualityComparer? comparer = null) + { + // 检查是否设置了模板字符串前缀 + if (string.IsNullOrWhiteSpace(prefix)) + { + // 空检查 + ArgumentNullException.ThrowIfNull(parameterSource); + + return WithPathParameters( + parameterSource.ObjectToDictionary()!.ToDictionary( + u => u.Key.ToCultureString(culture ?? CultureInfo.InvariantCulture)!, u => u.Value), escape, + culture, comparer); + } + + ObjectPathParameters ??= new Dictionary(); + + // 存在则更新否则添加 + ObjectPathParameters[prefix] = parameterSource; + + return this; + } + + /// + /// 设置 Cookies + /// + /// 支持多次调用。 + /// Cookie 标头值格式化字符串 + /// + /// + /// + public HttpRequestBuilder WithCookie(string cookieHeaderValue) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(cookieHeaderValue); + + return WithCookies(cookieHeaderValue.ParseFormatKeyValueString([';'])); + } + + /// + /// 设置 Cookies + /// + /// 支持多次调用。 + /// 键 + /// 值 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// + /// + /// + public HttpRequestBuilder WithCookie(string key, object? value, bool escape = false, CultureInfo? culture = null, + IEqualityComparer? comparer = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + return WithCookies(new Dictionary { { key, value } }, escape, culture, comparer); + } + + /// + /// 设置 Cookies + /// + /// 支持多次调用。 + /// Cookies 集合 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// + /// + /// + public HttpRequestBuilder WithCookies(IDictionary cookies, + bool escape = false, + CultureInfo? culture = null, + IEqualityComparer? comparer = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(cookies); + + Cookies ??= new Dictionary(comparer); + + // 存在则更新否则添加 + Cookies.AddOrUpdate(cookies.ToDictionary(u => u.Key, + u => u.Value?.ToCultureString(culture ?? CultureInfo.InvariantCulture)?.EscapeDataString(escape), + comparer)); + + return this; + } + + /// + /// 设置 Cookies + /// + /// 支持多次调用。 + /// Cookie 参数源对象 + /// 是否转义字符串,默认 false + /// + /// + /// + /// + /// + /// + /// + /// + /// + public HttpRequestBuilder WithCookies(object cookieSource, bool escape = false, + CultureInfo? culture = null, + IEqualityComparer? comparer = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(cookieSource); + + // 存在则更新否则添加 + return WithCookies( + cookieSource.ObjectToDictionary()!.ToDictionary( + u => u.Key.ToCultureString(culture ?? CultureInfo.InvariantCulture)!, u => u.Value), escape, culture, + comparer); + } + + /// + /// 需要从请求中移除的 Cookie 集合 + /// + /// 支持多次调用。 + /// Cookie 键集合 + /// + /// + /// + public HttpRequestBuilder RemoveCookies(params string[] cookieNames) + { + // 空检查 + ArgumentNullException.ThrowIfNull(cookieNames); + + // 检查是否为空元素数组 + if (cookieNames.Length == 0) + { + return this; + } + + CookiesToRemove ??= new HashSet(StringComparer.OrdinalIgnoreCase); + + // 逐条添加到集合中 + foreach (var cookieName in cookieNames) + { + if (!string.IsNullOrWhiteSpace(cookieName)) + { + CookiesToRemove.Add(cookieName); + } + } + + return this; + } + + /// + /// 设置 实例的配置名称 + /// + /// 实例的配置名称 + /// + /// + /// + public HttpRequestBuilder SetHttpClientName(string? httpClientName) + { + HttpClientName = httpClientName; + + return this; + } + + /// + /// 设置响应内容最大缓存字节数 + /// + /// 响应内容最大缓存字节数 + /// + /// + /// + /// + public HttpRequestBuilder SetMaxResponseContentBufferSize(long maxResponseContentBufferSize) + { + // 小于或等于 0 检查 + if (maxResponseContentBufferSize <= 0) + { + throw new ArgumentException("Max response content buffer size must be greater than 0.", + nameof(maxResponseContentBufferSize)); + } + + MaxResponseContentBufferSize = maxResponseContentBufferSize; + + return this; + } + + /// + /// 设置 实例提供器 + /// + /// 实例提供器 + /// + /// + /// + public HttpRequestBuilder SetHttpClientProvider(Func<(HttpClient Instance, Action? Release)> configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + HttpClientProvider = configure; + + return this; + } + + /// + /// 添加 请求内容处理器 + /// + /// 支持多次调用。 + /// 实例提供器 + /// + /// + /// + public HttpRequestBuilder AddHttpContentProcessors(Func> configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + HttpContentProcessorProviders ??= new List>>(); + + HttpContentProcessorProviders.Add(configure); + + return this; + } + + /// + /// 添加 响应内容转换器 + /// + /// 支持多次调用。 + /// 实例提供器 + /// + /// + /// + public HttpRequestBuilder AddHttpContentConverters(Func> configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + HttpContentConverterProviders ??= new List>>(); + + HttpContentConverterProviders.Add(configure); + + return this; + } + + /// + /// 设置用于处理在设置 Content 时的操作 + /// + /// 支持多次调用。 + /// 自定义配置委托 + /// + /// + /// + public HttpRequestBuilder SetOnPreSetContent(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + // 如果 OnPreSetContent 未设置则直接赋值 + if (OnPreSetContent is null) + { + OnPreSetContent = configure; + } + // 否则创建级联调用委托 + else + { + // 复制一个新的委托避免死循环 + var originalOnPreSetContent = OnPreSetContent; + + OnPreSetContent = content => + { + originalOnPreSetContent.Invoke(content); + configure.Invoke(content); + }; + } + + return this; + } + + /// + /// 设置在发送 HTTP 请求之前执行的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpRequestBuilder SetOnPreSendRequest(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnPreSendRequest = configure; + + return this; + } + + /// + /// 设置在收到 HTTP 响应之后执行的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpRequestBuilder SetOnPostReceiveResponse(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnPostReceiveResponse = configure; + + return this; + } + + /// + /// 设置在发送 HTTP 请求发生异常时的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpRequestBuilder SetOnRequestFailed(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnRequestFailed = configure; + + return this; + } + + /// + /// 如果 HTTP 响应的 IsSuccessStatusCode 属性是 false,则引发异常。 + /// + /// + /// + /// + public HttpRequestBuilder EnsureSuccessStatusCode() => EnsureSuccessStatusCode(true); + + /// + /// 设置是否如果 HTTP 响应的 IsSuccessStatusCode 属性是 false,则引发异常 + /// + /// 是否启用 + /// + /// + /// + public HttpRequestBuilder EnsureSuccessStatusCode(bool enabled) + { + EnsureSuccessStatusCodeEnabled = enabled; + + return this; + } + + /// + /// 设置 Basic 身份验证凭据请求授权标头 + /// + /// 用户名 + /// 密码 + /// + /// + /// + public HttpRequestBuilder AddBasicAuthentication(string username, string password) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(username); + ArgumentException.ThrowIfNullOrWhiteSpace(password); + + // 将用户名和密码转换为 Base64 字符串 + var base64Credentials = Convert.ToBase64String(Encoding.UTF8.GetBytes(username + ":" + password)); + + AddAuthentication(new AuthenticationHeaderValue(Constants.BASIC_AUTHENTICATION_SCHEME, base64Credentials)); + + return this; + } + + /// + /// 设置 JWT (JSON Web Token) 身份验证凭据请求授权标头 + /// + /// JWT 字符串 + /// + /// + /// + public HttpRequestBuilder AddJwtBearerAuthentication(string jwtToken) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(jwtToken); + + AddAuthentication(new AuthenticationHeaderValue(Constants.JWT_BEARER_AUTHENTICATION_SCHEME, jwtToken)); + + return this; + } + + /// + /// 设置 Digest 摘要身份验证凭据请求授权标头 + /// + /// 用户名 + /// 密码 + /// + /// + /// + public HttpRequestBuilder AddDigestAuthentication(string username, string password) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(username); + ArgumentException.ThrowIfNullOrWhiteSpace(password); + + // 设置预设授权凭证 + AddAuthentication(new AuthenticationHeaderValue(Constants.DIGEST_AUTHENTICATION_SCHEME, + $"{username}|:|{password}")); + + return this; + } + + /// + /// 设置身份验证凭据请求授权标头 + /// + /// + /// + /// + /// + /// + /// + public HttpRequestBuilder AddAuthentication(AuthenticationHeaderValue authenticationHeader) + { + // 空检查 + ArgumentNullException.ThrowIfNull(authenticationHeader); + + AuthenticationHeader = authenticationHeader; + + return this; + } + + /// + /// 设置禁用 HTTP 缓存 + /// + /// + /// + /// + public HttpRequestBuilder DisableCache() => DisableCache(true); + + /// + /// 设置禁用 HTTP 缓存 + /// + /// 是否禁用 + /// + /// + /// + public HttpRequestBuilder DisableCache(bool disabled) + { + DisableCacheEnabled = disabled; + + return this; + } + + /// + /// 设置 HTTP 远程请求事件处理程序 + /// + /// 实现 接口的类型 + /// + /// + /// + /// + public HttpRequestBuilder SetEventHandler(Type requestEventHandlerType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(requestEventHandlerType); + + // 检查类型是否实现了 IHttpRequestEventHandler 接口 + if (!typeof(IHttpRequestEventHandler).IsAssignableFrom(requestEventHandlerType)) + { + throw new ArgumentException( + $"`{requestEventHandlerType}` type is not assignable from `{typeof(IHttpRequestEventHandler)}`.", + nameof(requestEventHandlerType)); + } + + RequestEventHandlerType = requestEventHandlerType; + + return this; + } + + /// + /// 设置 HTTP 远程请求事件处理程序 + /// + /// + /// + /// + /// + /// + /// + public HttpRequestBuilder SetEventHandler() + where TRequestEventHandler : IHttpRequestEventHandler => + SetEventHandler(typeof(TRequestEventHandler)); + + /// + /// 设置是否启用 的池化管理 + /// + /// + /// 用于在并发请求中复用同一个 实例。 + /// 注意:启用池化管理后,在请求完成之后需手动调用 方法释放资源。 + /// + /// + /// + /// + public HttpRequestBuilder UseHttpClientPool() + { + HttpClientPoolingEnabled = true; + + return this; + } + + /// + /// 添加请求结束时需要释放的对象 + /// + /// 支持多次调用。 + /// + /// + /// + /// + /// + /// + public HttpRequestBuilder AddDisposable(IDisposable disposable) + { + // 空检查 + ArgumentNullException.ThrowIfNull(disposable); + + Disposables ??= []; + Disposables.Add(disposable); + + return this; + } + + /// + /// 释放资源集合 + /// + /// 包含自定义 实例和其他可释放对象集合。 + public void ReleaseResources() + { + // 空检查 + if (HttpClientPooling is not null) + { + HttpClientPooling.Release?.Invoke(HttpClientPooling.Instance); + HttpClientPooling = null; + } + + // 释放可释放的对象集合 + ReleaseDisposables(); + } + + /// + /// 设置模拟浏览器环境 + /// + /// 设置此配置后,将在单次请求标头中添加主流浏览器的 User-Agent 值。 + /// 是否模拟移动端,默认值为:false(即模拟桌面端)。 + /// + /// + /// + public HttpRequestBuilder SimulateBrowser(bool simulateMobile = false) => + WithHeader(HeaderNames.UserAgent, + !simulateMobile ? Constants.USER_AGENT_OF_BROWSER : Constants.USER_AGENT_OF_MOBILE_BROWSER, replace: true); + + /// + /// 添加状态码处理程序 + /// + /// 支持多次调用。 + /// HTTP 状态码 + /// 自定义处理程序 + /// + /// + /// + public HttpRequestBuilder WithStatusCodeHandler(object statusCode, + Func handler) => + WithStatusCodeHandler([statusCode], handler); + + /// + /// 添加任何状态码处理程序 + /// + /// 支持多次调用。 + /// 自定义处理程序 + /// + /// + /// + public HttpRequestBuilder WithAnyStatusCodeHandler(Func handler) => + WithStatusCodeHandler(["*"], handler); + + /// + /// 添加状态码处理程序 + /// + /// 支持多次调用。 + /// HTTP 状态码集合 + /// 自定义处理程序 + /// + /// + /// + public HttpRequestBuilder WithStatusCodeHandler(IEnumerable statusCodes, + Func handler) + { + // 空检查 + ArgumentNullException.ThrowIfNull(statusCodes); + + // 检查数量是否为空 + if (statusCodes.TryGetCount(out var count) && count == 0) + { + throw new ArgumentException( + "The status codes array cannot be empty. At least one status code must be provided.", + nameof(statusCodes)); + } + + // 空检查 + ArgumentNullException.ThrowIfNull(handler); + + StatusCodeHandlers ??= + new Dictionary, Func>(); + + StatusCodeHandlers[statusCodes] = handler; + + return this; + } + + /// + /// 设置是否启用请求分析工具 + /// + /// + /// + /// + public HttpRequestBuilder Profiler() => Profiler(true); + + /// + /// 设置是否启用请求分析工具 + /// + /// 是否启用 + /// + /// + /// + public HttpRequestBuilder Profiler(bool enabled) + { + ProfilerEnabled = enabled; + __Disabled_Profiler__ = !enabled; + + return this; + } + + /// + /// 设置客户端所偏好的自然语言和区域设置 + /// + /// 设置此配置后,将在单次请求标头中添加 Accept-Language 值。 + /// 自然语言和区域设置 + /// + /// + /// + public HttpRequestBuilder AcceptLanguage(string? language) => + WithHeader(HeaderNames.AcceptLanguage, language, replace: true); + + /// + /// 设置 请求属性 + /// + /// 支持多次调用。 + /// 键 + /// 值 + /// + /// + /// + public HttpRequestBuilder WithProperty(string key, object? value) + { + // 空检查 + ArgumentNullException.ThrowIfNull(key); + + return WithProperties(new Dictionary { { key, value } }); + } + + /// + /// 设置 请求属性集合 + /// + /// 支持多次调用。 + /// 请求的属性集合 + /// + /// + /// + public HttpRequestBuilder WithProperties(IDictionary properties) + { + // 空检查 + ArgumentNullException.ThrowIfNull(properties); + + Properties.AddOrUpdate(properties); + + return this; + } + + /// + /// 设置 请求属性集合 + /// + /// 支持多次调用。 + /// 请求的属性源对象 + /// + /// + /// + public HttpRequestBuilder WithProperties(object? propertySource) + { + // 空检查 + ArgumentNullException.ThrowIfNull(propertySource); + + return WithProperties( + propertySource.ObjectToDictionary()!.ToDictionary(u => u.Key.ToCultureString(CultureInfo.InvariantCulture)!, + u => u.Value)); + } + + /// + /// 设置是否启用性能优化 + /// + /// 当需要返回 内容或进行 HttpContext 网页转发时,请勿启用此配置,因为流会因压缩而变得不可读,同时该配置也不适用于网页转发的场景。 + /// + /// + /// + public HttpRequestBuilder PerformanceOptimization() => PerformanceOptimization(true); + + /// + /// 设置是否启用性能优化 + /// + /// 当需要返回 内容或进行 HttpContext 网页转发时,请勿启用此配置,因为流会因压缩而变得不可读,同时该配置也不适用于网页转发的场景。 + /// 是否启用 + /// + /// + /// + public HttpRequestBuilder PerformanceOptimization(bool enabled) + { + PerformanceOptimizationEnabled = enabled; + + return this; + } + + /// + /// 设置是否自动设置 Host 标头 + /// + /// + /// + /// + public HttpRequestBuilder AutoSetHostHeader() => AutoSetHostHeader(true); + + /// + /// 设置是否自动设置 Host 标头 + /// + /// 是否启用 + /// + /// + /// + public HttpRequestBuilder AutoSetHostHeader(bool enabled) + { + AutoSetHostHeaderEnabled = enabled; + + return this; + } + + /// + /// 设置请求基地址 + /// + /// 基地址 + /// + /// + /// + public HttpRequestBuilder SetBaseAddress(Uri? baseAddress) + { + // 检查基地址是否是绝对路径地址 + if (baseAddress is not null && !baseAddress.IsAbsoluteUri) + { + throw new ArgumentException("The base address must be absolute.", nameof(baseAddress)); + } + + BaseAddress = baseAddress; + + return this; + } + + /// + /// 设置请求基地址 + /// + /// 基地址 + /// + /// + /// + public HttpRequestBuilder SetBaseAddress(string? baseAddress) => + SetBaseAddress(string.IsNullOrWhiteSpace(baseAddress) + ? null + : new Uri(baseAddress, UriKind.RelativeOrAbsolute)); + + /// + /// 释放可释放的对象集合 + /// + internal void ReleaseDisposables() + { + // 空检查 + if (Disposables.IsNullOrEmpty()) + { + return; + } + + // 逐条遍历进行释放 + foreach (var disposable in Disposables) + { + disposable.Dispose(); + } + + // 清空集合 + Disposables.Clear(); + } + + /// + /// 添加 处理器 + /// + internal void AddStringContentForFormUrlEncodedContentProcessor() + { + lock (_lock) + { + // 检查是否已添加 StringContentForFormUrlEncodedContentProcessor 处理器 + if (_isAddedStringContentForFormUrlEncodedContentProcessor) + { + return; + } + + _isAddedStringContentForFormUrlEncodedContentProcessor = true; + AddHttpContentProcessors(() => [_stringContentForFormUrlEncodedContentProcessorInstance.Value]); + } + } + + /// + /// 重写请求地址 + /// + /// 新的请求地址 + /// + /// + /// + internal HttpRequestBuilder RewriteRequestUri(Uri? newRequestUri) + { + RequestUri = newRequestUri; + + // 解决重定向时重复拼接查询参数问题 + QueryParameters?.Clear(); + QueryParametersToRemove?.Clear(); + + return this; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.Properties.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.Properties.cs new file mode 100644 index 000000000..ba4c84916 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.Properties.cs @@ -0,0 +1,260 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.Http.Headers; +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// 构建器 +/// +public sealed partial class HttpRequestBuilder +{ + /// + /// 请求地址 + /// + public Uri? RequestUri { get; private set; } + + /// + /// 请求方式 + /// + public HttpMethod? Method { get; } + + /// + /// 跟踪标识 + /// + /// + /// 可为每个请求指定唯一标识符,用于请求的跟踪和调试。 + /// 唯一标识符将在 类型实例的 Headers 属性中通过 X-Trace-ID 作为键指定。 + /// + public string? TraceIdentifier { get; private set; } + + /// + /// 内容类型 + /// + public string? ContentType { get; private set; } + + /// + /// 内容编码 + /// + public Encoding? ContentEncoding { get; private set; } + + /// + /// 原始请求内容 + /// + /// 此属性值最终将转换为 类型实例。 + public object? RawContent { get; private set; } + + /// + /// 请求标头集合 + /// + public IDictionary>? Headers { get; private set; } + + /// + /// 需要从请求中移除的标头集合 + /// + public HashSet? HeadersToRemove { get; private set; } + + /// + /// 片段标识符 + /// + /// 请求地址中的 # 符号后面的部分。 + public string? Fragment { get; private set; } + + /// + /// 超时时间 + /// + /// 可为单次请求设置超时时间。 + public TimeSpan? Timeout { get; private set; } + + /// + /// 查询参数集合 + /// + /// 请求地址中位于 ? 符号之后且 # 符号之前的部分。 + public IDictionary>? QueryParameters { get; private set; } + + /// + /// 需要从 URL 中移除的查询参数集合 + /// + public HashSet? QueryParametersToRemove { get; private set; } + + /// + /// 路径参数集合 + /// + /// 用于替换请求地址中符合 \{\s*(\w+\s*(\.\s*\w+\s*)*)\s*\} 正则表达式匹配的数据。 + public IDictionary? PathParameters { get; private set; } + + /// + /// 路径参数集合 + /// + /// 支持自定义类类型。用于替换请求地址中符合 \{\s*(\w+\s*(\.\s*\w+\s*)*)\s*\} 正则表达式匹配的数据。 + public IDictionary? ObjectPathParameters { get; private set; } + + /// + /// Cookies 集合 + /// + /// + /// 可为单次请求设置 Cookies。 + /// Cookies 将在 类型实例的 Headers 属性中通过 Cookie 作为键指定。 + /// 使用该方式不会自动处理服务器返回的 Set-Cookie 头。 + /// + public IDictionary? Cookies { get; private set; } + + /// + /// 需要从请求中移除的 Cookie 集合 + /// + public HashSet? CookiesToRemove { get; private set; } + + /// + /// 实例的配置名称 + /// + /// + /// 此属性用于指定 创建 实例时传递的名称。 + /// 该名称用于标识在服务容器中与特定 实例相关的配置。 + /// + public string? HttpClientName { get; private set; } + + /// + /// 响应内容最大缓存字节数 + /// + /// 可为单次请求设置最大缓存字节数。 + public long? MaxResponseContentBufferSize { get; private set; } + + /// + /// 实例提供器 + /// + /// + /// 返回一个包含 实例及其释放方法的委托。 + /// 释放方法的委托用于在不再需要 实例时释放资源。 + /// + public Func<(HttpClient Instance, Action? Release)>? HttpClientProvider { get; private set; } + + /// + /// 集合提供器 + /// + /// 返回多个包含实现 集合的集合。 + public IList>>? HttpContentProcessorProviders { get; private set; } + + /// + /// 集合提供器 + /// + /// 返回多个包含实现 集合的集合。 + public IList>>? HttpContentConverterProviders { get; private set; } + + /// + /// 用于处理在设置 的请求消息的内容时的操作 + /// + public Action? OnPreSetContent { get; private set; } + + /// + /// 用于处理在发送 HTTP 请求之前的操作 + /// + public Action? OnPreSendRequest { get; private set; } + + /// + /// 用于处理在收到 HTTP 响应之后的操作 + /// + public Action? OnPostReceiveResponse { get; private set; } + + /// + /// 用于处理在发送 HTTP 请求发生异常时的操作 + /// + public Action? OnRequestFailed { get; private set; } + + /// + /// 身份验证凭据请求授权标头 + /// + /// 可为单次请求设置身份验证凭据请求授权标头。 + public AuthenticationHeaderValue? AuthenticationHeader { get; private set; } + + /// + /// 请求属性集合 + /// + /// 用于添加 请求属性。该值将合并到 HttpRequestMessage.Options 属性中。 + public IDictionary Properties { get; } = new Dictionary(); + + /// + /// 请求基地址 + /// + public Uri? BaseAddress { get; private set; } + + /// + /// + /// + internal HttpMultipartFormDataBuilder? MultipartFormDataBuilder { get; private set; } + + /// + /// 如果 HTTP 响应的 IsSuccessStatusCode 属性是 false,则引发异常。 + /// + /// 默认值为 false + internal bool EnsureSuccessStatusCodeEnabled { get; private set; } + + /// + /// 是否禁用 HTTP 缓存 + /// + /// 可为单次请求设置禁用 HTTP 缓存。默认值为:false + internal bool DisableCacheEnabled { get; private set; } + + /// + /// 实现 的类型 + /// + internal Type? RequestEventHandlerType { get; private set; } + + /// + /// 用于请求结束时需要释放的对象集合 + /// + internal List? Disposables { get; private set; } + + /// + /// 实例管理器 + /// + internal HttpClientPooling? HttpClientPooling { get; set; } + + /// + /// 是否启用 的池化管理 + /// + /// 默认值为:false + internal bool HttpClientPoolingEnabled { get; private set; } + + /// + /// 是否启用请求分析工具 + /// + /// 默认值为:false + internal bool ProfilerEnabled { get; private set; } + + /// + /// 是否启用性能优化 + /// + /// 默认值为:false + internal bool PerformanceOptimizationEnabled { get; private set; } + + /// + /// 是否自动设置 Host 标头 + /// + /// Host 标头是 HTTP/1.1 协议中的一个必需标头。默认值为:false,表示不默认添加 Host 标头。 + internal bool AutoSetHostHeaderEnabled { get; private set; } + + /// + /// 表示禁用请求分析工具标识 + /// + /// 用于禁用全局请求分析工具。 + internal bool __Disabled_Profiler__ { get; private set; } + + /// + /// 状态码处理程序 + /// + internal IDictionary, Func>? StatusCodeHandlers + { + get; + private set; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.StaticMethods.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.StaticMethods.cs new file mode 100644 index 000000000..cd2b2c3af --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.StaticMethods.cs @@ -0,0 +1,596 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.HttpRemote; + +/// +/// 构建器 +/// +public sealed partial class HttpRequestBuilder +{ + /// + /// 创建 GET 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Get(string? requestUri, Action? configure = null) => + Create(HttpMethod.Get, requestUri, configure); + + /// + /// 创建 GET 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Get(Uri? requestUri, Action? configure = null) => + Create(HttpMethod.Get, requestUri, configure); + + /// + /// 创建 PUT 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Put(string? requestUri, Action? configure = null) => + Create(HttpMethod.Put, requestUri, configure); + + /// + /// 创建 PUT 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Put(Uri? requestUri, Action? configure = null) => + Create(HttpMethod.Put, requestUri, configure); + + /// + /// 创建 POST 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Post(string? requestUri, Action? configure = null) => + Create(HttpMethod.Post, requestUri, configure); + + /// + /// 创建 POST 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Post(Uri? requestUri, Action? configure = null) => + Create(HttpMethod.Post, requestUri, configure); + + /// + /// 创建 DELETE 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Delete(string? requestUri, Action? configure = null) => + Create(HttpMethod.Delete, requestUri, configure); + + /// + /// 创建 DELETE 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Delete(Uri? requestUri, Action? configure = null) => + Create(HttpMethod.Delete, requestUri, configure); + + /// + /// 创建 HEAD 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Head(string? requestUri, Action? configure = null) => + Create(HttpMethod.Head, requestUri, configure); + + /// + /// 创建 HEAD 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Head(Uri? requestUri, Action? configure = null) => + Create(HttpMethod.Head, requestUri, configure); + + /// + /// 创建 OPTIONS 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Options(string? requestUri, Action? configure = null) => + Create(HttpMethod.Options, requestUri, configure); + + /// + /// 创建 OPTIONS 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Options(Uri? requestUri, Action? configure = null) => + Create(HttpMethod.Options, requestUri, configure); + + /// + /// 创建 TRACE 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Trace(string? requestUri, Action? configure = null) => + Create(HttpMethod.Trace, requestUri, configure); + + /// + /// 创建 TRACE 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Trace(Uri? requestUri, Action? configure = null) => + Create(HttpMethod.Trace, requestUri, configure); + + /// + /// 创建 PATCH 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Patch(string? requestUri, Action? configure = null) => + Create(HttpMethod.Patch, requestUri, configure); + + /// + /// 创建 PATCH 请求的 构建器 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Patch(Uri? requestUri, Action? configure = null) => + Create(HttpMethod.Patch, requestUri, configure); + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// + /// + /// + public static HttpRequestBuilder Create(HttpMethod httpMethod, Uri? requestUri) => new(httpMethod, requestUri); + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// + /// + /// + public static HttpRequestBuilder Create(HttpMethod httpMethod, string? requestUri) => + Create(httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute)); + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// + /// + /// + public static HttpRequestBuilder Create(string httpMethod, string? requestUri) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod); + + return Create(Helpers.ParseHttpMethod(httpMethod), requestUri); + } + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// + /// + /// + public static HttpRequestBuilder Create(string httpMethod, Uri? requestUri) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod); + + return Create(Helpers.ParseHttpMethod(httpMethod), requestUri); + } + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Create(HttpMethod httpMethod, Uri? requestUri, + Action? configure) + { + // 初始化 HttpRequestBuilder 实例 + var httpRequestBuilder = Create(httpMethod, requestUri); + + // 调用自定义配置委托 + configure?.Invoke(httpRequestBuilder); + + return httpRequestBuilder; + } + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Create(HttpMethod httpMethod, string? requestUri, + Action? configure) => + Create(httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure); + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + public static HttpRequestBuilder Create(string httpMethod, string? requestUri, + Action? configure) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod); + + return Create(Helpers.ParseHttpMethod(httpMethod), requestUri, configure); + } + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// 文件保存的目标路径 + /// 用于传输进度发生变化时执行的委托 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + public static HttpFileDownloadBuilder DownloadFile(HttpMethod httpMethod, Uri? requestUri, string? destinationPath, + Func? onProgressChanged = null, + FileExistsBehavior fileExistsBehavior = FileExistsBehavior.CreateNew, + Action? configure = null) + { + // 初始化 HttpFileDownloadBuilder 实例 + var httpFileDownloadBuilder = + new HttpFileDownloadBuilder(httpMethod, requestUri).SetDestinationPath(destinationPath) + .SetFileExistsBehavior(fileExistsBehavior); + + // 空检查 + if (onProgressChanged is not null) + { + httpFileDownloadBuilder.SetOnProgressChanged(onProgressChanged); + } + + // 调用自定义配置委托 + configure?.Invoke(httpFileDownloadBuilder); + + return httpFileDownloadBuilder; + } + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 文件保存的目标路径 + /// 用于传输进度发生变化时执行的委托 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + public static HttpFileDownloadBuilder DownloadFile(Uri? requestUri, string? destinationPath, + Func? onProgressChanged = null, + FileExistsBehavior fileExistsBehavior = FileExistsBehavior.CreateNew, + Action? configure = null) => + DownloadFile(HttpMethod.Get, requestUri, destinationPath, onProgressChanged, fileExistsBehavior, configure); + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 文件保存的目标路径 + /// 用于传输进度发生变化时执行的委托 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + public static HttpFileDownloadBuilder DownloadFile(string? requestUri, string? destinationPath, + Func? onProgressChanged = null, + FileExistsBehavior fileExistsBehavior = FileExistsBehavior.CreateNew, + Action? configure = null) => + DownloadFile(HttpMethod.Get, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), + destinationPath, onProgressChanged, fileExistsBehavior, configure); + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// 文件路径 + /// 表单名称;默认值为 file。 + /// 用于传输进度发生变化时执行的委托 + /// 文件的名称 + /// 自定义配置委托 + /// + /// + /// + public static HttpFileUploadBuilder UploadFile(HttpMethod httpMethod, Uri? requestUri, string filePath, + string name = "file", Func? onProgressChanged = null, string? fileName = null, + Action? configure = null) + { + // 初始化 HttpFileUploadBuilder 实例 + var httpFileUploadBuilder = new HttpFileUploadBuilder(httpMethod, requestUri, filePath, name, fileName); + + // 空检查 + if (onProgressChanged is not null) + { + httpFileUploadBuilder.SetOnProgressChanged(onProgressChanged); + } + + // 调用自定义配置委托 + configure?.Invoke(httpFileUploadBuilder); + + return httpFileUploadBuilder; + } + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 文件路径 + /// 表单名称;默认值为 file。 + /// 用于传输进度发生变化时执行的委托 + /// 文件的名称 + /// 自定义配置委托 + /// + /// + /// + public static HttpFileUploadBuilder UploadFile(Uri? requestUri, string filePath, string name = "file", + Func? onProgressChanged = null, string? fileName = null, + Action? configure = null) => + UploadFile(HttpMethod.Post, requestUri, filePath, name, onProgressChanged, fileName, configure); + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 文件路径 + /// 表单名称;默认值为 file。 + /// 用于传输进度发生变化时执行的委托 + /// 文件的名称 + /// 自定义配置委托 + /// + /// + /// + public static HttpFileUploadBuilder UploadFile(string? requestUri, string filePath, string name = "file", + Func? onProgressChanged = null, string? fileName = null, + Action? configure = null) => + UploadFile(HttpMethod.Post, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), filePath, + name, onProgressChanged, fileName, configure); + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 用于在从事件源接收到数据时的操作 + /// 自定义配置委托 + /// + /// + /// + public static HttpServerSentEventsBuilder ServerSentEvents(Uri? requestUri, + Func onMessage, Action? configure = null) + { + // 初始化 HttpServerSentEventsBuilder 实例 + var httpServerSentEventsBuilder = new HttpServerSentEventsBuilder(requestUri).SetOnMessage(onMessage); + + // 调用自定义配置委托 + configure?.Invoke(httpServerSentEventsBuilder); + + return httpServerSentEventsBuilder; + } + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 用于在从事件源接收到数据时的操作 + /// 自定义配置委托 + /// + /// + /// + public static HttpServerSentEventsBuilder ServerSentEvents(string? requestUri, + Func onMessage, Action? configure = null) => + ServerSentEvents(string.IsNullOrWhiteSpace(requestUri) + ? null + : new Uri(requestUri, UriKind.RelativeOrAbsolute), onMessage, configure); + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// 并发请求数量,默认值为:100。 + /// 自定义配置委托 + /// + /// + /// + public static HttpStressTestHarnessBuilder StressTestHarness(HttpMethod httpMethod, Uri? requestUri, + int numberOfRequests = 100, Action? configure = null) + { + // 初始化 HttpStressTestHarnessBuilder 实例 + var httpStressTestHarnessBuilder = + new HttpStressTestHarnessBuilder(httpMethod, requestUri).SetNumberOfRequests(numberOfRequests); + + // 调用自定义配置委托 + configure?.Invoke(httpStressTestHarnessBuilder); + + return httpStressTestHarnessBuilder; + } + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 并发请求数量,默认值为:100。 + /// 自定义配置委托 + /// + /// + /// + public static HttpStressTestHarnessBuilder StressTestHarness(Uri? requestUri, int numberOfRequests = 100, + Action? configure = null) => + StressTestHarness(HttpMethod.Get, requestUri, numberOfRequests, configure); + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 并发请求数量,默认值为:100。 + /// 自定义配置委托 + /// + /// + /// + public static HttpStressTestHarnessBuilder StressTestHarness(string? requestUri, int numberOfRequests = 100, + Action? configure = null) => + StressTestHarness(HttpMethod.Get, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), + numberOfRequests, configure); + + /// + /// 创建 构建器 + /// + /// 请求方式 + /// 请求地址 + /// 用于接收服务器返回 200~299 状态码的数据的操作 + /// 自定义配置委托 + /// + /// + /// + public static HttpLongPollingBuilder LongPolling(HttpMethod httpMethod, Uri? requestUri, + Func onDataReceived, Action? configure = null) + { + // 初始化 HttpLongPollingBuilder 实例 + var httpLongPollingBuilder = + new HttpLongPollingBuilder(httpMethod, requestUri).SetOnDataReceived(onDataReceived); + + // 调用自定义配置委托 + configure?.Invoke(httpLongPollingBuilder); + + return httpLongPollingBuilder; + } + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 用于接收服务器返回 200~299 状态码的数据的操作 + /// 自定义配置委托 + /// + /// + /// + public static HttpLongPollingBuilder LongPolling(Uri? requestUri, Func onDataReceived, + Action? configure = null) => + LongPolling(HttpMethod.Get, requestUri, onDataReceived, configure); + + /// + /// 创建 构建器 + /// + /// 请求地址 + /// 用于接收服务器返回 200~299 状态码的数据的操作 + /// 自定义配置委托 + /// + /// + /// + public static HttpLongPollingBuilder LongPolling(string? requestUri, Func onDataReceived, + Action? configure = null) => + LongPolling(HttpMethod.Get, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), + onDataReceived, configure); + + /// + /// 创建 构建器 + /// + /// 被调用方法 + /// 被调用方法的参数值数组 + /// + /// + /// + public static HttpDeclarativeBuilder Declarative(MethodInfo method, object?[] args) => + new(method, args); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.cs new file mode 100644 index 000000000..42f16db97 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpRequestBuilder.cs @@ -0,0 +1,509 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; +using Microsoft.Net.Http.Headers; + +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Net.Mime; + +using ThingsGateway.Extensions; + +using CacheControlHeaderValue = System.Net.Http.Headers.CacheControlHeaderValue; +using StringWithQualityHeaderValue = System.Net.Http.Headers.StringWithQualityHeaderValue; + +namespace ThingsGateway.HttpRemote; + +/// +/// 构建器 +/// +public sealed partial class HttpRequestBuilder +{ + /// + /// 实例 + /// + internal static readonly Lazy + _stringContentForFormUrlEncodedContentProcessorInstance = + new(() => new StringContentForFormUrlEncodedContentProcessor()); + + /// + /// + /// + /// 请求方式 + /// 请求地址 + internal HttpRequestBuilder(HttpMethod httpMethod, Uri? requestUri) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpMethod); + + Method = httpMethod; + RequestUri = requestUri; + } + + /// + /// 构建 实例 + /// + /// + /// + /// + /// + /// + /// + /// 客户端基地址 + /// + /// + /// + internal HttpRequestMessage Build(HttpRemoteOptions httpRemoteOptions, + IHttpContentProcessorFactory httpContentProcessorFactory, Uri? clientBaseAddress) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteOptions); + ArgumentNullException.ThrowIfNull(httpContentProcessorFactory); + ArgumentNullException.ThrowIfNull(Method); + + // 构建最终的请求地址 + var finalRequestUri = BuildFinalRequestUri(clientBaseAddress, httpRemoteOptions.Configuration); + + // 初始化 HttpRequestMessage 实例 + var httpRequestMessage = new HttpRequestMessage(Method, finalRequestUri); + + // 启用性能优化 + EnablePerformanceOptimization(httpRequestMessage); + + // 追加请求标头 + AppendHeaders(httpRequestMessage); + + // 追加 Cookies + AppendCookies(httpRequestMessage); + + // 移除 Cookies + RemoveCookies(httpRequestMessage); + + // 移除请求标头 + RemoveHeaders(httpRequestMessage); + + // 构建并设置指定的 HttpRequestMessage 请求消息的内容 + BuildAndSetContent(httpRequestMessage, httpContentProcessorFactory, httpRemoteOptions); + + // 追加 HttpRequestMessage 请求属性集合 + AppendProperties(httpRequestMessage); + + return httpRequestMessage; + } + + /// + /// 构建最终的请求地址 + /// + /// 客户端基地址 + /// + /// + /// + /// + /// + /// + internal string BuildFinalRequestUri(Uri? clientBaseAddress, IConfiguration? configuration) + { + // 替换路径或配置参数,处理非标准 HTTP URI 的应用场景(如 {url}),此时需优先解决路径或配置参数问题 + var newRequestUri = RequestUri is null or { OriginalString: null } + ? RequestUri + : new Uri(ReplacePlaceholders(RequestUri.OriginalString, configuration), UriKind.RelativeOrAbsolute); + + // 初始化带局部 BaseAddress 的请求地址 + var requestUriWithBaseAddress = BaseAddress is null + ? newRequestUri! + : new Uri(BaseAddress, newRequestUri!); + + // 初始化 UriBuilder 实例 + var uriBuilder = new UriBuilder(clientBaseAddress is null + ? requestUriWithBaseAddress + : new Uri(clientBaseAddress, requestUriWithBaseAddress)); + + // 追加片段标识符 + AppendFragment(uriBuilder); + + // 追加查询参数 + AppendQueryParameters(uriBuilder); + + // 替换路径或配置参数 + var finalRequestUri = ReplacePlaceholders(uriBuilder.Uri.ToString(), configuration); + + return finalRequestUri; + } + + /// + /// 追加片段标识符 + /// + /// + /// + /// + internal void AppendFragment(UriBuilder uriBuilder) + { + // 空检查 + if (string.IsNullOrWhiteSpace(Fragment)) + { + return; + } + + uriBuilder.Fragment = Fragment; + } + + /// + /// 追加查询参数 + /// + /// + /// + /// + internal void AppendQueryParameters(UriBuilder uriBuilder) + { + // 空检查 + if (QueryParameters.IsNullOrEmpty()) + { + return; + } + + // 解析 URL 中的查询字符串为键值对列表 + var queryParameters = uriBuilder.Query.ParseFormatKeyValueString(['&'], '?'); + + // 追加查询参数 + foreach (var (key, values) in QueryParameters) + { + queryParameters.AddRange(values.Select(value => + new KeyValuePair(key, value))); + } + + // 构建查询字符串赋值给 UriBuilder 的 Query 属性 + uriBuilder.Query = + "?" + string.Join('&', + queryParameters + // 过滤已标记为移除的查询参数 + .WhereIf(QueryParametersToRemove is { Count: > 0 }, + u => QueryParametersToRemove?.TryGetValue(u.Key, out _) == false) + .Select(u => $"{u.Key}={u.Value}")); + } + + /// + /// 替换路径或配置参数 + /// + /// 源请求地址 + /// + /// + /// + /// + /// + /// + internal string ReplacePlaceholders(string originalUri, IConfiguration? configuration) + { + var newUri = originalUri; + + // 空检查 + if (!PathParameters.IsNullOrEmpty()) + { + newUri = newUri.ReplacePlaceholders(PathParameters); + } + + // 空检查 + if (!ObjectPathParameters.IsNullOrEmpty()) + { + newUri = ObjectPathParameters.Aggregate(newUri, + (current, objectPathParameter) => + current.ReplacePlaceholders(objectPathParameter.Value, objectPathParameter.Key)); + } + + // 替换配置参数 + newUri = newUri.ReplacePlaceholders(configuration); + + return newUri!; + } + + /// + /// 追加请求标头 + /// + /// + /// + /// + internal void AppendHeaders(HttpRequestMessage httpRequestMessage) + { + // 添加 Host 标头 + if (AutoSetHostHeaderEnabled) + { + httpRequestMessage.Headers.Host = + $"{httpRequestMessage.RequestUri?.Host}{(httpRequestMessage.RequestUri?.IsDefaultPort != true ? $":{httpRequestMessage.RequestUri?.Port}" : string.Empty)}"; + } + + // 添加跟踪标识 + if (!string.IsNullOrWhiteSpace(TraceIdentifier)) + { + httpRequestMessage.Headers.TryAddWithoutValidation(Constants.X_TRACE_ID_HEADER, TraceIdentifier); + } + + // 添加身份认证 + AppendAuthentication(httpRequestMessage); + + // 设置禁用 HTTP 缓存 + if (DisableCacheEnabled) + { + httpRequestMessage.Headers.CacheControl = new CacheControlHeaderValue + { + NoCache = true, + NoStore = true, + MustRevalidate = true + }; + } + + // 空检查 + if (Headers.IsNullOrEmpty()) + { + return; + } + + // 遍历请求标头集合并追加到 HttpRequestMessage.Headers 中 + foreach (var (key, values) in Headers) + { + httpRequestMessage.Headers.TryAddWithoutValidation(key, values); + } + } + + /// + /// 添加身份认证 + /// + /// + /// + /// + internal void AppendAuthentication(HttpRequestMessage httpRequestMessage) + { + // 空检查 + if (AuthenticationHeader is null) + { + return; + } + + // 检查是否是 Digest 摘要认证 + if (AuthenticationHeader.Scheme != Constants.DIGEST_AUTHENTICATION_SCHEME) + { + httpRequestMessage.Headers.Authorization = AuthenticationHeader; + + return; + } + + // 检查参数是否包含预设的 Digest 授权凭证 + const string separator = "|:|"; + if (AuthenticationHeader.Parameter?.Contains(separator) != true) + { + return; + } + + // 分割预设的用户名和密码 + var parts = AuthenticationHeader.Parameter.Split(separator); + + // 获取 Digest 摘要认证授权凭证 + var digestCredentials = + DigestCredentials.GetDigestCredentials(httpRequestMessage.RequestUri?.OriginalString, parts[0], parts[1], + Method!); + + // 设置身份验证凭据请求授权标头 + httpRequestMessage.Headers.Authorization = + new AuthenticationHeaderValue(Constants.DIGEST_AUTHENTICATION_SCHEME, digestCredentials); + } + + /// + /// 移除请求标头 + /// + /// + /// + /// + internal void RemoveHeaders(HttpRequestMessage httpRequestMessage) + { + // 空检查 + if (HeadersToRemove.IsNullOrEmpty()) + { + return; + } + + // 遍历请求标头集合并从 HttpRequestMessage.Headers 中移除 + foreach (var headerName in HeadersToRemove) + { + httpRequestMessage.Headers.Remove(headerName); + } + } + + /// + /// 启用性能优化 + /// + /// + /// + /// + internal void EnablePerformanceOptimization(HttpRequestMessage httpRequestMessage) + { + if (!PerformanceOptimizationEnabled) + { + return; + } + + // 设置 Accept 头,表示可以接受任何类型的内容 + httpRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); + + // 添加 Accept-Encoding 头,支持 gzip、deflate 以及 Brotli 压缩算法 + // 这样服务器可以根据情况选择最合适的压缩方式发送响应,从而减少传输的数据量 + httpRequestMessage.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + httpRequestMessage.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate")); + httpRequestMessage.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("br")); + + // 设置 Connection 头为 keep-alive,允许重用 TCP 连接,避免每次请求都重新建立连接带来的开销 + httpRequestMessage.Headers.ConnectionClose = false; + } + + /// + /// 追加 Cookies + /// + /// + /// + /// + internal void AppendCookies(HttpRequestMessage httpRequestMessage) + { + // 空检查 + if (Cookies.IsNullOrEmpty()) + { + return; + } + + httpRequestMessage.Headers.TryAddWithoutValidation(HeaderNames.Cookie, + string.Join("; ", Cookies.Select(u => $"{u.Key}={u.Value.EscapeDataString(true)}"))); + } + + /// + /// 移除 Cookies + /// + /// + /// + /// + internal void RemoveCookies(HttpRequestMessage httpRequestMessage) + { + // 空检查 + if (CookiesToRemove.IsNullOrEmpty()) + { + return; + } + + // 获取已经设置的 Cookies + if (!httpRequestMessage.Headers.TryGetValues(HeaderNames.Cookie, out var cookies)) + { + return; + } + + // 解析 Cookies 标头值 + var cookieList = CookieHeaderValue.ParseList(cookies.ToList()); + + // 空检查 + if (cookieList.Count == 0) + { + return; + } + + // 重新设置 Cookies + httpRequestMessage.Headers.Remove(HeaderNames.Cookie); + httpRequestMessage.Headers.TryAddWithoutValidation(HeaderNames.Cookie, + // 过滤已标记为移除的 Cookie 键 + string.Join("; ", cookieList.WhereIf(CookiesToRemove is { Count: > 0 }, + u => CookiesToRemove?.TryGetValue(u.Name.ToString(), out _) == false) + .Select(u => $"{u.Name}={u.Value}"))); + } + + /// + /// 构建并设置指定的 请求消息的内容 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal void BuildAndSetContent(HttpRequestMessage httpRequestMessage, + IHttpContentProcessorFactory httpContentProcessorFactory, HttpRemoteOptions httpRemoteOptions) + { + // 获取自定义的 IHttpContentProcessor 集合 + var processors = HttpContentProcessorProviders?.SelectMany(u => u.Invoke()).ToArray(); + + // 构建 MultipartFormDataContent 请求内容 + if (MultipartFormDataBuilder is not null) + { + ContentType = MediaTypeNames.Multipart.FormData; + RawContent = MultipartFormDataBuilder.Build(httpRemoteOptions, httpContentProcessorFactory, processors); + } + + // 检查是否设置了内容 + if (RawContent is null) + { + return; + } + + // 设置默认的内容类型 + SetDefaultContentType(httpRemoteOptions.DefaultContentType); + + // 构建 HttpContent 实例 + var httpContent = httpContentProcessorFactory.Build(RawContent, ContentType!, ContentEncoding, processors); + + // 调用用于处理在设置请求消息的内容时的操作 + OnPreSetContent?.Invoke(httpContent); + + // 设置 HttpRequestMessage 请求消息的内容 + httpRequestMessage.Content = httpContent; + } + + /// + /// 追加 请求属性集合 + /// + /// + /// + /// + internal void AppendProperties(HttpRequestMessage httpRequestMessage) + { + // 空检查 + if (Properties.Count > 0) + { + // 注意:httpRequestMessage.Properties 已过时,使用 Options 替代 + httpRequestMessage.Options.AddOrUpdate(Properties); + } + + // 检查是否禁用全局请求分析工具 + if (__Disabled_Profiler__) + { + httpRequestMessage.Options.AddOrUpdate(Constants.DISABLED_PROFILER_KEY, "TRUE"); + } + } + + /// + /// 设置默认的内容类型 + /// + /// 默认请求内容类型 + internal void SetDefaultContentType(string? defaultContentType) + { + // 空检查 + if (!string.IsNullOrWhiteSpace(ContentType)) + { + return; + } + + ContentType = RawContent switch + { + JsonContent => MediaTypeNames.Application.Json, + FormUrlEncodedContent => MediaTypeNames.Application.FormUrlEncoded, + (byte[] or Stream or ByteArrayContent or StreamContent or ReadOnlyMemoryContent or ReadOnlyMemory) + and not StringContent => MediaTypeNames.Application + .Octet, + MultipartContent => MediaTypeNames.Multipart.FormData, + _ => defaultContentType ?? Constants.TEXT_PLAIN_MIME_TYPE + }; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpServerSentEventsBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpServerSentEventsBuilder.cs new file mode 100644 index 000000000..5b275044a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpServerSentEventsBuilder.cs @@ -0,0 +1,231 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.Http.Headers; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP Server-Sent Events 构建器 +/// +/// +/// 使用 HttpRequestBuilder.ServerSentEvents(requestUri, onMessage) 静态方法创建。 +/// 参考文献:https://developer.mozilla.org/zh-CN/docs/Web/API/Server-sent_events/Using_server-sent_events。 +/// +public sealed class HttpServerSentEventsBuilder +{ + /// + /// + /// + /// 请求地址 + internal HttpServerSentEventsBuilder(Uri? requestUri) => RequestUri = requestUri; + + /// + /// 请求地址 + /// + public Uri? RequestUri { get; } + + /// + /// 默认重新连接的间隔时间(毫秒) + /// + /// 默认值为 2000 毫秒。 + public int DefaultRetryInterval { get; private set; } = 2000; + + /// + /// 最大重试次数 + /// + /// 默认最大重试次数为 100。 + public int MaxRetries { get; private set; } = 100; + + /// + /// 用于在与事件源的连接打开时的操作 + /// + public Action? OnOpen { get; private set; } + + /// + /// 用于在从事件源接收到数据时的操作 + /// + public Func? OnMessage { get; private set; } + + /// + /// 用于在事件源连接未能打开时的操作 + /// + public Action? OnError { get; private set; } + + /// + /// 实现 的类型 + /// + internal Type? ServerSentEventsEventHandlerType { get; private set; } + + /// + /// 设置默认重新连接的间隔时间 + /// + /// 默认重新连接的间隔时间 + /// + /// + /// + /// + public HttpServerSentEventsBuilder SetDefaultRetryInterval(int retryInterval) + { + // 小于或等于 0 检查 + if (retryInterval <= 0) + { + throw new ArgumentException("Retry interval must be greater than 0.", nameof(retryInterval)); + } + + DefaultRetryInterval = retryInterval; + + return this; + } + + /// + /// 设置最大重试次数 + /// + /// 最大重试次数 + /// + /// + /// + /// + public HttpServerSentEventsBuilder SetMaxRetries(int maxRetries) + { + // 小于或等于 0 检查 + if (maxRetries <= 0) + { + throw new ArgumentException("Max retries must be greater than 0.", nameof(maxRetries)); + } + + MaxRetries = maxRetries; + + return this; + } + + /// + /// 设置用于在与事件源的连接打开时的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpServerSentEventsBuilder SetOnOpen(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnOpen = configure; + + return this; + } + + /// + /// 设置用于在从事件源接收到数据时的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpServerSentEventsBuilder SetOnMessage(Func configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnMessage = configure; + + return this; + } + + /// + /// 设置用于在事件源连接未能打开时的操作 + /// + /// 自定义配置委托 + /// + /// + /// + public HttpServerSentEventsBuilder SetOnError(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + OnError = configure; + + return this; + } + + /// + /// 设置 Server-Sent Events 事件处理程序 + /// + /// 实现 接口的类型 + /// + /// + /// + /// + public HttpServerSentEventsBuilder SetEventHandler(Type serverSentEventsEventHandlerType) + { + // 空检查 + ArgumentNullException.ThrowIfNull(serverSentEventsEventHandlerType); + + // 检查类型是否实现了 IHttpServerSentEventsEventHandler 接口 + if (!typeof(IHttpServerSentEventsEventHandler).IsAssignableFrom(serverSentEventsEventHandlerType)) + { + throw new ArgumentException( + $"`{serverSentEventsEventHandlerType}` type is not assignable from `{typeof(IHttpServerSentEventsEventHandler)}`.", + nameof(serverSentEventsEventHandlerType)); + } + + ServerSentEventsEventHandlerType = serverSentEventsEventHandlerType; + + return this; + } + + /// + /// 设置 Server-Sent Events 事件处理程序 + /// + /// + /// + /// + /// + /// + /// + public HttpServerSentEventsBuilder SetEventHandler() + where TServerSentEventsEventHandler : IHttpServerSentEventsEventHandler => + SetEventHandler(typeof(TServerSentEventsEventHandler)); + + /// + /// 构建 实例 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + internal HttpRequestBuilder Build(HttpRemoteOptions httpRemoteOptions, Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteOptions); + + // 初始化 HttpRequestBuilder 实例,并确保请求标头中添加了 Accept: text/event-stream; + // 如果请求失败,则应抛出异常。 + // 请注意,Server-Sent Events(SSE)标准仅支持使用 GET 方法进行请求。 + var httpRequestBuilder = HttpRequestBuilder.Create(HttpMethod.Get, RequestUri, configure) + .WithHeader(nameof(HttpRequestHeaders.Accept), "text/event-stream", replace: true).DisableCache() + .EnsureSuccessStatusCode(); + + // 检查是否设置了事件处理程序且该处理程序实现了 IHttpRequestEventHandler 接口,如果有则设置给 httpRequestBuilder + if (ServerSentEventsEventHandlerType is not null && + typeof(IHttpRequestEventHandler).IsAssignableFrom(ServerSentEventsEventHandlerType)) + { + httpRequestBuilder.SetEventHandler(ServerSentEventsEventHandlerType); + } + + return httpRequestBuilder; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpStressTestHarnessBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpStressTestHarnessBuilder.cs new file mode 100644 index 000000000..20c870906 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/HttpStressTestHarnessBuilder.cs @@ -0,0 +1,151 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 压力测试构建器 +/// +/// 使用 HttpRequestBuilder.StressTestHarness(requestUri, numberOfRequests) 静态方法创建。 +public sealed class HttpStressTestHarnessBuilder +{ + /// + /// + /// + /// 请求方式 + /// 请求地址 + internal HttpStressTestHarnessBuilder(HttpMethod httpMethod, Uri? requestUri) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpMethod); + + Method = httpMethod; + RequestUri = requestUri; + } + + /// + /// 请求地址 + /// + public Uri? RequestUri { get; } + + /// + /// 请求方式 + /// + public HttpMethod Method { get; } + + /// + /// 并发请求数量 + /// + /// 默认值为:100。 + public int NumberOfRequests { get; private set; } = 100; + + /// + /// 最大并发度 + /// + /// 用于控制系统在同一时间内处理的请求数量。默认值为:100。 + public int MaxDegreeOfParallelism { get; private set; } = 100; + + /// + /// 压测轮次 + /// + /// 默认值为:1。 + public int NumberOfRounds { get; private set; } = 1; + + /// + /// 设置并发请求数量 + /// + /// 并发请求数量 + /// + /// + /// + /// + public HttpStressTestHarnessBuilder SetNumberOfRequests(int numberOfRequests) + { + // 小于或等于 0 检查 + if (numberOfRequests <= 0) + { + throw new ArgumentException("Number of requests must be greater than 0.", nameof(numberOfRequests)); + } + + NumberOfRequests = numberOfRequests; + + return this; + } + + /// + /// 设置最大并发度 + /// + /// 最大并发度 + /// + /// + /// + /// + public HttpStressTestHarnessBuilder SetMaxDegreeOfParallelism(int maxDegreeOfParallelism) + { + // 小于或等于 0 检查 + if (maxDegreeOfParallelism <= 0) + { + throw new ArgumentException("Max degree of parallelism must be greater than 0.", + nameof(maxDegreeOfParallelism)); + } + + MaxDegreeOfParallelism = maxDegreeOfParallelism; + + return this; + } + + /// + /// 设置压测轮次 + /// + /// 压测轮次 + /// + /// + /// + /// + public HttpStressTestHarnessBuilder SetNumberOfRounds(int numberOfRounds) + { + // 小于或等于 0 检查 + if (numberOfRounds <= 0) + { + throw new ArgumentException("Number of rounds must be greater than 0.", + nameof(numberOfRounds)); + } + + NumberOfRounds = numberOfRounds; + + return this; + } + + /// + /// 构建 实例 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + internal HttpRequestBuilder Build(HttpRemoteOptions httpRemoteOptions, Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteOptions); + + // 初始化 HttpRequestBuilder 实例,并确保请求标头中添加了 X-Stress-Test: Harness; + // 同时禁用请求分析工具和启用 HttpClient 池化管理 + var httpRequestBuilder = HttpRequestBuilder.Create(Method, RequestUri, configure) + .WithHeader(Constants.X_STRESS_TEST_HEADER, Constants.X_STRESS_TEST_VALUE, replace: true).Profiler(false) + .PerformanceOptimization() + .UseHttpClientPool(); + + return httpRequestBuilder; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/IHttpRemoteBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/IHttpRemoteBuilder.cs new file mode 100644 index 000000000..bdf67846c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Builders/IHttpRemoteBuilder.cs @@ -0,0 +1,68 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求服务构建器 +/// +public interface IHttpRemoteBuilder +{ + /// + /// + /// + IServiceCollection Services { get; } + + /// + /// 配置 实例 + /// + /// 自定义配置委托 + /// + /// + /// + IHttpRemoteBuilder ConfigureOptions(Action configure); +} + +/// +/// 默认实现 +/// +internal sealed class DefaultHttpRemoteBuilder : IHttpRemoteBuilder +{ + /// + /// + /// + /// + /// + /// + public DefaultHttpRemoteBuilder(IServiceCollection services) + { + // 空检查 + ArgumentNullException.ThrowIfNull(services); + + Services = services; + } + + /// + public IServiceCollection Services { get; } + + /// + public IHttpRemoteBuilder ConfigureOptions(Action configure) + { + // 空检查 + ArgumentNullException.ThrowIfNull(configure); + + Services.Configure(configure); + + return this; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Constants/Constants.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Constants/Constants.cs new file mode 100644 index 000000000..bd20b4a5e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Constants/Constants.cs @@ -0,0 +1,102 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求模块常量配置 +/// +internal static class Constants +{ + /// + /// 请求跟踪标识标头 + /// + internal const string X_TRACE_ID_HEADER = "X-Trace-ID"; + + /// + /// 未知 User Agent 版本 + /// + internal const string UNKNOWN_USER_AGENT_VERSION = "unknown"; + + /// + /// 内容正文部分的处置类型 + /// + internal const string FORM_DATA_DISPOSITION_TYPE = "form-data"; + + /// + /// Basic 授权标识 + /// + internal const string BASIC_AUTHENTICATION_SCHEME = "Basic"; + + /// + /// JWT (JSON Web Token) 授权标识 + /// + internal const string JWT_BEARER_AUTHENTICATION_SCHEME = "Bearer"; + + /// + /// Digest 授权标识 + /// + internal const string DIGEST_AUTHENTICATION_SCHEME = "Digest"; + + /// + /// text/plain 内容类型 + /// + internal const string TEXT_PLAIN_MIME_TYPE = "text/plain"; + + /// + /// 响应结束符标头 + /// + internal const string X_END_OF_STREAM_HEADER = "X-End-Of-Stream"; + + /// + /// 请求原始地址标头 + /// + internal const string X_ORIGINAL_URL_HEADER = "X-Original-URL"; + + /// + /// 请求转发目标地址标头 + /// + internal const string X_FORWARD_TO_HEADER = "X-Forward-To"; + + /// + /// 压力测试标头 + /// + internal const string X_STRESS_TEST_HEADER = "X-Stress-Test"; + + /// + /// 压力测试标头值 + /// + internal const string X_STRESS_TEST_VALUE = "Harness"; + + /// + /// 禁用请求分析工具键 + /// + /// 被用于从 Options 属性中读取。 + internal const string DISABLED_PROFILER_KEY = "__Disabled_Profiler__"; + + /// + /// HTTP 声明式请求方法签名键 + /// + /// 被用于从 Options 属性中读取。 + internal const string DECLARATIVE_METHOD_KEY = "__DECLARATIVE_METHOD__"; + + /// + /// 浏览器的 User-Agent 标头值 + /// + internal const string USER_AGENT_OF_BROWSER = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0"; + + /// + /// 移动端浏览器的 User-Agent 标头值 + /// + internal const string USER_AGENT_OF_MOBILE_BROWSER = + "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36 Edg/130.0.0.0"; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Constants/FileExistsBehavior.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Constants/FileExistsBehavior.cs new file mode 100644 index 000000000..cd0e2123b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Constants/FileExistsBehavior.cs @@ -0,0 +1,35 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 指定当目标文件已存在时的行为 +/// +public enum FileExistsBehavior +{ + /// + /// 创建新文件 + /// + /// 如果文件已存在则抛出异常。 + CreateNew = 0, + + /// + /// 覆盖现有文件 + /// + Overwrite, + + /// + /// 保留现有文件 + /// + /// 不进行任何操作。 + Skip +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Constants/FileSourceType.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Constants/FileSourceType.cs new file mode 100644 index 000000000..8b4bb6648 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Constants/FileSourceType.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 指定多部分表单内容文件的来源类型 +/// +public enum FileSourceType +{ + /// + /// 缺省值 + /// + /// 不用作为文件的来源。 + None = 0, + + /// + /// 本地文件路径 + /// + Path, + + /// + /// Base64 字符串文件 + /// + Base64String, + + /// + /// 互联网文件地址 + /// + Remote, + + /// + /// + /// + Stream, + + /// + /// 字节数组 + /// + ByteArray +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/ByteArrayContentConverter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/ByteArrayContentConverter.cs new file mode 100644 index 000000000..ecc471f46 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/ByteArrayContentConverter.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 字节数组内容转换器 +/// +public class ByteArrayContentConverter : HttpContentConverterBase +{ + /// + public override byte[]? Read(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + httpResponseMessage.Content.ReadAsByteArrayAsync(cancellationToken).GetAwaiter().GetResult(); + + /// + public override async Task ReadAsync(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + await httpResponseMessage.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/HttpContentConverterBase.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/HttpContentConverterBase.cs new file mode 100644 index 000000000..e0efa0f3a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/HttpContentConverterBase.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 内容处理器基类 +/// +/// 转换的目标类型 +public abstract class HttpContentConverterBase : IHttpContentConverter +{ + /// + public IServiceProvider? ServiceProvider { get; set; } + + /// + public abstract TResult? Read(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default); + + /// + public abstract Task ReadAsync(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default); + + /// + public virtual object? Read(Type resultType, HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + Read(httpResponseMessage, cancellationToken); + + /// + public virtual async Task ReadAsync(Type resultType, HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + await ReadAsync(httpResponseMessage, cancellationToken).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/HttpResponseMessageConverter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/HttpResponseMessageConverter.cs new file mode 100644 index 000000000..bad9498d3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/HttpResponseMessageConverter.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 内容转换器 +/// +public class HttpResponseMessageConverter : HttpContentConverterBase +{ + /// + public override HttpResponseMessage? Read(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + httpResponseMessage; + + /// + public override Task ReadAsync(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + Task.FromResult(httpResponseMessage); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/IActionResultContentConverter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/IActionResultContentConverter.cs new file mode 100644 index 000000000..74339fb85 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/IActionResultContentConverter.cs @@ -0,0 +1,178 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Mvc; + +using System.Net; +using System.Net.Mime; + +namespace ThingsGateway.HttpRemote; + +/// +/// 内容转换器 +/// +public class IActionResultContentConverter : HttpContentConverterBase +{ + /// + public override IActionResult? Read(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) + { + // 处理特定状态码结果 + if (TryGetStatusCodeResult(httpResponseMessage, out var statusCode, out var statusCodeResult)) + { + return statusCodeResult; + } + + // 获取响应内容标头 + var contentHeaders = httpResponseMessage.Content.Headers; + + // 获取内容类型 + var contentType = contentHeaders.ContentType; + var mediaType = contentType?.MediaType; + + // 空检查 + ArgumentNullException.ThrowIfNull(mediaType); + + switch (mediaType) + { + case MediaTypeNames.Application.Json: + case MediaTypeNames.Application.JsonPatch: + case MediaTypeNames.Application.Xml: + case MediaTypeNames.Application.XmlPatch: + case MediaTypeNames.Text.Xml: + case MediaTypeNames.Text.Html: + case MediaTypeNames.Text.Plain: + // 读取字符串内容 + var stringContent = httpResponseMessage.Content.ReadAsStringAsync(cancellationToken).GetAwaiter() + .GetResult(); + + return new ContentResult + { + Content = stringContent, + StatusCode = (int)statusCode, + ContentType = contentType?.ToString() + }; + default: + // 获取 ContentDisposition 实例 + var contentDisposition = contentHeaders.ContentDisposition; + + // 获取文件下载名 + var fileDownloadName = contentDisposition?.FileNameStar ?? contentDisposition?.FileName; + + // 读取流内容 + var streamContent = httpResponseMessage.Content.ReadAsStream(cancellationToken); + + return new FileStreamResult(streamContent, contentType!.ToString()) + { + FileDownloadName = + string.IsNullOrWhiteSpace(fileDownloadName) + ? fileDownloadName + // 使用 UTF-8 解码文件的名称 + : Uri.UnescapeDataString(fileDownloadName), + LastModified = contentHeaders.LastModified?.UtcDateTime + }; + } + } + + /// + public override async Task ReadAsync(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) + { + // 处理特定状态码结果 + if (TryGetStatusCodeResult(httpResponseMessage, out var statusCode, out var statusCodeResult)) + { + return statusCodeResult; + } + + // 获取响应内容标头 + var contentHeaders = httpResponseMessage.Content.Headers; + + // 获取内容类型 + var contentType = contentHeaders.ContentType; + var mediaType = contentType?.MediaType; + + // 空检查 + ArgumentNullException.ThrowIfNull(mediaType); + + switch (mediaType) + { + case MediaTypeNames.Application.Json: + case MediaTypeNames.Application.JsonPatch: + case MediaTypeNames.Application.Xml: + case MediaTypeNames.Application.XmlPatch: + case MediaTypeNames.Text.Xml: + case MediaTypeNames.Text.Html: + case MediaTypeNames.Text.Plain: + // 读取字符串内容 + var stringContent = await httpResponseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + return new ContentResult + { + Content = stringContent, + StatusCode = (int)statusCode, + ContentType = contentType?.ToString() + }; + default: + // 获取 ContentDisposition 实例 + var contentDisposition = contentHeaders.ContentDisposition; + + // 获取文件下载名 + var fileDownloadName = contentDisposition?.FileNameStar ?? contentDisposition?.FileName; + + // 读取流内容 + var streamContent = await httpResponseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + return new FileStreamResult(streamContent, contentType!.ToString()) + { + FileDownloadName = + string.IsNullOrWhiteSpace(fileDownloadName) + ? fileDownloadName + // 使用 UTF-8 解码文件的名称 + : Uri.UnescapeDataString(fileDownloadName), + LastModified = contentHeaders.LastModified?.UtcDateTime + }; + } + } + + /// + /// 处理特定状态码结果 + /// + /// + /// + /// + /// HTTP 状态码 + /// + /// + /// + /// + /// + /// + internal static bool TryGetStatusCodeResult(HttpResponseMessage httpResponseMessage, out HttpStatusCode statusCode, + out IActionResult? statusCodeResult) + { + // 获取状态码 + statusCode = httpResponseMessage.StatusCode; + + statusCodeResult = statusCode switch + { + HttpStatusCode.NoContent => new NoContentResult(), + HttpStatusCode.BadRequest => new BadRequestResult(), + HttpStatusCode.Unauthorized => new UnauthorizedResult(), + HttpStatusCode.NotFound => new NotFoundResult(), + HttpStatusCode.Conflict => new ConflictResult(), + HttpStatusCode.UnsupportedMediaType => new UnsupportedMediaTypeResult(), + HttpStatusCode.UnprocessableEntity => new UnprocessableEntityResult(), + _ => null + }; + + return statusCodeResult is not null; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/IHttpContentConverter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/IHttpContentConverter.cs new file mode 100644 index 000000000..744dd0580 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/IHttpContentConverter.cs @@ -0,0 +1,90 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 响应内容转换器默认实现接口 +/// +public interface IHttpContentConverter +{ + /// + /// + /// + IServiceProvider? ServiceProvider { get; set; } + + /// + /// 从 中同步读取数据并转换为 实例 + /// + /// 转换的目标类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + object? Read(Type resultType, HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default); + + /// + /// 从 中异步读取数据并转换为 实例 + /// + /// 转换的目标类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task ReadAsync(Type resultType, HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default); +} + +/// +/// 响应内容转换器 +/// +/// 转换的目标类型 +public interface IHttpContentConverter : IHttpContentConverter +{ + /// + /// 从 中同步读取数据并转换为目标类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + TResult? Read(HttpResponseMessage httpResponseMessage, CancellationToken cancellationToken = default); + + /// + /// 从 中异步读取数据并转换为目标类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task ReadAsync(HttpResponseMessage httpResponseMessage, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/ObjectContentConverter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/ObjectContentConverter.cs new file mode 100644 index 000000000..3984238a5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/ObjectContentConverter.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using System.Net.Http.Json; + +namespace ThingsGateway.HttpRemote; + +/// +/// 默认基类 +/// +public class ObjectContentConverter : IHttpContentConverter +{ + /// + public IServiceProvider? ServiceProvider { get; set; } + + /// + public virtual object? Read(Type resultType, HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + httpResponseMessage.Content.ReadFromJsonAsync(resultType, + ServiceProvider?.GetRequiredService>().Value.JsonSerializerOptions ?? + HttpRemoteOptions.JsonSerializerOptionsDefault, cancellationToken).GetAwaiter().GetResult(); + + /// + public virtual async Task ReadAsync(Type resultType, HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + await httpResponseMessage.Content.ReadFromJsonAsync(resultType, + ServiceProvider?.GetRequiredService>().Value.JsonSerializerOptions ?? + HttpRemoteOptions.JsonSerializerOptionsDefault, cancellationToken).ConfigureAwait(false); +} + +/// +/// 对象转换器 +/// +/// 转换的目标类型 +public class ObjectContentConverter : ObjectContentConverter, IHttpContentConverter +{ + /// + public virtual TResult? Read(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + httpResponseMessage.Content.ReadFromJsonAsync( + ServiceProvider?.GetRequiredService>().Value.JsonSerializerOptions ?? + HttpRemoteOptions.JsonSerializerOptionsDefault, cancellationToken).GetAwaiter().GetResult(); + + /// + public virtual async Task ReadAsync(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + await httpResponseMessage.Content.ReadFromJsonAsync( + ServiceProvider?.GetRequiredService>().Value.JsonSerializerOptions ?? + HttpRemoteOptions.JsonSerializerOptionsDefault, cancellationToken).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/StreamContentConverter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/StreamContentConverter.cs new file mode 100644 index 000000000..87529076a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/StreamContentConverter.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 流内容转换器 +/// +public class StreamContentConverter : HttpContentConverterBase +{ + /// + public override Stream? Read(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + httpResponseMessage.Content.ReadAsStream(cancellationToken); + + /// + public override async Task ReadAsync(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + await httpResponseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/StringContentConverter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/StringContentConverter.cs new file mode 100644 index 000000000..f6b4a7f0d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/StringContentConverter.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 字符串内容转换器 +/// +public class StringContentConverter : HttpContentConverterBase +{ + /// + public override string? Read(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + httpResponseMessage.Content.ReadAsStringAsync(cancellationToken).GetAwaiter().GetResult(); + + /// + public override async Task ReadAsync(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + await httpResponseMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/VoidContentConverter.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/VoidContentConverter.cs new file mode 100644 index 000000000..b278d5c22 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Converters/VoidContentConverter.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 内容转换器 +/// +public class VoidContentConverter : HttpContentConverterBase +{ + /// + public override VoidContent? Read(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => default; + + /// + public override Task ReadAsync(HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken = default) => + Task.FromResult(default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/AcceptLanguageAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/AcceptLanguageAttribute.cs new file mode 100644 index 000000000..45e0b5330 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/AcceptLanguageAttribute.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式客户端所偏好的自然语言和区域特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class AcceptLanguageAttribute : Attribute +{ + /// + /// + /// + /// 自然语言和区域设置 + public AcceptLanguageAttribute(string? language) => Language = language; + + /// + /// 客户端偏好的语言和区域 + /// + public string? Language { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/AutoSetHostHeaderAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/AutoSetHostHeaderAttribute.cs new file mode 100644 index 000000000..5134ac069 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/AutoSetHostHeaderAttribute.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式设置自动 Host 标头特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class AutoSetHostHeaderAttribute : Attribute +{ + /// + /// + /// + public AutoSetHostHeaderAttribute() + : this(true) + { + } + + /// + /// + /// + /// 是否启用 + public AutoSetHostHeaderAttribute(bool enabled) => Enabled = enabled; + + /// + /// 是否启用 + /// + public bool Enabled { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/BaseAddressAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/BaseAddressAttribute.cs new file mode 100644 index 000000000..7f063f46e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/BaseAddressAttribute.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式请求基地址特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class BaseAddressAttribute : Attribute +{ + /// + /// + /// + /// 请求基地址 + public BaseAddressAttribute(string? baseAddress) => BaseAddress = baseAddress; + + /// + /// 请求基地址 + /// + public string? BaseAddress { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/BodyAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/BodyAttribute.cs new file mode 100644 index 000000000..13fa78553 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/BodyAttribute.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式请求内容特性 +/// +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class BodyAttribute : Attribute +{ + /// + /// + /// + public BodyAttribute() + { + } + + /// + /// + /// + /// 内容类型 + public BodyAttribute(string contentType) => ContentType = contentType; + + /// + /// + /// + /// 内容类型 + /// 内容编码 + public BodyAttribute(string contentType, string contentEncoding) + : this(contentType) => + ContentEncoding = contentEncoding; + + /// + /// 内容类型 + /// + public string? ContentType { get; set; } + + /// + /// 内容编码 + /// + public string? ContentEncoding { get; set; } + + /// + /// 是否使用 构建 。默认 false + /// + /// 值为 application/x-www-form-urlencoded 时有效。 + public bool UseStringContent { get; set; } + + /// + /// 是否为原始字符串内容。默认 false + /// + /// + /// 作用于 类型参数时有效。 + /// 当属性值设置为 true 时,将校验 属性值是否为空,并且字符串内容将被双引号包围并发送,格式如下:"内容" + /// + public bool RawString { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/CookieAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/CookieAttribute.cs new file mode 100644 index 000000000..5871298ce --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/CookieAttribute.cs @@ -0,0 +1,97 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 Cookie 特性 +/// +/// 支持多次指定。 +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Parameter, + AllowMultiple = true)] +public sealed class CookieAttribute : Attribute +{ + /// + /// + /// + /// 特性作用于参数时有效。 + public CookieAttribute() + { + } + + /// + /// + /// + /// + /// 当特性作用于方法或接口时,则表示移除指定 Cookie 操作。 + /// 当特性作用于参数时,则表示添加 Cookie ,同时设置 Cookie 键为 name 的值。 + /// + /// Cookie 键 + public CookieAttribute(string name) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + } + + /// + /// + /// + /// Cookie 键 + /// Cookie 的值 + public CookieAttribute(string name, object? value) + : this(name) => + Value = value; + + /// + /// Cookie 键 + /// + /// 该属性优先级低于 属性设置的值。 + public string? Name { get; set; } + + /// + /// Cookie 的值 + /// + /// 当特性作用于参数时,表示默认值。 + public object? Value + { + get + { + return _value; + } + set + { + _value = value; + HasSetValue = true; + } + } + private object? _value { get; set; } + + /// + /// 别名 + /// + /// + /// 特性用于参数时有效。 + /// 该属性优先级高于 属性设置的值。 + /// + public string? AliasAs { get; set; } + + /// + /// 是否转义 + /// + public bool Escape { get; set; } + + /// + /// 是否设置了值 + /// + internal bool HasSetValue { get; private set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/DisableCacheAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/DisableCacheAttribute.cs new file mode 100644 index 000000000..af3f5e669 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/DisableCacheAttribute.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式禁用 HTTP 缓存特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class DisableCacheAttribute : Attribute +{ + /// + /// + /// + public DisableCacheAttribute() + : this(true) + { + } + + /// + /// + /// + /// 是否禁用 + public DisableCacheAttribute(bool disabled) => Disabled = disabled; + + /// + /// 是否禁用 + /// + public bool Disabled { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/EnsureSuccessStatusCodeAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/EnsureSuccessStatusCodeAttribute.cs new file mode 100644 index 000000000..e78e80b17 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/EnsureSuccessStatusCodeAttribute.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式如果 HTTP 响应的 IsSuccessStatusCode 属性是 false,则引发异常特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class EnsureSuccessStatusCodeAttribute : Attribute +{ + /// + /// + /// + public EnsureSuccessStatusCodeAttribute() + : this(true) + { + } + + /// + /// + /// + /// 是否启用 + public EnsureSuccessStatusCodeAttribute(bool enabled) => Enabled = enabled; + + /// + /// 是否启用 + /// + public bool Enabled { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/HeaderAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/HeaderAttribute.cs new file mode 100644 index 000000000..4c21f6fef --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/HeaderAttribute.cs @@ -0,0 +1,104 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式请求标头特性 +/// +/// 支持多次指定。 +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Parameter, + AllowMultiple = true)] +public sealed class HeaderAttribute : Attribute +{ + /// + /// + /// + /// 特性作用于参数时有效。 + public HeaderAttribute() + { + } + + /// + /// + /// + /// + /// 当特性作用于方法或接口时,则表示移除指定请求标头操作。 + /// 当特性作用于参数时,则表示添加请求标头,同时设置请求标头键为 name 的值。 + /// + /// 请求标头键 + public HeaderAttribute(string name) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + } + + /// + /// + /// + /// 请求标头键 + /// 请求标头的值 + public HeaderAttribute(string name, object? value) + : this(name) => + Value = value; + + /// + /// 请求标头键 + /// + /// 该属性优先级低于 属性设置的值。 + public string? Name { get; set; } + + + + /// + /// 请求标头的值 + /// + /// 当特性作用于参数时,表示默认值。 + public object? Value + { + get + { + return _value; + } + set + { + _value = value; + HasSetValue = true; + } + } + private object? _value { get; set; } + + /// + /// 别名 + /// + /// + /// 特性用于参数时有效。 + /// 该属性优先级高于 属性设置的值。 + /// + public string? AliasAs { get; set; } + + /// + /// 是否转义 + /// + public bool Escape { get; set; } + + /// + /// 是否替换已存在的请求标头。默认值为 false + /// + public bool Replace { get; set; } + + /// + /// 是否设置了值 + /// + internal bool HasSetValue { get; private set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/HttpClientNameAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/HttpClientNameAttribute.cs new file mode 100644 index 000000000..6193d7e06 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/HttpClientNameAttribute.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 实例的配置名称特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class HttpClientNameAttribute : Attribute +{ + /// + /// + /// + /// 实例的配置名称 + public HttpClientNameAttribute(string? name) => Name = name; + + /// + /// 实例的配置名称 + /// + public string? Name { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/DeleteAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/DeleteAttribute.cs new file mode 100644 index 000000000..026fc5567 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/DeleteAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 DELETE 请求方式特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class DeleteAttribute : HttpMethodAttribute +{ + /// + /// + /// + /// 请求地址 + public DeleteAttribute(string? requestUri = null) + : base(HttpMethod.Delete, requestUri) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/GetAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/GetAttribute.cs new file mode 100644 index 000000000..7a2629a0f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/GetAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 GET 请求方式特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class GetAttribute : HttpMethodAttribute +{ + /// + /// + /// + /// 请求地址 + public GetAttribute(string? requestUri = null) + : base(HttpMethod.Get, requestUri) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/HeadAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/HeadAttribute.cs new file mode 100644 index 000000000..088d87dc0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/HeadAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 HEAD 请求方式特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class HeadAttribute : HttpMethodAttribute +{ + /// + /// + /// + /// 请求地址 + public HeadAttribute(string? requestUri = null) + : base(HttpMethod.Head, requestUri) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/HttpMethodAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/HttpMethodAttribute.cs new file mode 100644 index 000000000..e9953e396 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/HttpMethodAttribute.cs @@ -0,0 +1,53 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式请求方式特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public class HttpMethodAttribute : Attribute +{ + /// + /// + /// + /// 请求方式 + /// 请求地址 + public HttpMethodAttribute(string httpMethod, string? requestUri = null) + : this(Helpers.ParseHttpMethod(httpMethod), requestUri) + { + } + + /// + /// + /// + /// 请求方式 + /// 请求地址 + public HttpMethodAttribute(HttpMethod httpMethod, string? requestUri = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpMethod); + + Method = httpMethod; + RequestUri = requestUri; + } + + /// + /// 请求方式 + /// + public HttpMethod Method { get; set; } + + /// + /// 请求地址 + /// + public string? RequestUri { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/OptionsAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/OptionsAttribute.cs new file mode 100644 index 000000000..800a1147f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/OptionsAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 OPTIONS 请求方式特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class OptionsAttribute : HttpMethodAttribute +{ + /// + /// + /// + /// 请求地址 + public OptionsAttribute(string? requestUri = null) + : base(HttpMethod.Options, requestUri) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/PatchAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/PatchAttribute.cs new file mode 100644 index 000000000..701b444b8 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/PatchAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 PATCH 请求方式特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class PatchAttribute : HttpMethodAttribute +{ + /// + /// + /// + /// 请求地址 + public PatchAttribute(string? requestUri = null) + : base(HttpMethod.Patch, requestUri) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/PostAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/PostAttribute.cs new file mode 100644 index 000000000..2bf1d2b18 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/PostAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 POST 请求方式特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class PostAttribute : HttpMethodAttribute +{ + /// + /// + /// + /// 请求地址 + public PostAttribute(string? requestUri = null) + : base(HttpMethod.Post, requestUri) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/PutAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/PutAttribute.cs new file mode 100644 index 000000000..428218723 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/PutAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 PUT 请求方式特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class PutAttribute : HttpMethodAttribute +{ + /// + /// + /// + /// 请求地址 + public PutAttribute(string? requestUri = null) + : base(HttpMethod.Put, requestUri) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/TraceAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/TraceAttribute.cs new file mode 100644 index 000000000..63e6ebafa --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/Methods/TraceAttribute.cs @@ -0,0 +1,28 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 TRACE 请求方式特性 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class TraceAttribute : HttpMethodAttribute +{ + /// + /// + /// + /// 请求地址 + public TraceAttribute(string? requestUri = null) + : base(HttpMethod.Trace, requestUri) + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/MultipartAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/MultipartAttribute.cs new file mode 100644 index 000000000..5cc21379f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/MultipartAttribute.cs @@ -0,0 +1,67 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式多部分表单项内容特性 +/// +[AttributeUsage(AttributeTargets.Parameter)] +public sealed class MultipartAttribute : Attribute +{ + /// + /// + /// + public MultipartAttribute() + { + } + + /// + /// + /// + /// 表单名称 + public MultipartAttribute(string name) => Name = name; + + /// + /// 表单名称 + /// + public string? Name { get; set; } + + /// + /// 文件的名称 + /// + public string? FileName { get; set; } + + /// + /// 内容类型 + /// + public string? ContentType { get; set; } + + /// + /// 内容编码 + /// + public string? ContentEncoding { get; set; } + + /// + /// 表示将字符串作为多部分表单文件的来源 + /// + /// 用于设置多部分表单文件内容。当参数类型为 时有效。 + public FileSourceType AsFileFrom { get; set; } + + /// + /// 表示是否作为表单的一项 + /// + /// + /// 当参数类型为对象类型时有效。 + /// 该属性值为 true 时作为表单的一项。否则将遍历对象类型的每一个公开属性作为表单的项。默认值为:true + /// + public bool AsFormItem { get; set; } = true; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/MultipartFormAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/MultipartFormAttribute.cs new file mode 100644 index 000000000..c22fa9aa0 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/MultipartFormAttribute.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式多部分表单内容特性 +/// +/// 需配合 使用。 +[AttributeUsage(AttributeTargets.Method)] +public sealed class MultipartFormAttribute : Attribute +{ + /// + /// + /// + public MultipartFormAttribute() + { + } + + /// + /// + /// + /// 多部分表单内容的边界 + public MultipartFormAttribute(string? boundary) => Boundary = boundary; + + /// + /// 多部分表单内容的边界 + /// + public string? Boundary { get; set; } = $"--------------------------{DateTime.Now.Ticks:x}"; + + /// + /// 是否移除默认的多部分内容的 Content-Type + /// + /// 默认值为:true + public bool OmitContentType { get; set; } = true; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/PathAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/PathAttribute.cs new file mode 100644 index 000000000..9e8433685 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/PathAttribute.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式路径参数特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface, AllowMultiple = true)] +public sealed class PathAttribute : Attribute +{ + /// + /// + /// + /// 参数名称 + /// 参数值 + public PathAttribute(string name, object? value) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + Value = value; + } + + /// + /// 路径参数键 + /// + public string Name { get; set; } + + /// + /// 路径参数的值 + /// + public object? Value { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/PerformanceOptimizationAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/PerformanceOptimizationAttribute.cs new file mode 100644 index 000000000..9105796f7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/PerformanceOptimizationAttribute.cs @@ -0,0 +1,41 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式启用性能优化特性 +/// +/// 当需要返回 内容或进行 HttpContext 网页转发时,请勿启用此配置,因为流会因压缩而变得不可读,同时该配置也不适用于网页转发的场景。 +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class PerformanceOptimizationAttribute : Attribute +{ + /// + /// + /// + /// 当需要返回 内容或进行 HttpContext 网页转发时,请勿启用此配置,因为流会因压缩而变得不可读,同时该配置也不适用于网页转发的场景。 + public PerformanceOptimizationAttribute() + : this(true) + { + } + + /// + /// + /// + /// 当需要返回 内容或进行 HttpContext 网页转发时,请勿启用此配置,因为流会因压缩而变得不可读,同时该配置也不适用于网页转发的场景。 + /// 是否启用 + public PerformanceOptimizationAttribute(bool enabled) => Enabled = enabled; + + /// + /// 是否启用 + /// + public bool Enabled { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/ProfilerAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/ProfilerAttribute.cs new file mode 100644 index 000000000..cb6eb1ada --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/ProfilerAttribute.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式启用请求分析工具特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class ProfilerAttribute : Attribute +{ + /// + /// + /// + public ProfilerAttribute() + : this(true) + { + } + + /// + /// + /// + /// 是否启用 + public ProfilerAttribute(bool enabled) => Enabled = enabled; + + /// + /// 是否启用 + /// + public bool Enabled { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/PropertyAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/PropertyAttribute.cs new file mode 100644 index 000000000..79f1e70d9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/PropertyAttribute.cs @@ -0,0 +1,87 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 请求属性特性 +/// +/// 支持多次指定。 +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Parameter, + AllowMultiple = true)] +public sealed class PropertyAttribute : Attribute +{ + /// + /// + /// + /// 特性作用于参数时有效。 + public PropertyAttribute() + { + } + + /// + /// + /// + /// + /// 当特性作用于参数时,则表示添加 请求属性,同时设置 请求属性键为 + /// name 的值。 + /// + /// 请求属性键 + public PropertyAttribute(string name) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + } + + /// + /// + /// + /// 请求属性键 + /// 请求属性的值 + public PropertyAttribute(string name, object? value) + : this(name) => + Value = value; + + /// + /// 请求属性键 + /// + /// 该属性优先级低于 属性设置的值。 + public string? Name { get; set; } + + /// + /// 请求属性的值 + /// + /// 当特性作用于参数时,表示默认值。 + public object? Value { get; set; } + + /// + /// 别名 + /// + /// + /// 特性用于参数时有效。 + /// 该属性优先级高于 属性设置的值。 + /// + public string? AliasAs { get; set; } + + /// + /// 表示是否作为 请求属性的一项 + /// + /// + /// 当参数类型为对象类型时有效。 + /// + /// 该属性值为 true 时作为 请求属性的一项。否则将遍历对象类型的每一个公开属性作为 + /// 请求属性的项。默认值为:true。 + /// + /// + public bool AsItem { get; set; } = true; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/QueryAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/QueryAttribute.cs new file mode 100644 index 000000000..101be7507 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/QueryAttribute.cs @@ -0,0 +1,114 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式查询参数特性 +/// +/// 支持多次指定。 +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface | AttributeTargets.Parameter, + AllowMultiple = true)] +public sealed class QueryAttribute : Attribute +{ + /// + /// + /// + /// 特性作用于参数时有效。 + public QueryAttribute() + { + } + + /// + /// + /// + /// + /// 当特性作用于方法或接口时,则表示移除指定查询参数操作。 + /// 当特性作用于参数时,则表示添加查询参数,同时设置查询参数键为 name 的值。 + /// + /// 查询参数键 + public QueryAttribute(string name) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + } + + /// + /// + /// + /// 查询参数键 + /// 查询参数的值 + public QueryAttribute(string name, object? value) + : this(name) => + Value = value; + + /// + /// 查询参数键 + /// + /// 该属性优先级低于 属性设置的值。 + public string? Name { get; set; } + + /// + /// 查询参数的值 + /// + /// 当特性作用于参数时,表示默认值。 + public object? Value + { + get + { + return _value; + } + set + { + _value = value; + HasSetValue = true; + } + } + private object? _value { get; set; } + + /// + /// 别名 + /// + /// + /// 特性用于参数时有效。 + /// 该属性优先级高于 属性设置的值。 + /// + public string? AliasAs { get; set; } + + /// + /// 是否转义 + /// + public bool Escape { get; set; } + + /// + /// 参数前缀 + /// + /// 作用于对象类型时有效。 + public string? Prefix { get; set; } + + /// + /// 是否替换已存在的查询参数。默认值为 false + /// + public bool Replace { get; set; } + + /// + /// 是否忽略空值 + /// + /// 设置为 true 之后,当参数值为 null 时将被忽略。默认值为 false + public bool IgnoreNullValues { get; set; } + + /// + /// 是否设置了值 + /// + internal bool HasSetValue { get; private set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/SimulateBrowserAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/SimulateBrowserAttribute.cs new file mode 100644 index 000000000..f40df152b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/SimulateBrowserAttribute.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式模拟浏览器环境特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class SimulateBrowserAttribute : Attribute +{ + /// + /// 是否模拟移动端,默认值为:false(即模拟桌面端) + /// + public bool Mobile { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/TimeoutAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/TimeoutAttribute.cs new file mode 100644 index 000000000..1007ded44 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/TimeoutAttribute.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式超时时间特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class TimeoutAttribute : Attribute +{ + /// + /// + /// + /// 超时时间(毫秒) + public TimeoutAttribute(double timeoutMilliseconds) => Timeout = timeoutMilliseconds; + + /// + /// 超时时间(毫秒) + /// + public double Timeout { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/TraceIdentifierAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/TraceIdentifierAttribute.cs new file mode 100644 index 000000000..723c52c12 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Attributes/TraceIdentifierAttribute.cs @@ -0,0 +1,30 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式跟踪标识特性 +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Interface)] +public sealed class TraceIdentifierAttribute : Attribute +{ + /// + /// + /// + /// 跟踪标识 + public TraceIdentifierAttribute(string traceIdentifier) => Identifier = traceIdentifier; + + /// + /// 跟踪标识 + /// + public string Identifier { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Builders/HttpDeclarativeBuilder.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Builders/HttpDeclarativeBuilder.cs new file mode 100644 index 000000000..776c42427 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Builders/HttpDeclarativeBuilder.cs @@ -0,0 +1,147 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident + +using System.Collections.Concurrent; +using System.Reflection; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式远程请求构建器 +/// +/// 使用 HttpRequestBuilder.Declarative(method, args) 静态方法创建。 +public sealed class HttpDeclarativeBuilder +{ + /// + /// HTTP 声明式 提取器集合 + /// + internal static readonly ConcurrentDictionary _extractors = new([ + new(typeof(BaseAddressDeclarativeExtractor), new BaseAddressDeclarativeExtractor()), + new(typeof(ValidationDeclarativeExtractor), new ValidationDeclarativeExtractor()), + new(typeof(AutoSetHostHeaderDeclarativeExtractor), new AutoSetHostHeaderDeclarativeExtractor()), + new(typeof(PerformanceOptimizationDeclarativeExtractor), new PerformanceOptimizationDeclarativeExtractor()), + new(typeof(HttpClientNameDeclarativeExtractor), new HttpClientNameDeclarativeExtractor()), + new(typeof(TraceIdentifierDeclarativeExtractor), new TraceIdentifierDeclarativeExtractor()), + new(typeof(ProfilerDeclarativeExtractor), new ProfilerDeclarativeExtractor()), + new(typeof(SimulateBrowserDeclarativeExtractor), new SimulateBrowserDeclarativeExtractor()), + new(typeof(AcceptLanguageDeclarativeExtractor), new AcceptLanguageDeclarativeExtractor()), + new(typeof(DisableCacheDeclarativeExtractor), new DisableCacheDeclarativeExtractor()), + new(typeof(EnsureSuccessStatusCodeDeclarativeExtractor), new EnsureSuccessStatusCodeDeclarativeExtractor()), + new(typeof(TimeoutDeclarativeExtractor), new TimeoutDeclarativeExtractor()), + new(typeof(QueryDeclarativeExtractor), new QueryDeclarativeExtractor()), + new(typeof(PathDeclarativeExtractor), new PathDeclarativeExtractor()), + new(typeof(CookieDeclarativeExtractor), new CookieDeclarativeExtractor()), + new(typeof(HeaderDeclarativeExtractor), new HeaderDeclarativeExtractor()), + new(typeof(PropertyDeclarativeExtractor), new PropertyDeclarativeExtractor()), + new(typeof(BodyDeclarativeExtractor), new BodyDeclarativeExtractor()) + ]); + + /// + /// HTTP 声明式 提取器集合(冻结) + /// + /// 该集合用于确保某些 HTTP 声明式提取器始终位于最后。 + internal static readonly ConcurrentDictionary _frozenExtractors = new([ + new(typeof(MultipartDeclarativeExtractor), new MultipartDeclarativeExtractor()), + new(typeof(HttpMultipartFormDataBuilderDeclarativeExtractor), + new HttpMultipartFormDataBuilderDeclarativeExtractor()), + new(typeof(HttpRequestBuilderDeclarativeExtractor), new HttpRequestBuilderDeclarativeExtractor()) + ]); + + /// + /// 标识是否已加载自定义 HTTP 声明式提取器 + /// + internal bool _hasLoadedExtractors; + + /// + /// + /// + /// 被调用方法 + /// 被调用方法的参数值数组 + internal HttpDeclarativeBuilder(MethodInfo method, object?[] args) + { + // 空检查 + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(args); + + Method = method; + Args = args; + } + + /// + /// 被调用方法 + /// + public MethodInfo Method { get; } + + /// + /// 被调用方法的参数值数组 + /// + public object?[] Args { get; } + + /// + /// 构建 实例 + /// + /// + /// + /// + /// + /// + /// + /// + internal HttpRequestBuilder Build(HttpRemoteOptions httpRemoteOptions) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteOptions); + + // 检查被调用方法是否贴有 [HttpMethod] 特性 + if (!Method.IsDefined(typeof(HttpMethodAttribute), true)) + { + throw new InvalidOperationException( + $"No `[HttpMethod]` annotation was found in method `{Method.ToFriendlyString()}` of type `{Method.DeclaringType?.ToFriendlyString()}`."); + } + + // 获取 HttpMethodAttribute 实例 + var httpMethodAttribute = Method.GetCustomAttribute(true)!; + + // 初始化 HttpRequestBuilder 实例并添加声明式方法签名 + var httpRequestBuilder = HttpRequestBuilder.Create(httpMethodAttribute.Method, httpMethodAttribute.RequestUri) + .WithProperty(Constants.DECLARATIVE_METHOD_KEY, + $"{Method.ToFriendlyString()} | {Method.DeclaringType.ToFriendlyString()}"); + + // 初始化 HttpDeclarativeExtractorContext 实例 + var httpDeclarativeExtractorContext = new HttpDeclarativeExtractorContext(Method, Args); + + // 检查是否已加载自定义 HTTP 声明式提取器 + if (!_hasLoadedExtractors) + { + _hasLoadedExtractors = true; + + // 添加自定义 IHttpDeclarativeExtractor 数组 + _extractors.TryAdd(httpRemoteOptions.HttpDeclarativeExtractors?.SelectMany(u => u.Invoke()).ToArray(), + value => value.GetType()); + } + + // 组合所有 HTTP 声明式提取器 + var extractors = _extractors.Values.Concat(_frozenExtractors.Values.OrderByDescending(e => e.Order)).ToArray(); + + // 遍历 HTTP 声明式提取器集合 + foreach (var extractor in extractors) + { + // 提取方法信息构建 HttpRequestBuilder 实例 + extractor.Extract(httpRequestBuilder, httpDeclarativeExtractorContext); + } + + return httpRequestBuilder; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/DeclarativeManager.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/DeclarativeManager.cs new file mode 100644 index 000000000..bc5bf1794 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/DeclarativeManager.cs @@ -0,0 +1,110 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式管理器 +/// +internal sealed class DeclarativeManager +{ + /// + internal readonly HttpDeclarativeBuilder _httpDeclarativeBuilder; + + /// + internal readonly IHttpRemoteService _httpRemoteService; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal DeclarativeManager(IHttpRemoteService httpRemoteService, HttpDeclarativeBuilder httpDeclarativeBuilder) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteService); + ArgumentNullException.ThrowIfNull(httpDeclarativeBuilder); + + _httpRemoteService = httpRemoteService; + _httpDeclarativeBuilder = httpDeclarativeBuilder; + + // 构建 HttpRequestBuilder 实例 + RequestBuilder = httpDeclarativeBuilder.Build(httpRemoteService.ServiceProvider + .GetRequiredService>().Value); + } + + /// + /// + /// + internal HttpRequestBuilder RequestBuilder { get; } + + /// + /// 开始请求 + /// + /// + /// + /// + internal object? Start() + { + // 尝试解析单个特殊类型参数 + var (completionOption, cancellationToken) = ExtractSingleSpecialArguments(_httpDeclarativeBuilder.Args); + + // 获取被调用方法返回值类型 + var method = _httpDeclarativeBuilder.Method; + var returnType = method.ReturnType == typeof(void) ? typeof(VoidContent) : method.ReturnType; + + // 发送 HTTP 远程请求 + return _httpRemoteService.SendAs(returnType, RequestBuilder, completionOption, cancellationToken); + } + + /// + /// 开始请求 + /// + /// 转换的目标类型 + /// + /// + /// + internal async Task StartAsync() + { + // 尝试解析单个特殊类型参数 + var (completionOption, cancellationToken) = ExtractSingleSpecialArguments(_httpDeclarativeBuilder.Args); + + // 发送 HTTP 远程请求 + return await _httpRemoteService.SendAsAsync(RequestBuilder, completionOption, cancellationToken).ConfigureAwait(false); + } + + /// + /// 尝试解析单个特殊类型参数 + /// + /// 被调用方法的参数值数组 + /// + /// + /// + internal static (HttpCompletionOption CompletionOption, CancellationToken CancellationToken) + ExtractSingleSpecialArguments(object?[] args) + { + // 尝试解析单个 HttpCompletionOption 参数 + var completionOption = args.SingleOrDefault(u => u is HttpCompletionOption) as HttpCompletionOption?; + + // 尝试解析单个 CancellationToken 参数 + var cancellationToken = args.SingleOrDefault(u => u is CancellationToken) as CancellationToken?; + + return (completionOption ?? HttpCompletionOption.ResponseContentRead, + cancellationToken ?? CancellationToken.None); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/AcceptLanguageDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/AcceptLanguageDeclarativeExtractor.cs new file mode 100644 index 000000000..2a976be25 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/AcceptLanguageDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class AcceptLanguageDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [AcceptLanguage] 特性 + if (!context.IsMethodDefined(out var acceptLanguageAttribute, true)) + { + return; + } + + // 设置客户端所偏好的自然语言和区域设置 + httpRequestBuilder.AcceptLanguage(acceptLanguageAttribute.Language); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/AutoSetHostHeaderDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/AutoSetHostHeaderDeclarativeExtractor.cs new file mode 100644 index 000000000..4e908a2eb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/AutoSetHostHeaderDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class AutoSetHostHeaderDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [AutoSetHostHeader] 特性 + if (!context.IsMethodDefined(out var autoSetHostHeaderAttribute, true)) + { + return; + } + + // 设置是否自动设置 Host 标头 + httpRequestBuilder.AutoSetHostHeader(autoSetHostHeaderAttribute.Enabled); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/BaseAddressDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/BaseAddressDeclarativeExtractor.cs new file mode 100644 index 000000000..569e19e59 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/BaseAddressDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class BaseAddressDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [BaseAddress] 特性 + if (!context.IsMethodDefined(out var baseAddressAttribute, true)) + { + return; + } + + // 设置请求基地址 + httpRequestBuilder.SetBaseAddress(baseAddressAttribute.BaseAddress); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/BodyDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/BodyDeclarativeExtractor.cs new file mode 100644 index 000000000..376765e4b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/BodyDeclarativeExtractor.cs @@ -0,0 +1,74 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.Mime; +using System.Reflection; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class BodyDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 查找单个贴有 [Body] 特性的参数 + var bodyParameter = + context.UnFrozenParameters.SingleOrDefault(u => u.Key.IsDefined(typeof(BodyAttribute), true)); + + // 解析参数信息 + var (parameter, value) = bodyParameter; + + // 空检查 + if (parameter is null) + { + return; + } + + // 获取 BodyAttribute 实例 + var bodyAttribute = parameter.GetCustomAttribute(true); + + // 空检查 + ArgumentNullException.ThrowIfNull(bodyAttribute); + + // 检查是否为原始字符串内容 + if (value is string stringValue && bodyAttribute.RawString) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(bodyAttribute.ContentType); + + // 设置原始字符串内容 + httpRequestBuilder.SetRawStringContent(stringValue, bodyAttribute.ContentType); + } + else + { + // 设置请求内容 + httpRequestBuilder.SetContent(value, bodyAttribute.ContentType); + } + + // 检查是否启用 StringContent 方式构建 application/x-www-form-urlencoded 请求内容 + if (httpRequestBuilder.ContentType.IsIn([MediaTypeNames.Application.FormUrlEncoded]) && + bodyAttribute.UseStringContent) + { + httpRequestBuilder.AddStringContentForFormUrlEncodedContentProcessor(); + } + + // 设置内容编码 + if (!string.IsNullOrWhiteSpace(bodyAttribute.ContentEncoding)) + { + httpRequestBuilder.SetContentEncoding(bodyAttribute.ContentEncoding); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/CookieDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/CookieDeclarativeExtractor.cs new file mode 100644 index 000000000..c1951de66 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/CookieDeclarativeExtractor.cs @@ -0,0 +1,106 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class CookieDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + /* 情况一:当特性作用于方法或接口时 */ + + // 获取 CookieAttribute 特性集合 + var cookieAttributes = context.GetMethodDefinedCustomAttributes(true, false)?.ToArray(); + + // 空检查 + if (cookieAttributes is { Length: > 0 }) + { + // 遍历所有 [Cookie] 特性并添加到 HttpRequestBuilder 中 + foreach (var cookieAttribute in cookieAttributes) + { + // 获取 Cookie 键 + var cookieName = cookieAttribute.Name; + + // 空检查 + ArgumentException.ThrowIfNullOrEmpty(cookieName); + + // 设置 Cookies + if (cookieAttribute.HasSetValue) + { + httpRequestBuilder.WithCookie(cookieName, cookieAttribute.Value, cookieAttribute.Escape); + } + // 移除 Cookies + else + { + httpRequestBuilder.RemoveCookies(cookieName); + } + } + } + + /* 情况二:当特性作用于参数时 */ + + // 查找所有贴有 [Cookie] 特性的参数集合 + var cookieParameters = context.UnFrozenParameters.Where(u => u.Key.IsDefined(typeof(CookieAttribute), true)) + .ToArray(); + + // 空检查 + if (cookieParameters.Length == 0) + { + return; + } + + // 遍历所有贴有 [Cookie] 特性的参数 + foreach (var (parameter, value) in cookieParameters) + { + // 获取 CookieAttribute 特性集合 + var parameterCookieAttributes = parameter.GetCustomAttributes(true); + + // 获取参数名 + var parameterName = AliasAsUtility.GetParameterName(parameter, out var aliasAsDefined); + + // 遍历所有 [Cookie] 特性并添加到 HttpRequestBuilder 中 + foreach (var cookieAttribute in parameterCookieAttributes) + { + // 检查参数是否贴了 [AliasAs] 特性 + if (!aliasAsDefined) + { + parameterName = string.IsNullOrWhiteSpace(cookieAttribute.AliasAs) + ? string.IsNullOrWhiteSpace(cookieAttribute.Name) ? parameterName : cookieAttribute.Name.Trim() + : cookieAttribute.AliasAs.Trim(); + } + + // 检查类型是否是基本类型或枚举类型或由它们组成的数组或集合类型 + if (parameter.ParameterType.IsBaseTypeOrEnumOrCollection()) + { + httpRequestBuilder.WithCookie(parameterName, value ?? cookieAttribute.Value, + cookieAttribute.Escape); + + continue; + } + + // 空检查 + if (value is not null) + { + httpRequestBuilder.WithCookies(value, cookieAttribute.Escape); + } + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/DisableCacheDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/DisableCacheDeclarativeExtractor.cs new file mode 100644 index 000000000..595c71840 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/DisableCacheDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class DisableCacheDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [DisableCache] 特性 + if (!context.IsMethodDefined(out var disableCacheAttribute, true)) + { + return; + } + + // 设置禁用 HTTP 缓存 + httpRequestBuilder.DisableCache(disableCacheAttribute.Disabled); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/EnsureSuccessStatusCodeDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/EnsureSuccessStatusCodeDeclarativeExtractor.cs new file mode 100644 index 000000000..dbc3c942c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/EnsureSuccessStatusCodeDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class EnsureSuccessStatusCodeDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [EnsureSuccessStatusCode] 特性 + if (!context.IsMethodDefined(out var ensureSuccessStatusCodeAttribute, true)) + { + return; + } + + // 设置是否如果 HTTP 响应的 IsSuccessStatusCode 属性是 false,则引发异常 + httpRequestBuilder.EnsureSuccessStatusCode(ensureSuccessStatusCodeAttribute.Enabled); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HeaderDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HeaderDeclarativeExtractor.cs new file mode 100644 index 000000000..afcad33af --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HeaderDeclarativeExtractor.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class HeaderDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + /* 情况一:当特性作用于方法或接口时 */ + + // 获取 HeaderAttribute 特性集合 + var headerAttributes = context.GetMethodDefinedCustomAttributes(true, false)?.ToArray(); + + // 空检查 + if (headerAttributes is { Length: > 0 }) + { + // 遍历所有 [Header] 特性并添加到 HttpRequestBuilder 中 + foreach (var headerAttribute in headerAttributes) + { + // 获取请求标头键 + var headerName = headerAttribute.Name; + + // 空检查 + ArgumentException.ThrowIfNullOrEmpty(headerName); + + // 设置请求标头 + if (headerAttribute.HasSetValue) + { + httpRequestBuilder.WithHeader(headerName, headerAttribute.Value, headerAttribute.Escape, + replace: headerAttribute.Replace); + } + // 移除请求标头 + else + { + httpRequestBuilder.RemoveHeaders(headerName); + } + } + } + + /* 情况二:当特性作用于参数时 */ + + // 查找所有贴有 [Header] 特性的参数集合 + var headerParameters = context.UnFrozenParameters.Where(u => u.Key.IsDefined(typeof(HeaderAttribute), true)) + .ToArray(); + + // 空检查 + if (headerParameters.Length == 0) + { + return; + } + + // 遍历所有贴有 [Header] 特性的参数 + foreach (var (parameter, value) in headerParameters) + { + // 获取 HeaderAttribute 特性集合 + var parameterHeaderAttributes = parameter.GetCustomAttributes(true); + + // 获取参数名 + var parameterName = AliasAsUtility.GetParameterName(parameter, out var aliasAsDefined); + + // 遍历所有 [Header] 特性并添加到 HttpRequestBuilder 中 + foreach (var headerAttribute in parameterHeaderAttributes) + { + // 检查参数是否贴了 [AliasAs] 特性 + if (!aliasAsDefined) + { + parameterName = string.IsNullOrWhiteSpace(headerAttribute.AliasAs) + ? string.IsNullOrWhiteSpace(headerAttribute.Name) ? parameterName : headerAttribute.Name.Trim() + : headerAttribute.AliasAs.Trim(); + } + + // 检查类型是否是基本类型或枚举类型或由它们组成的数组或集合类型 + if (parameter.ParameterType.IsBaseTypeOrEnumOrCollection()) + { + httpRequestBuilder.WithHeader(parameterName, value ?? headerAttribute.Value, + headerAttribute.Escape, replace: headerAttribute.Replace); + + continue; + } + + // 空检查 + if (value is not null) + { + httpRequestBuilder.WithHeaders(value, headerAttribute.Escape, replace: headerAttribute.Replace); + } + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HttpClientNameDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HttpClientNameDeclarativeExtractor.cs new file mode 100644 index 000000000..59c45d255 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HttpClientNameDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class HttpClientNameDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [HttpClientName] 特性 + if (!context.IsMethodDefined(out var httpClientNameAttribute, true)) + { + return; + } + + // 设置 HttpClient 实例的配置名称 + httpRequestBuilder.SetHttpClientName(httpClientNameAttribute.Name); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HttpMultipartFormDataBuilderDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HttpMultipartFormDataBuilderDeclarativeExtractor.cs new file mode 100644 index 000000000..7c25715ba --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HttpMultipartFormDataBuilderDeclarativeExtractor.cs @@ -0,0 +1,43 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 多部分表单内容配置提取器 +/// +internal sealed class HttpMultipartFormDataBuilderDeclarativeExtractor : IFrozenHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 尝试解析单个 Action 类型参数 + if (context.Args.SingleOrDefault(u => u is Action) is not + Action multipartFormDataBuilderAction) + { + return; + } + + // 处理和 [Multipart] 特性冲突问题 + if (httpRequestBuilder.MultipartFormDataBuilder is not null) + { + multipartFormDataBuilderAction.Invoke(httpRequestBuilder.MultipartFormDataBuilder); + } + else + { + // 设置多部分表单内容 + httpRequestBuilder.SetMultipartContent(multipartFormDataBuilderAction); + } + } + + /// + public int Order => 2; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HttpRequestBuilderDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HttpRequestBuilderDeclarativeExtractor.cs new file mode 100644 index 000000000..92bdca326 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/HttpRequestBuilderDeclarativeExtractor.cs @@ -0,0 +1,32 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 自定义配置提取器 +/// +internal sealed class HttpRequestBuilderDeclarativeExtractor : IFrozenHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 尝试解析单个 Action 类型参数 + if (context.Args.SingleOrDefault(u => u is Action) is Action + requestBuilderAction) + { + requestBuilderAction.Invoke(httpRequestBuilder); + } + } + + /// + public int Order => 1; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/IFrozenHttpDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/IFrozenHttpDeclarativeExtractor.cs new file mode 100644 index 000000000..ef66e4f36 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/IFrozenHttpDeclarativeExtractor.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式提取器排序(冻结) +/// +public interface IFrozenHttpDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + /// 获取提取器的顺序值。值越小,提取器越晚被调用 + /// + int Order { get; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/IHttpDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/IHttpDeclarativeExtractor.cs new file mode 100644 index 000000000..bdcd63533 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/IHttpDeclarativeExtractor.cs @@ -0,0 +1,29 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式提取器 +/// +public interface IHttpDeclarativeExtractor +{ + /// + /// 提取方法信息构建 实例 + /// + /// + /// + /// + /// + /// + /// + void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/MultipartDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/MultipartDeclarativeExtractor.cs new file mode 100644 index 000000000..e28539bdf --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/MultipartDeclarativeExtractor.cs @@ -0,0 +1,230 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Globalization; +using System.Net.Mime; +using System.Reflection; +using System.Text; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class MultipartDeclarativeExtractor : IFrozenHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 查找所有贴有 [Multipart] 特性的参数 + var multipartParameters = context.UnFrozenParameters + .Where(u => u.Key.IsDefined(typeof(MultipartAttribute), true)).ToArray(); + + // 空检查 + if (multipartParameters is { Length: 0 }) + { + return; + } + + // 初始化 HttpMultipartFormDataBuilder 实例 + var httpMultipartFormDataBuilder = new HttpMultipartFormDataBuilder(httpRequestBuilder); + + // 设置多部分表单 + SetMultipartFormData(context.Method, httpMultipartFormDataBuilder); + + // 遍历所有贴有 [Multipart] 特性的参数 + foreach (var (parameter, value) in multipartParameters) + { + // 添加多部分表单内容 + AddMultipart(parameter, value, httpMultipartFormDataBuilder); + } + + // 设置多部分表单内容 + httpRequestBuilder.SetMultipartContent(httpMultipartFormDataBuilder); + } + + /// + public int Order => 3; + + /// + /// 设置多部分表单 + /// + /// + /// + /// + /// + /// + /// + internal static void SetMultipartFormData(MethodInfo method, + HttpMultipartFormDataBuilder httpMultipartFormDataBuilder) + { + // 检查方法是否定义了 MultipartFormAttribute 特性 + if (!method.IsDefined(typeof(MultipartFormAttribute), true)) + { + return; + } + + // 获取 MultipartFormAttribute 实例 + var multipartFormAttribute = method.GetCustomAttribute(true)!; + + // 设置多部分表单内容的边界 + httpMultipartFormDataBuilder.SetBoundary(multipartFormAttribute.Boundary); + + // 设置是否移除默认的多部分内容的 Content-Type + httpMultipartFormDataBuilder.OmitContentType = multipartFormAttribute.OmitContentType; + } + + /// + /// 添加多部分表单内容 + /// + /// + /// + /// + /// 参数值 + /// + /// + /// + internal static void AddMultipart(ParameterInfo parameter, object? value, + HttpMultipartFormDataBuilder httpMultipartFormDataBuilder) + { + // 判断参数是否为冻结参数 + if (HttpDeclarativeExtractorContext.IsFrozenParameter(parameter)) + { + return; + } + + // 获取 MultipartAttribute 实例 + var multipartAttribute = parameter.GetCustomAttribute(true)!; + + // 获取表单名称 + var name = multipartAttribute.Name ?? parameter.Name!; + + // 获取内容编码 + var contentEncoding = multipartAttribute.ContentEncoding is null + ? null + : Encoding.GetEncoding(multipartAttribute.ContentEncoding); + + switch (value) + { + // 添加流 + case Stream stream: + httpMultipartFormDataBuilder.AddStream(stream, name, multipartAttribute.FileName, + multipartAttribute.ContentType ?? MediaTypeNames.Application.Octet, contentEncoding); + break; + // 添加字节数组 + case byte[] byteArray: + httpMultipartFormDataBuilder.AddByteArray(byteArray, name, multipartAttribute.FileName, + multipartAttribute.ContentType ?? MediaTypeNames.Application.Octet, contentEncoding); + break; + // 添加 MultipartFile + case MultipartFile multipartFile: + httpMultipartFormDataBuilder.AddFile(multipartFile); + break; + // 添加 HttpContent + case HttpContent httpContent: + httpMultipartFormDataBuilder.Add(httpContent, name, multipartAttribute.ContentType, + contentEncoding); + break; + // 添加文件 + case string fileSource when multipartAttribute.AsFileFrom is not FileSourceType.None: + AddFileFromSource(fileSource, name, multipartAttribute, httpMultipartFormDataBuilder, contentEncoding); + break; + // 添加单个表单项或对象 + default: + AddFormItemOrObject(value, name, parameter.ParameterType, multipartAttribute, + httpMultipartFormDataBuilder, contentEncoding); + break; + } + } + + /// + /// 添加文件 + /// + /// 文件的来源 + /// 表单名称 + /// + /// + /// + /// + /// + /// + /// 内容编码 + internal static void AddFileFromSource(string fileSource, string name, MultipartAttribute multipartAttribute, + HttpMultipartFormDataBuilder httpMultipartFormDataBuilder, Encoding? contentEncoding) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(fileSource, nameof(fileSource)); + + // 获取内容类型 + var contentType = multipartAttribute.ContentType ?? MediaTypeNames.Application.Octet; + + // 获取文件的名称 + var fileName = multipartAttribute.FileName; + + switch (multipartAttribute.AsFileFrom) + { + // 从本地文件路径中添加 + case FileSourceType.Path: + httpMultipartFormDataBuilder.AddFileAsStream(fileSource, name, fileName, contentType, contentEncoding); + break; + // 从 Base64 字符串文件中添加 + case FileSourceType.Base64String: + httpMultipartFormDataBuilder.AddFileFromBase64String(fileSource, name, fileName, contentType, + contentEncoding); + break; + // 从互联网文件地址中添加 + case FileSourceType.Remote: + httpMultipartFormDataBuilder.AddFileFromRemote(fileSource, name, fileName, contentType, + contentEncoding); + break; + // 不做任何操作 + case FileSourceType.None: + case FileSourceType.ByteArray: + case FileSourceType.Stream: + default: + break; + } + } + + /// + /// 添加单个表单项或对象 + /// + /// 参数的值 + /// 表单名称 + /// 参数类型 + /// + /// + /// + /// + /// + /// + /// 内容编码 + internal static void AddFormItemOrObject(object? value, string name, Type parameterType, + MultipartAttribute multipartAttribute, HttpMultipartFormDataBuilder httpMultipartFormDataBuilder, + Encoding? contentEncoding) + { + // 检查类型是否是基本类型或枚举类型或由它们组成的数组或集合类型 + if (parameterType.IsBaseTypeOrEnumOrCollection()) + { + // 添加单个表单项内容 + httpMultipartFormDataBuilder.AddFormItem(value.ToCultureString(CultureInfo.InvariantCulture), name, + contentEncoding); + } + // 添加原始内容 + else + { + httpMultipartFormDataBuilder.AddObject(value, multipartAttribute.AsFormItem ? name : null, + multipartAttribute.ContentType ?? MediaTypeNames.Text.Plain, contentEncoding); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/PathDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/PathDeclarativeExtractor.cs new file mode 100644 index 000000000..48d22588b --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/PathDeclarativeExtractor.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式路径参数提取器 +/// +internal sealed class PathDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + /* 情况一:当特性作用于方法或接口时 */ + + // 获取 PathAttribute 特性集合 + var pathAttributes = context.GetMethodDefinedCustomAttributes(true, false)?.ToArray(); + + // 空检查 + if (pathAttributes is { Length: > 0 }) + { + // 遍历所有 [Path] 特性并添加到 HttpRequestBuilder 中 + foreach (var pathAttribute in pathAttributes) + { + // 设置路径参数 + httpRequestBuilder.WithPathParameter(pathAttribute.Name, pathAttribute.Value); + } + } + + /* 情况二:将所有非冻结类型参数添加到路径参数中 */ + + // 遍历所有路径参数 + foreach (var (parameter, value) in context.UnFrozenParameters) + { + // 获取参数名 + var parameterName = AliasAsUtility.GetParameterName(parameter, out _); + + // 检查类型是否是基本类型或枚举类型或由它们组成的数组或集合类型 + if (parameter.ParameterType.IsBaseTypeOrEnumOrCollection()) + { + // 设置路径参数 + httpRequestBuilder.WithPathParameter(parameterName, value); + + continue; + } + + // 设置路径参数 + httpRequestBuilder.WithPathParameters(value, parameterName); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/PerformanceOptimizationDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/PerformanceOptimizationDeclarativeExtractor.cs new file mode 100644 index 000000000..97f060ae2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/PerformanceOptimizationDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class PerformanceOptimizationDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [PerformanceOptimization] 特性 + if (!context.IsMethodDefined(out var performanceOptimizationAttribute, true)) + { + return; + } + + // 设置是否启用性能优化 + httpRequestBuilder.PerformanceOptimization(performanceOptimizationAttribute.Enabled); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/ProfilerDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/ProfilerDeclarativeExtractor.cs new file mode 100644 index 000000000..91ac62f17 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/ProfilerDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class ProfilerDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [Profiler] 特性 + if (!context.IsMethodDefined(out var profilerAttribute, true)) + { + return; + } + + // 设置是否启用请求分析工具 + httpRequestBuilder.Profiler(profilerAttribute.Enabled); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/PropertyDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/PropertyDeclarativeExtractor.cs new file mode 100644 index 000000000..60107441a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/PropertyDeclarativeExtractor.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class PropertyDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + /* 情况一:当特性作用于方法或接口时 */ + + // 获取 PropertyAttribute 特性集合 + var propertyAttributes = context.GetMethodDefinedCustomAttributes(true, false)?.ToArray(); + + // 空检查 + if (propertyAttributes is { Length: > 0 }) + { + // 遍历所有 [Property] 特性并添加到 HttpRequestBuilder 中 + foreach (var propertyAttribute in propertyAttributes) + { + // 获取 HttpRequestMessage 请求属性键 + var propertyName = propertyAttribute.Name; + + // 空检查 + ArgumentException.ThrowIfNullOrEmpty(propertyName); + + // 设置 HttpRequestMessage 请求属性 + httpRequestBuilder.WithProperty(propertyName, propertyAttribute.Value); + } + } + + /* 情况二:当特性作用于参数时 */ + + // 查找所有贴有 [Property] 特性的参数集合 + var propertyParameters = context.UnFrozenParameters.Where(u => u.Key.IsDefined(typeof(PropertyAttribute), true)) + .ToArray(); + + // 空检查 + if (propertyParameters.Length == 0) + { + return; + } + + // 遍历所有贴有 [Property] 特性的参数 + foreach (var (parameter, value) in propertyParameters) + { + // 获取 PropertyAttribute 特性集合 + var parameterPropertyAttributes = parameter.GetCustomAttributes(true); + + // 获取参数名 + var parameterName = AliasAsUtility.GetParameterName(parameter, out var aliasAsDefined); + + // 遍历所有 [Property] 特性并添加到 HttpRequestBuilder 中 + foreach (var propertyAttribute in parameterPropertyAttributes) + { + // 检查参数是否贴了 [AliasAs] 特性 + if (!aliasAsDefined) + { + parameterName = string.IsNullOrWhiteSpace(propertyAttribute.AliasAs) + ? string.IsNullOrWhiteSpace(propertyAttribute.Name) + ? parameterName + : propertyAttribute.Name.Trim() + : propertyAttribute.AliasAs.Trim(); + } + + // 检查类型是否是基本类型或枚举类型或由它们组成的数组或集合类型或 AsItem 属性为真 + if (parameter.ParameterType.IsBaseTypeOrEnumOrCollection() || propertyAttribute.AsItem) + { + httpRequestBuilder.WithProperty(parameterName, value ?? propertyAttribute.Value); + + continue; + } + + // 空检查 + if (value is not null) + { + httpRequestBuilder.WithProperties(value); + } + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/QueryDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/QueryDeclarativeExtractor.cs new file mode 100644 index 000000000..a86842418 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/QueryDeclarativeExtractor.cs @@ -0,0 +1,109 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class QueryDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + /* 情况一:当特性作用于方法或接口时 */ + + // 获取 QueryAttribute 特性集合 + var queryAttributes = context.GetMethodDefinedCustomAttributes(true, false)?.ToArray(); + + // 空检查 + if (queryAttributes is { Length: > 0 }) + { + // 遍历所有 [Query] 特性并添加到 HttpRequestBuilder 中 + foreach (var queryAttribute in queryAttributes) + { + // 获取查询参数键 + var queryName = queryAttribute.Name; + + // 空检查 + ArgumentException.ThrowIfNullOrEmpty(queryName); + + // 设置查询参数 + if (queryAttribute.HasSetValue) + { + httpRequestBuilder.WithQueryParameter(queryName, queryAttribute.Value, queryAttribute.Escape, + replace: queryAttribute.Replace, ignoreNullValues: queryAttribute.IgnoreNullValues); + } + // 移除查询参数 + else + { + httpRequestBuilder.RemoveQueryParameters(queryName); + } + } + } + + /* 情况二:当特性作用于参数时 */ + + // 查找所有贴有 [Query] 特性的参数集合 + var queryParameters = context.UnFrozenParameters.Where(u => u.Key.IsDefined(typeof(QueryAttribute), true)) + .ToArray(); + + // 空检查 + if (queryParameters.Length == 0) + { + return; + } + + // 遍历所有贴有 [Query] 特性的参数 + foreach (var (parameter, value) in queryParameters) + { + // 获取 QueryAttribute 特性集合 + var parameterQueryAttributes = parameter.GetCustomAttributes(true); + + // 获取参数名 + var parameterName = AliasAsUtility.GetParameterName(parameter, out var aliasAsDefined); + + // 遍历所有 [Query] 特性并添加到 HttpRequestBuilder 中 + foreach (var queryAttribute in parameterQueryAttributes) + { + // 检查参数是否贴了 [AliasAs] 特性 + if (!aliasAsDefined) + { + parameterName = string.IsNullOrWhiteSpace(queryAttribute.AliasAs) + ? string.IsNullOrWhiteSpace(queryAttribute.Name) ? parameterName : queryAttribute.Name.Trim() + : queryAttribute.AliasAs.Trim(); + } + + // 检查类型是否是基本类型或枚举类型或由它们组成的数组或集合类型 + if (parameter.ParameterType.IsBaseTypeOrEnumOrCollection()) + { + httpRequestBuilder.WithQueryParameter(parameterName, value ?? queryAttribute.Value, + queryAttribute.Escape, replace: queryAttribute.Replace, + ignoreNullValues: queryAttribute.IgnoreNullValues); + + continue; + } + + // 空检查 + if (value is not null) + { + httpRequestBuilder.WithQueryParameters(value, queryAttribute.Prefix, queryAttribute.Escape, + replace: queryAttribute.Replace, ignoreNullValues: queryAttribute.IgnoreNullValues); + } + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/SimulateBrowserDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/SimulateBrowserDeclarativeExtractor.cs new file mode 100644 index 000000000..c7b84ac29 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/SimulateBrowserDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class SimulateBrowserDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [SimulateBrowser] 特性 + if (!context.IsMethodDefined(out var simulateBrowserAttribute, true)) + { + return; + } + + // 设置模拟浏览器环境 + httpRequestBuilder.SimulateBrowser(simulateBrowserAttribute.Mobile); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/TimeoutDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/TimeoutDeclarativeExtractor.cs new file mode 100644 index 000000000..cb35972c7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/TimeoutDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class TimeoutDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [Timeout] 特性 + if (!context.IsMethodDefined(out var timeoutAttribute, true)) + { + return; + } + + // 设置超时时间 + httpRequestBuilder.SetTimeout(timeoutAttribute.Timeout); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/TraceIdentifierDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/TraceIdentifierDeclarativeExtractor.cs new file mode 100644 index 000000000..ddb28c6b7 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/TraceIdentifierDeclarativeExtractor.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class TraceIdentifierDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 检查方法或接口是否贴有 [TraceIdentifier] 特性 + if (!context.IsMethodDefined(out var traceIdentifierAttribute, true)) + { + return; + } + + // 设置跟踪标识 + httpRequestBuilder.SetTraceIdentifier(traceIdentifierAttribute.Identifier); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/ValidationDeclarativeExtractor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/ValidationDeclarativeExtractor.cs new file mode 100644 index 000000000..78968e975 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/Extractors/ValidationDeclarativeExtractor.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; +using System.Reflection; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式 特性提取器 +/// +internal sealed class ValidationDeclarativeExtractor : IHttpDeclarativeExtractor +{ + /// + public void Extract(HttpRequestBuilder httpRequestBuilder, HttpDeclarativeExtractorContext context) + { + // 遍历所有非冻结类型参数并进行验证操作 + foreach (var (parameter, value) in context.UnFrozenParameters) + { + ValidateParameter(parameter, value); + } + } + + /// + /// 验证参数 + /// + /// + /// + /// + /// 参数的值 + /// + internal static void ValidateParameter(ParameterInfo parameter, object? value) + { + // 空检查 + ArgumentNullException.ThrowIfNull(parameter); + + // 获取参数名和参数类型 + var parameterName = parameter.Name!; + var parameterType = parameter.ParameterType; + + // 空检查 + if (value is null) + { + // 优先验证 [Required] 特性 + if (parameter.IsDefined(typeof(RequiredAttribute), true)) + { + throw new ValidationException(parameter.GetCustomAttribute(true) + ?.FormatErrorMessage(parameterName)); + } + + return; + } + + // 检查类型是否是基本类型或枚举类型或由它们组成的数组或集合类型 + if (parameterType.IsBaseTypeOrEnumOrCollection()) + { + // 检查参数是否贴有验证特性 + if (!parameter.IsDefined(typeof(ValidationAttribute), true)) + { + return; + } + + // 验证单个值类型 + Validator.ValidateValue(value, new ValidationContext(value) { MemberName = parameterName }, + parameter.GetCustomAttributes(true)); + } + else + { + // 验证复杂对象类型 + Validator.ValidateObject(value, new ValidationContext(value), true); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/HttpDeclarativeDispatchProxy.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/HttpDeclarativeDispatchProxy.cs new file mode 100644 index 000000000..deda52df4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/HttpDeclarativeDispatchProxy.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式远程请求代理类 +/// +public class HttpDeclarativeDispatchProxy : DispatchProxyAsync +{ + /// + public IHttpRemoteService RemoteService { get; internal set; } = null!; + + /// + public override object Invoke(MethodInfo method, object[] args) => RemoteService.Declarative(method, args)!; + + /// + public override async Task InvokeAsync(MethodInfo method, object[] args) => + _ = await InvokeAsyncT(method, args).ConfigureAwait(false); + + /// + public override async Task InvokeAsyncT(MethodInfo method, object[] args) => + (await RemoteService.DeclarativeAsync(method, args).ConfigureAwait(false))!; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/HttpDeclarativeExtractorContext.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/HttpDeclarativeExtractorContext.cs new file mode 100644 index 000000000..72bc226a4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/HttpDeclarativeExtractorContext.cs @@ -0,0 +1,126 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式提取器上下文 +/// +public sealed class HttpDeclarativeExtractorContext +{ + /// + /// 冻结参数类型集合 + /// + /// 此类参数类型不应作为外部提取对象。 + internal static Type[] _frozenParameterTypes = + [ + typeof(Action), typeof(Action), typeof(HttpCompletionOption), + typeof(CancellationToken) + ]; + + /// + /// + /// + /// 被调用方法 + /// 被调用方法的参数值数组 + internal HttpDeclarativeExtractorContext(MethodInfo method, object?[] args) + { + // 空检查 + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(args); + + Method = method; + Args = args; + + // 初始化被调用方法的参数键值字典 + Parameters = method.GetParameters().Select((p, i) => new { Parameter = p, Value = args[i] }) + .ToDictionary(u => u.Parameter, u => u.Value).AsReadOnly(); + + // 初始化被调用方法的非冻结类型参数键值字典 + UnFrozenParameters = Parameters.Where(u => !IsFrozenParameter(u.Key)).ToDictionary(u => u.Key, u => u.Value) + .AsReadOnly(); + } + + /// + /// 被调用方法 + /// + public MethodInfo Method { get; } + + /// + /// 被调用方法的参数值数组 + /// + public object?[] Args { get; } + + /// + /// 被调用方法的参数键值字典 + /// + public IReadOnlyDictionary Parameters { get; } + + /// + /// 被调用方法的非冻结类型参数键值字典 + /// + public IReadOnlyDictionary UnFrozenParameters { get; } + + /// + /// 判断参数是否为冻结参数 + /// + /// 此类参数不应作为外部提取对象。 + /// + /// + /// + /// + /// + /// + public static bool IsFrozenParameter(ParameterInfo parameter) + { + // 空检查 + ArgumentNullException.ThrowIfNull(parameter); + + return _frozenParameterTypes.Contains(parameter.ParameterType); + } + + /// + /// 检查被调用方法是否定义了指定特性 + /// + /// + /// + /// + /// 是否在基类中搜索 + /// + /// + /// + /// + /// + /// + public bool IsMethodDefined([NotNullWhen(true)] out TAttribute? attribute, bool inherit = false) + where TAttribute : Attribute => + Method.IsDefined(out attribute, inherit); + + /// + /// 获取被调用方法指定特性的所有实例 + /// + /// 是否在基类中搜索 + /// 是否优先查找 的特性。默认值为:true。 + /// + /// + /// + /// + /// [] + /// + public TAttribute[]? GetMethodDefinedCustomAttributes(bool inherit = false, bool methodScanFirst = true) + where TAttribute : Attribute => + Method.GetDefinedCustomAttributes(inherit, methodScanFirst); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/IHttpDeclarative.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/IHttpDeclarative.cs new file mode 100644 index 000000000..5f8428b9e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Declarative/IHttpDeclarative.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 声明式远程请求依赖接口 +/// +public interface IHttpDeclarative; \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Delegates/ProfilerDelegatingHandler.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Delegates/ProfilerDelegatingHandler.cs new file mode 100644 index 000000000..baa0f9366 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Delegates/ProfilerDelegatingHandler.cs @@ -0,0 +1,266 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Http.Logging; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using System.Diagnostics; +using System.Net; + +using ThingsGateway.HttpRemote.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求分析工具处理委托 +/// +/// 参考文献:https://learn.microsoft.com/zh-cn/aspnet/core/fundamentals/http-requests?view=aspnetcore-8.0#outgoing-request-middleware +/// +/// +/// +/// +/// +/// +public sealed class ProfilerDelegatingHandler(ILogger logger, IOptions httpRemoteOptions) + : DelegatingHandler +{ + /// + /// 是否启用请求分析工具 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsEnabled(HttpRequestMessage httpRequestMessage) => + !(httpRequestMessage.Options.TryGetValue(new HttpRequestOptionsKey(Constants.DISABLED_PROFILER_KEY), + out var value) && value == "TRUE"); + + /// + protected override HttpResponseMessage Send(HttpRequestMessage httpRequestMessage, + CancellationToken cancellationToken) + { + // 检查是否启用请求分析工具 + if (!IsEnabled(httpRequestMessage)) + { + return base.Send(httpRequestMessage, cancellationToken); + } + + // 记录请求信息 + LogRequestAsync(logger, httpRemoteOptions.Value, httpRequestMessage, cancellationToken) + .GetAwaiter().GetResult(); + + // 初始化 Stopwatch 实例并开启计时操作 + var stopwatch = Stopwatch.StartNew(); + + // 发送 HTTP 远程请求 + var httpResponseMessage = base.Send(httpRequestMessage, cancellationToken); + + // 获取请求耗时 + var requestDuration = stopwatch.ElapsedMilliseconds; + + // 停止计时 + stopwatch.Stop(); + + // 记录响应信息 + LogResponseAsync(logger, httpRemoteOptions.Value, httpResponseMessage, requestDuration, cancellationToken) + .GetAwaiter().GetResult(); + + // 打印 CookieContainer 内容 + LogCookieContainer(logger, httpRemoteOptions.Value, httpRequestMessage, ExtractCookieContainer()); + + return httpResponseMessage; + } + + /// + protected override async Task SendAsync(HttpRequestMessage httpRequestMessage, + CancellationToken cancellationToken) + { + // 检查是否启用请求分析工具 + if (!IsEnabled(httpRequestMessage)) + { + return await base.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); + } + + // 记录请求信息 + await LogRequestAsync(logger, httpRemoteOptions.Value, httpRequestMessage, cancellationToken).ConfigureAwait(false); + + // 初始化 Stopwatch 实例并开启计时操作 + var stopwatch = Stopwatch.StartNew(); + + // 发送 HTTP 远程请求 + var httpResponseMessage = await base.SendAsync(httpRequestMessage, cancellationToken).ConfigureAwait(false); + + // 获取请求耗时 + var requestDuration = stopwatch.ElapsedMilliseconds; + + // 停止计时 + stopwatch.Stop(); + + // 记录响应信息 + await LogResponseAsync(logger, httpRemoteOptions.Value, httpResponseMessage, requestDuration, + cancellationToken).ConfigureAwait(false); + + // 打印 CookieContainer 内容 + LogCookieContainer(logger, httpRemoteOptions.Value, httpRequestMessage, ExtractCookieContainer()); + + return httpResponseMessage; + } + + /// + /// 记录请求信息 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static async Task LogRequestAsync(ILogger logger, HttpRemoteOptions remoteOptions, + HttpRequestMessage request, CancellationToken cancellationToken = default) + { + Log(logger, remoteOptions, request.ProfilerHeaders()); + Log(logger, remoteOptions, await request.Content.ProfilerAsync(cancellationToken: cancellationToken).ConfigureAwait(false)); + } + + /// + /// 记录响应信息 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 请求耗时(毫秒) + /// + /// + /// + internal static async Task LogResponseAsync(ILogger logger, HttpRemoteOptions remoteOptions, + HttpResponseMessage httpResponseMessage, long requestDuration, CancellationToken cancellationToken = default) + { + Log(logger, remoteOptions, + httpResponseMessage.ProfilerGeneralAndHeaders(generalCustomKeyValues: + [new KeyValuePair>("Request Duration (ms)", [$"{requestDuration:N2}"])])); + Log(logger, remoteOptions, await httpResponseMessage.Content.ProfilerAsync("Response Body", cancellationToken).ConfigureAwait(false)); + } + + /// + /// 打印 内容 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static void LogCookieContainer(ILogger logger, HttpRemoteOptions remoteOptions, HttpRequestMessage request, + CookieContainer? cookieContainer) + { + // 空检查 + if (request.RequestUri is null || cookieContainer is null) + { + return; + } + + // 获取 Cookie 集合 + var cookies = cookieContainer.GetCookies(request.RequestUri); + + // 空检查 + if (cookies is { Count: 0 }) + { + return; + } + + // 打印日志 + Log(logger, remoteOptions, + StringUtility.FormatKeyValuesSummary( + cookies.ToDictionary(u => u.Name, u => Enumerable.Empty().Concat([u.Value])), + "Cookie Container")); + } + + /// + /// 打印日志 + /// + /// + /// + /// + /// + /// + /// + /// 日志消息 + internal static void Log(ILogger logger, HttpRemoteOptions remoteOptions, string? message) + { + // 空检查 + ArgumentNullException.ThrowIfNull(logger); + + // 空检查 + if (string.IsNullOrWhiteSpace(message)) + { + return; + } + + // 检查是否配置(注册)了日志程序 + if (remoteOptions.IsLoggingRegistered) + { + logger.Log(remoteOptions.ProfilerLogLevel, "{message}", message); + } + else + { + Console.WriteLine(message); + } + } + + /// + /// 提取 实例 + /// + /// + /// + /// + internal CookieContainer? ExtractCookieContainer() => + InnerHandler switch + { + LoggingHttpMessageHandler loggingHttpMessageHandler => loggingHttpMessageHandler.InnerHandler switch + { + SocketsHttpHandler socketsHttpHandler => socketsHttpHandler.CookieContainer, + HttpClientHandler httpClientHandler => httpClientHandler.CookieContainer, + _ => null + }, + LoggingScopeHttpMessageHandler loggingScopeHttpMessageHandler => loggingScopeHttpMessageHandler.InnerHandler + switch + { + SocketsHttpHandler socketsHttpHandler => socketsHttpHandler.CookieContainer, + HttpClientHandler httpClientHandler => httpClientHandler.CookieContainer, + _ => null + }, + SocketsHttpHandler socketsHttpHandler => socketsHttpHandler.CookieContainer, + HttpClientHandler httpClientHandler => httpClientHandler.CookieContainer, + _ => null + }; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpContextExtensions.ForwardAs.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpContextExtensions.ForwardAs.cs new file mode 100644 index 000000000..a40a14816 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpContextExtensions.ForwardAs.cs @@ -0,0 +1,1283 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +namespace ThingsGateway.HttpRemote.Extensions; + +/// +/// 拓展类 +/// +public static partial class HttpContextExtensions +{ + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static TResult? ForwardAs(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, + Helpers.ParseHttpMethod(httpContext?.Request.Method), + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static TResult? ForwardAs(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static TResult? ForwardAs(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, + Helpers.ParseHttpMethod(httpContext?.Request.Method), requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static TResult? ForwardAs(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(httpMethod); + + // 初始化转发所需的服务 + var (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService) = + PrepareForwardService(httpContext, httpMethod, requestUri, configure, forwardOptions); + + // 获取 IHttpContentConverterFactory 实例 + var httpContentConverterFactory = + httpContext.RequestServices.GetRequiredService(); + + // 发送 HTTP 远程请求 + var httpResponseMessage = + httpRemoteService.Send(httpRequestBuilder, completionOption, httpContext.RequestAborted); + + // 根据配置选项将 HttpResponseMessage 信息转发到 HttpContext 中 + ForwardResponseMessage(httpContext, httpResponseMessage, httpContextForwardBuilder.ForwardOptions); + + // 将 HttpResponseMessage 转换为 TResult 实例 + return httpContentConverterFactory.Read(httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + httpContext.RequestAborted); + } + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static Task ForwardAsAsync(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static Task ForwardAsAsync(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static Task ForwardAsAsync(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static async Task ForwardAsAsync(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(httpMethod); + + // 初始化转发所需的服务 + var (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService) = + await PrepareForwardServiceAsync(httpContext, httpMethod, requestUri, configure, forwardOptions).ConfigureAwait(false); + + // 获取 IHttpContentConverterFactory 实例 + var httpContentConverterFactory = + httpContext.RequestServices.GetRequiredService(); + + // 发送 HTTP 远程请求 + var httpResponseMessage = + await httpRemoteService.SendAsync(httpRequestBuilder, completionOption, httpContext.RequestAborted).ConfigureAwait(false); + + // 根据配置选项将 HttpResponseMessage 信息转发到 HttpContext 中 + ForwardResponseMessage(httpContext, httpResponseMessage, httpContextForwardBuilder.ForwardOptions); + + // 将 HttpResponseMessage 转换为 TResult 实例 + return await httpContentConverterFactory.ReadAsync(httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + httpContext.RequestAborted).ConfigureAwait(false); + } + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static string? ForwardAsString(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static string? ForwardAsString(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, httpMethod, requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static string? ForwardAsString(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static string? ForwardAsString(this HttpContext? httpContext, HttpMethod httpMethod, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsStringAsync(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsStringAsync(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsStringAsync(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsStringAsync(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + public static byte[]? ForwardAsByteArray(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + public static byte[]? ForwardAsByteArray(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, httpMethod, requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + public static byte[]? ForwardAsByteArray(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + public static byte[]? ForwardAsByteArray(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + public static Task ForwardAsByteArrayAsync(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + public static Task ForwardAsByteArrayAsync(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + public static Task ForwardAsByteArrayAsync(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + public static Task ForwardAsByteArrayAsync(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Stream? ForwardAsStream(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Stream? ForwardAsStream(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, httpMethod, requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Stream? ForwardAsStream(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Stream? ForwardAsStream(this HttpContext? httpContext, HttpMethod httpMethod, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsStreamAsync(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsStreamAsync(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsStreamAsync(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsStreamAsync(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static IActionResult? ForwardAsResult(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static IActionResult? ForwardAsResult(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, httpMethod, + requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static IActionResult? ForwardAsResult(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static IActionResult? ForwardAsResult(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAs(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsResultAsync(this HttpContext? httpContext, + string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsResultAsync(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsResultAsync(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsResultAsync(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, httpMethod, requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转换的目标类型 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static object? ForwardAs(this HttpContext? httpContext, Type resultType, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, resultType, + Helpers.ParseHttpMethod(httpContext?.Request.Method), + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// 转换的目标类型 + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static object? ForwardAs(this HttpContext? httpContext, Type resultType, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, resultType, httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转换的目标类型 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static object? ForwardAs(this HttpContext? httpContext, Type resultType, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => ForwardAs(httpContext, resultType, + Helpers.ParseHttpMethod(httpContext?.Request.Method), requestUri, configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转换的目标类型 + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static object? ForwardAs(this HttpContext? httpContext, Type resultType, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(httpMethod); + + // 初始化转发所需的服务 + var (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService) = + PrepareForwardService(httpContext, httpMethod, requestUri, configure, forwardOptions); + + // 获取 IHttpContentConverterFactory 实例 + var httpContentConverterFactory = + httpContext.RequestServices.GetRequiredService(); + + // 发送 HTTP 远程请求 + var httpResponseMessage = + httpRemoteService.Send(httpRequestBuilder, completionOption, httpContext.RequestAborted); + + // 根据配置选项将 HttpResponseMessage 信息转发到 HttpContext 中 + ForwardResponseMessage(httpContext, httpResponseMessage, httpContextForwardBuilder.ForwardOptions); + + // 将 HttpResponseMessage 转换为 TResult 实例 + return httpContentConverterFactory.Read(resultType, httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + httpContext.RequestAborted); + } + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转换的目标类型 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsAsync(this HttpContext? httpContext, Type resultType, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, resultType, Helpers.ParseHttpMethod(httpContext?.Request.Method), + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转换的目标类型 + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsAsync(this HttpContext? httpContext, Type resultType, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, resultType, httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转换的目标类型 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsAsync(this HttpContext? httpContext, Type resultType, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsAsync(httpContext, resultType, Helpers.ParseHttpMethod(httpContext?.Request.Method), requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转换的目标类型 + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task ForwardAsAsync(this HttpContext? httpContext, Type resultType, + HttpMethod httpMethod, Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(httpMethod); + + // 初始化转发所需的服务 + var (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService) = + await PrepareForwardServiceAsync(httpContext, httpMethod, requestUri, configure, forwardOptions).ConfigureAwait(false); + + // 获取 IHttpContentConverterFactory 实例 + var httpContentConverterFactory = + httpContext.RequestServices.GetRequiredService(); + + // 发送 HTTP 远程请求 + var httpResponseMessage = + await httpRemoteService.SendAsync(httpRequestBuilder, completionOption, httpContext.RequestAborted).ConfigureAwait(false); + + // 根据配置选项将 HttpResponseMessage 信息转发到 HttpContext 中 + ForwardResponseMessage(httpContext, httpResponseMessage, httpContextForwardBuilder.ForwardOptions); + + // 将 HttpResponseMessage 转换为 TResult 实例 + return await httpContentConverterFactory.ReadAsync(resultType, httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + httpContext.RequestAborted).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpContextExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpContextExtensions.cs new file mode 100644 index 000000000..8c708a0da --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpContextExtensions.cs @@ -0,0 +1,738 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +using System.Net.Http.Headers; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote.Extensions; + +/// +/// 拓展类 +/// +public static partial class HttpContextExtensions +{ + /// + /// 忽略在转发时需要跳过的响应标头列表 + /// + /// + /// + /// + /// Content-Type: + /// + /// 非标准的 Content-Type 值(例如 text/plain; charset=utf-8 + /// )可能会导致“No output formatter was found for content types 'text/plain; charset=utf-8, text/plain; + /// charset=utf-8' to write the response.”错误。忽略此标头以防止此类错误。 + /// + /// + /// + /// Transfer-Encoding: + /// 当响应标头包含 Transfer-Encoding: chunked 时,可能导致响应处理过程无限期挂起。忽略此标头可避免该问题。 + /// + /// + /// + internal static HashSet _ignoreResponseHeaders = + [ + "Content-Type", "Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection" + ]; + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static HttpResponseMessage Forward(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + Forward(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static HttpResponseMessage Forward(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + Forward(httpContext, httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static HttpResponseMessage Forward(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + Forward(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static HttpResponseMessage Forward(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(httpMethod); + + // 初始化转发所需的服务 + var (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService) = + PrepareForwardService(httpContext, httpMethod, requestUri, configure, forwardOptions); + + // 发送 HTTP 远程请求 + var httpResponseMessage = + httpRemoteService.Send(httpRequestBuilder, completionOption, httpContext.RequestAborted); + + // 根据配置选项将 HttpResponseMessage 信息转发到 HttpContext 中 + ForwardResponseMessage(httpContext, httpResponseMessage, httpContextForwardBuilder.ForwardOptions); + + return httpResponseMessage; + } + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsync(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsync(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsync(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsync(httpContext, httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static Task ForwardAsync(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsync(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task ForwardAsync(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(httpMethod); + + // 初始化转发所需的服务 + var (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService) = + await PrepareForwardServiceAsync(httpContext, httpMethod, requestUri, configure, forwardOptions).ConfigureAwait(false); + + // 发送 HTTP 远程请求 + var httpResponseMessage = await httpRemoteService.SendAsync(httpRequestBuilder, completionOption, + httpContext.RequestAborted).ConfigureAwait(false); + + // 根据配置选项将 HttpResponseMessage 信息转发到 HttpContext 中 + ForwardResponseMessage(httpContext, httpResponseMessage, httpContextForwardBuilder.ForwardOptions); + + return httpResponseMessage; + } + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static HttpRemoteResult Forward(this HttpContext? httpContext, string? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + Forward(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static HttpRemoteResult Forward(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + Forward(httpContext, httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static HttpRemoteResult Forward(this HttpContext? httpContext, Uri? requestUri = null, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + Forward(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static HttpRemoteResult Forward(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(httpMethod); + + // 初始化转发所需的服务 + var (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService) = + PrepareForwardService(httpContext, httpMethod, requestUri, configure, forwardOptions); + + // 发送 HTTP 远程请求 + var result = httpRemoteService.Send(httpRequestBuilder, completionOption, httpContext.RequestAborted); + + // 根据配置选项将 HttpResponseMessage 信息转发到 HttpContext 中 + ForwardResponseMessage(httpContext, result.ResponseMessage, httpContextForwardBuilder.ForwardOptions); + + return result; + } + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static Task> ForwardAsync(this HttpContext? httpContext, + string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsync(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static Task> ForwardAsync(this HttpContext? httpContext, + HttpMethod httpMethod, string? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsync(httpContext, httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), configure, + completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static Task> ForwardAsync(this HttpContext? httpContext, + Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) => + ForwardAsync(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), requestUri, + configure, completionOption, forwardOptions); + + /// + /// 转发 到新的 HTTP 远程地址 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + public static async Task> ForwardAsync(this HttpContext? httpContext, + HttpMethod httpMethod, Uri? requestUri = null, Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + HttpContextForwardOptions? forwardOptions = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(httpMethod); + + // 初始化转发所需的服务 + var (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService) = + await PrepareForwardServiceAsync(httpContext, httpMethod, requestUri, configure, forwardOptions).ConfigureAwait(false); + + // 发送 HTTP 远程请求 + var result = await httpRemoteService.SendAsync(httpRequestBuilder, completionOption, + httpContext.RequestAborted).ConfigureAwait(false); + + // 根据配置选项将 HttpResponseMessage 信息转发到 HttpContext 中 + ForwardResponseMessage(httpContext, result.ResponseMessage, httpContextForwardBuilder.ForwardOptions); + + return result; + } + + /// + /// 创建 实例 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// + /// + /// + /// + /// + /// + public static HttpContextForwardBuilder CreateForwardBuilder(this HttpContext? httpContext, HttpMethod httpMethod, + string? requestUri = null, HttpContextForwardOptions? forwardOptions = null) => + CreateForwardBuilder(httpContext, httpMethod, + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), + forwardOptions); + + /// + /// 创建 实例 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// + /// + /// + /// + /// + /// + public static HttpContextForwardBuilder CreateForwardBuilder(this HttpContext? httpContext, + string? requestUri = null, + HttpContextForwardOptions? forwardOptions = null) => + CreateForwardBuilder(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), + string.IsNullOrWhiteSpace(requestUri) ? null : new Uri(requestUri, UriKind.RelativeOrAbsolute), + forwardOptions); + + /// + /// 创建 实例 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// + /// + /// + /// + /// + /// + public static HttpContextForwardBuilder CreateForwardBuilder(this HttpContext? httpContext, HttpMethod httpMethod, + Uri? requestUri = null, HttpContextForwardOptions? forwardOptions = null) => + new(httpContext, httpMethod, requestUri, forwardOptions); + + /// + /// 创建 实例 + /// + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// + /// + /// + /// + /// + /// + public static HttpContextForwardBuilder CreateForwardBuilder(this HttpContext? httpContext, Uri? requestUri = null, + HttpContextForwardOptions? forwardOptions = null) => + new(httpContext, Helpers.ParseHttpMethod(httpContext?.Request.Method), requestUri, forwardOptions); + + /// + /// 根据配置选项将 信息转发到 中 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static void ForwardResponseMessage(HttpContext httpContext, HttpResponseMessage httpResponseMessage, + HttpContextForwardOptions forwardOptions) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpContext); + ArgumentNullException.ThrowIfNull(httpResponseMessage); + ArgumentNullException.ThrowIfNull(forwardOptions); + + // 获取 HttpResponse 实例 + var httpResponse = httpContext.Response; + + // 检查是否配置了响应状态码转发 + if (forwardOptions.WithResponseStatusCode) + { + httpResponse.StatusCode = (int)httpResponseMessage.StatusCode; + } + + // 检查是否配置了响应标头转发 + if (forwardOptions.WithResponseHeaders) + { + ForwardHttpHeaders(httpResponse, httpResponseMessage.Headers, forwardOptions); + } + + // 检查是否配置了响应内容标头转发 + if (forwardOptions.WithResponseContentHeaders) + { + ForwardHttpHeaders(httpResponse, httpResponseMessage.Content.Headers, forwardOptions); + } + + // 调用用于在转发响应之前执行自定义操作 + forwardOptions.OnForward?.Invoke(httpContext, httpResponseMessage); + } + + /// + /// 转发 HTTP 标头 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static void ForwardHttpHeaders(HttpResponse httpResponse, HttpHeaders httpHeaders, + HttpContextForwardOptions forwardOptions) + { + // 初始化忽略在转发时需要跳过的响应标头列表 + var ignoreResponseHeaders = + _ignoreResponseHeaders.ConcatIgnoreNull(forwardOptions.IgnoreResponseHeaders).Distinct().ToArray(); + + // 逐条更新响应标头 + foreach (var (key, values) in httpHeaders) + { + // 忽略特定响应标头 + if (key.IsIn(ignoreResponseHeaders, StringComparer.OrdinalIgnoreCase)) + { + continue; + } + + httpResponse.Headers[key] = values.ToArray(); + } + } + + /// + /// 初始化转发所需的服务 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + internal static (HttpContextForwardBuilder httpContextForwardBuilder, HttpRequestBuilder httpRequestBuilder, + IHttpRemoteService httpRemoteService) PrepareForwardService(HttpContext httpContext, HttpMethod httpMethod, + Uri? requestUri, Action? configure = null, + HttpContextForwardOptions? forwardOptions = null) + { + // 创建 HttpContextForwardBuilder 实例 + var httpContextForwardBuilder = CreateForwardBuilder(httpContext, httpMethod, requestUri, forwardOptions); + + // 构建 HttpRequestBuilder 实例 + var httpRequestBuilder = httpContextForwardBuilder.Build(configure); + + // 获取 IHttpRemoteService 实例 + var httpRemoteService = httpContext.RequestServices.GetRequiredService(); + + return (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService); + } + + /// + /// 初始化转发所需的服务 + /// + /// + /// + /// + /// 转发方式 + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + internal static async + Task<(HttpContextForwardBuilder httpContextForwardBuilder, HttpRequestBuilder httpRequestBuilder, + IHttpRemoteService + httpRemoteService)> PrepareForwardServiceAsync(HttpContext httpContext, HttpMethod httpMethod, + Uri? requestUri, + Action? configure = null, HttpContextForwardOptions? forwardOptions = null) + { + // 创建 HttpContextForwardBuilder 实例 + var httpContextForwardBuilder = CreateForwardBuilder(httpContext, httpMethod, requestUri, forwardOptions); + + // 构建 HttpRequestBuilder 实例 + var httpRequestBuilder = await httpContextForwardBuilder.BuildAsync(configure).ConfigureAwait(false); + + // 获取 IHttpRemoteService 实例 + var httpRemoteService = httpContext.RequestServices.GetRequiredService(); + + return (httpContextForwardBuilder, httpRequestBuilder, httpRemoteService); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpRemoteExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpRemoteExtensions.cs new file mode 100644 index 000000000..01a7d855c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpRemoteExtensions.cs @@ -0,0 +1,200 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +using System.Net.Http.Headers; +using System.Text; + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote.Extensions; + +/// +/// HTTP 远程服务拓展类 +/// +public static class HttpRemoteExtensions +{ + /// + /// 添加 HTTP 远程请求分析工具处理委托 + /// + /// + /// + /// + /// 自定义禁用配置委托 + /// + /// + /// + public static IHttpClientBuilder AddProfilerDelegatingHandler(this IHttpClientBuilder builder, + Func? disableConfigure = null) + { + // 获取 IServiceCollection 实例 + var services = builder.Services; + + // 注册请求分析工具服务 + services.TryAddTransient(); + + // 检查自定义禁用配置委托 + return disableConfigure?.Invoke() == true + ? builder + : builder.AddHttpMessageHandler(); + } + + /// + /// 为 启用性能优化 + /// + /// + /// + /// + public static void PerformanceOptimization(this HttpClient httpClient) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpClient); + + // 设置 Accept 头,表示可以接受任何类型的内容 + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*")); + + // 添加 Accept-Encoding 头,支持 gzip、deflate 以及 Brotli 压缩算法 + // 这样服务器可以根据情况选择最合适的压缩方式发送响应,从而减少传输的数据量 + httpClient.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("gzip")); + httpClient.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("deflate")); + httpClient.DefaultRequestHeaders.AcceptEncoding.Add(new StringWithQualityHeaderValue("br")); + + // 设置 Connection 头为 keep-alive,允许重用 TCP 连接,避免每次请求都重新建立连接带来的开销 + httpClient.DefaultRequestHeaders.ConnectionClose = false; + } + + /// + /// 分析 标头 + /// + /// + /// + /// + /// 摘要 + /// + /// + /// + public static string? ProfilerHeaders(this HttpRequestMessage httpRequestMessage, + string? summary = "Request Headers") => + StringUtility.FormatKeyValuesSummary( + httpRequestMessage.Headers.ConcatIgnoreNull(httpRequestMessage.Content?.Headers), summary); + + /// + /// 分析 标头 + /// + /// + /// + /// + /// 摘要 + /// + /// + /// + public static string? ProfilerHeaders(this HttpResponseMessage httpResponseMessage, + string? summary = "Response Headers") => + StringUtility.FormatKeyValuesSummary( + httpResponseMessage.Headers.ConcatIgnoreNull(httpResponseMessage.Content.Headers), + summary); + + /// + /// 分析常规和 标头 + /// + /// + /// + /// + /// 响应标头摘要 + /// 常规摘要 + /// 自定义常规摘要键值集合 + /// + /// + /// + public static string ProfilerGeneralAndHeaders(this HttpResponseMessage httpResponseMessage, + string? responseSummary = "Response Headers", string? generalSummary = "General", + IEnumerable>>? generalCustomKeyValues = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpResponseMessage); + + // 获取 HttpRequestMessage 实例 + var httpRequestMessage = httpResponseMessage.RequestMessage; + + // 空检查 + ArgumentNullException.ThrowIfNull(httpRequestMessage); + + // 获取 HttpContent 实例 + var httpContent = httpRequestMessage.Content; + + // 格式化 HTTP 声明式条目 + IEnumerable>>? declarativeKeyValues = + httpRequestMessage.Options.TryGetValue(new HttpRequestOptionsKey(Constants.DECLARATIVE_METHOD_KEY), + out var methodSignature) + ? [new KeyValuePair>("Declarative", [methodSignature])] + : null; + + // 格式化常规条目 + var generalEntry = StringUtility.FormatKeyValuesSummary(new[] + { + new KeyValuePair>("Request URL", + [httpRequestMessage.RequestUri?.OriginalString!]), + new KeyValuePair>("HTTP Method", [httpRequestMessage.Method.ToString()]), + new KeyValuePair>("Status Code", + [$"{(int)httpResponseMessage.StatusCode} {httpResponseMessage.StatusCode}"]), + new KeyValuePair>("HTTP Content", + [$"{httpContent?.GetType().Name}"]) + }.ConcatIgnoreNull(declarativeKeyValues).ConcatIgnoreNull(generalCustomKeyValues), generalSummary); + + // 格式化响应条目 + var responseEntry = httpResponseMessage.ProfilerHeaders(responseSummary); + + return $"{generalEntry}\r\n{responseEntry}"; + } + + /// + /// 分析 内容 + /// + /// + /// + /// + /// 摘要 + /// + /// + /// + /// + /// + /// + public static async Task ProfilerAsync(this HttpContent? httpContent, string? summary = "Request Body", + CancellationToken cancellationToken = default) + { + // 空检查 + if (httpContent is null) + { + return null; + } + + // 默认只读取 5KB 的内容 + const int maxBytesToDisplay = 5120; + + // 读取内容为字节数组 + var buffer = await httpContent.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + + // 计算要显示的部分 + var bytesToShow = Math.Min(buffer.Length, maxBytesToDisplay); + var partialContent = Encoding.UTF8.GetString(buffer, 0, bytesToShow); + + // 如果实际读取的数据小于最大显示大小,则直接返回;否则,添加省略号表示内容被截断 + var bodyString = buffer.Length <= maxBytesToDisplay ? partialContent : partialContent + " ... [truncated]"; + + return StringUtility.FormatKeyValuesSummary( + [new KeyValuePair>(string.Empty, [bodyString])], + $"{summary} ({httpContent.GetType().Name})"); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpRemoteServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpRemoteServiceCollectionExtensions.cs new file mode 100644 index 000000000..f15009c76 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Extensions/HttpRemoteServiceCollectionExtensions.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.HttpRemote; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// HTTP 远程请求模块 拓展类 +/// +public static class HttpRemoteServiceCollectionExtensions +{ + /// + /// 添加 HTTP 远程请求服务 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + public static IHttpRemoteBuilder AddHttpRemote(this IServiceCollection services + , Action? configure = null) + { + // 初始化 HTTP 远程请求构建器 + var httpRemoteBuilder = new HttpRemoteBuilder(); + + // 调用自定义配置委托 + configure?.Invoke(httpRemoteBuilder); + + return services.AddHttpRemote(httpRemoteBuilder); + } + + /// + /// 添加 HTTP 远程请求服务 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static IHttpRemoteBuilder AddHttpRemote(this IServiceCollection services, + HttpRemoteBuilder httpRemoteBuilder) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteBuilder); + + // 构建模块服务 + httpRemoteBuilder.Build(services); + + return new DefaultHttpRemoteBuilder(services); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/HttpContentConverterFactory.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/HttpContentConverterFactory.cs new file mode 100644 index 000000000..32eb8f24d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/HttpContentConverterFactory.cs @@ -0,0 +1,135 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +internal sealed class HttpContentConverterFactory : IHttpContentConverterFactory +{ + /// + /// 字典集合 + /// + internal readonly Dictionary _converters; + + /// + /// + /// + /// + /// + /// + /// 数组 + public HttpContentConverterFactory(IServiceProvider serviceProvider, IHttpContentConverter[]? converters) + { + ServiceProvider = serviceProvider; + + // 初始化响应内容转换器 + _converters = new Dictionary + { + [typeof(HttpResponseMessageConverter)] = new HttpResponseMessageConverter(), + [typeof(StringContentConverter)] = new StringContentConverter(), + [typeof(ByteArrayContentConverter)] = new ByteArrayContentConverter(), + [typeof(StreamContentConverter)] = new StreamContentConverter(), + [typeof(VoidContentConverter)] = new VoidContentConverter() + }; + + // 添加自定义 IHttpContentConverter 数组 + _converters.TryAdd(converters, value => value.GetType()); + } + + /// + public IServiceProvider ServiceProvider { get; } + + /// + public TResult? Read(HttpResponseMessage httpResponseMessage, IHttpContentConverter[]? converters = null, + CancellationToken cancellationToken = default) => + GetConverter(converters).Read(httpResponseMessage, cancellationToken); + + /// + public object? Read(Type resultType, HttpResponseMessage httpResponseMessage, + IHttpContentConverter[]? converters = null, + CancellationToken cancellationToken = default) => + GetConverter(resultType, converters).Read(resultType, httpResponseMessage, cancellationToken); + + /// + public async Task ReadAsync(HttpResponseMessage httpResponseMessage, + IHttpContentConverter[]? converters = null, + CancellationToken cancellationToken = default) => + await GetConverter(converters).ReadAsync(httpResponseMessage, cancellationToken).ConfigureAwait(false); + + /// + public async Task ReadAsync(Type resultType, HttpResponseMessage httpResponseMessage, + IHttpContentConverter[]? converters = null, + CancellationToken cancellationToken = default) => + await GetConverter(resultType, converters).ReadAsync(resultType, httpResponseMessage, cancellationToken).ConfigureAwait(false); + + /// + /// 获取 实例 + /// + /// 数组 + /// 转换的目标类型 + /// + /// + /// + internal IHttpContentConverter GetConverter(params IHttpContentConverter[]? converters) + { + // 初始化新的 IHttpContentConverter 字典集合 + var unionConverters = new Dictionary(_converters); + + // 添加自定义 IHttpContentConverter 数组 + unionConverters.TryAdd(converters, value => value.GetType()); + + // 查找可以处理目标类型的响应内容转换器 + var typeConverter = unionConverters.Values.OfType>().LastOrDefault(); + + // 如果未找到,则调用 IObjectContentConverterFactory 实例的 GetConverter 返回 + var converter = typeConverter ?? ServiceProvider.GetRequiredService() + .GetConverter(); + + // 设置服务提供器 + converter.ServiceProvider ??= ServiceProvider; + + return converter; + } + + /// + /// 获取 实例 + /// + /// 转换的目标类型 + /// 数组 + /// + /// + /// + internal IHttpContentConverter GetConverter(Type resultType, params IHttpContentConverter[]? converters) + { + // 初始化新的 IHttpContentConverter 字典集合 + var unionConverters = new Dictionary(_converters); + + // 添加自定义 IHttpContentConverter 数组 + unionConverters.TryAdd(converters, value => value.GetType()); + + // 查找可以处理目标类型的响应内容转换器 + var typeConverter = unionConverters.Values.OfType(typeof(IHttpContentConverter<>).MakeGenericType(resultType)) + .Cast().LastOrDefault(); + + // 如果未找到,则调用 IObjectContentConverterFactory 实例的 GetConverter 返回 + var converter = typeConverter ?? ServiceProvider.GetRequiredService() + .GetConverter(resultType); + + // 设置服务提供器 + converter.ServiceProvider ??= ServiceProvider; + + return converter; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/HttpContentProcessorFactory.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/HttpContentProcessorFactory.cs new file mode 100644 index 000000000..fe40efbef --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/HttpContentProcessorFactory.cs @@ -0,0 +1,95 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +internal sealed class HttpContentProcessorFactory : IHttpContentProcessorFactory +{ + /// + /// 字典集合 + /// + internal readonly Dictionary _processors; + + /// + /// + /// + /// + /// + /// + /// 数组 + public HttpContentProcessorFactory(IServiceProvider serviceProvider, IHttpContentProcessor[]? processors) + { + ServiceProvider = serviceProvider; + + // 初始化请求内容处理器 + _processors = new Dictionary + { + [typeof(StringContentProcessor)] = new StringContentProcessor(), + [typeof(FormUrlEncodedContentProcessor)] = new FormUrlEncodedContentProcessor(), + [typeof(ByteArrayContentProcessor)] = new ByteArrayContentProcessor(), + [typeof(StreamContentProcessor)] = new StreamContentProcessor(), + [typeof(MultipartFormDataContentProcessor)] = new MultipartFormDataContentProcessor(), + [typeof(ReadOnlyMemoryContentProcessor)] = new ReadOnlyMemoryContentProcessor() + }; + + // 添加自定义 IHttpContentProcessor 数组 + _processors.TryAdd(processors, value => value.GetType()); + } + + /// + public IServiceProvider ServiceProvider { get; } + + /// + public HttpContent? Build(object? rawContent, string contentType, Encoding? encoding = null, + params IHttpContentProcessor[]? processors) + { + // 查找可以处理指定内容类型或数据类型的 IHttpContentProcessor 实例 + var httpContentProcessor = GetProcessor(rawContent, contentType, processors); + + // 将原始请求内容转换为 HttpContent 实例 + return httpContentProcessor.Process(rawContent, contentType, encoding); + } + + /// + /// 查找可以处理指定内容类型或数据类型的 实例 + /// + /// 原始请求内容 + /// 内容类型 + /// 自定义 数组 + /// + /// + /// + internal IHttpContentProcessor GetProcessor(object? rawContent, string contentType, + params IHttpContentProcessor[]? processors) + { + // 初始化新的 IHttpContentProcessor 字典集合 + var unionProcessors = new Dictionary(_processors); + + // 添加自定义 IHttpContentProcessor 数组 + unionProcessors.TryAdd(processors, value => value.GetType()); + + // 查找可以处理指定内容类型或数据类型的 IHttpContentProcessor 实例 + var processor = unionProcessors.Values.LastOrDefault(u => u.CanProcess(rawContent, contentType)) ?? + throw new InvalidOperationException( + $"No processor found that can handle the content type `{contentType}` and the provided raw content of type `{rawContent?.GetType()}`. " + + "Please ensure that the correct content type is specified and that a suitable processor is registered."); + + // 设置服务提供器 + processor.ServiceProvider ??= ServiceProvider; + + return processor; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/IHttpContentConverterFactory.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/IHttpContentConverterFactory.cs new file mode 100644 index 000000000..e302df669 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/IHttpContentConverterFactory.cs @@ -0,0 +1,95 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 工厂 +/// +public interface IHttpContentConverterFactory +{ + /// + /// + /// + IServiceProvider ServiceProvider { get; } + + /// + /// 将 转换为 + /// + /// 实例 + /// + /// + /// + /// + /// 数组 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? Read(HttpResponseMessage httpResponseMessage, + IHttpContentConverter[]? converters = null, CancellationToken cancellationToken = default); + + /// + /// 将 转换为 实例 + /// + /// 转换的目标类型 + /// + /// + /// + /// 数组 + /// + /// + /// + /// + /// + /// + object? Read(Type resultType, HttpResponseMessage httpResponseMessage, + IHttpContentConverter[]? converters = null, CancellationToken cancellationToken = default); + + /// + /// 将 转换为 + /// + /// 实例 + /// + /// + /// + /// + /// 数组 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task ReadAsync(HttpResponseMessage httpResponseMessage, + IHttpContentConverter[]? converters = null, CancellationToken cancellationToken = default); + + /// + /// 将 转换为 实例 + /// + /// 转换的目标类型 + /// + /// + /// + /// 数组 + /// + /// + /// + /// + /// + /// + Task ReadAsync(Type resultType, HttpResponseMessage httpResponseMessage, + IHttpContentConverter[]? converters = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/IHttpContentProcessorFactory.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/IHttpContentProcessorFactory.cs new file mode 100644 index 000000000..2a8d667eb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/IHttpContentProcessorFactory.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// 工厂 +/// +public interface IHttpContentProcessorFactory +{ + /// + /// + /// + IServiceProvider ServiceProvider { get; } + + /// + /// 构建 实例 + /// + /// 原始请求内容 + /// 内容类型 + /// 内容编码 + /// 数组 + /// + /// + /// + HttpContent? Build(object? rawContent, string contentType, Encoding? encoding = null, + params IHttpContentProcessor[]? processors); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/IObjectContentConverterFactory.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/IObjectContentConverterFactory.cs new file mode 100644 index 000000000..7b1c5f7bc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/IObjectContentConverterFactory.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 工厂 +/// +public interface IObjectContentConverterFactory +{ + /// + /// 获取 实例 + /// + /// 转换的目标类型 + /// + /// + /// + ObjectContentConverter GetConverter(); + + /// + /// 获取 实例 + /// + /// 转换的目标类型 + /// + /// + /// + ObjectContentConverter GetConverter(Type resultType); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/ObjectContentConverterFactory.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/ObjectContentConverterFactory.cs new file mode 100644 index 000000000..bcdcb175d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Factories/ObjectContentConverterFactory.cs @@ -0,0 +1,22 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +internal sealed class ObjectContentConverterFactory : IObjectContentConverterFactory +{ + /// + public ObjectContentConverter GetConverter() => new(); + + /// + public ObjectContentConverter GetConverter(Type resultType) => new(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Filters/ForwardAttribute.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Filters/ForwardAttribute.cs new file mode 100644 index 000000000..a7519648c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Filters/ForwardAttribute.cs @@ -0,0 +1,162 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Filters; + +using ThingsGateway.Extensions; +using ThingsGateway.HttpRemote.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// 转发操作筛选器 +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class ForwardAttribute : ActionFilterAttribute +{ + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + public ForwardAttribute(string? requestUri) => RequestUri = requestUri; + + /// + /// + /// + /// 转发地址。若为空则尝试从请求标头 X-Forward-To 中获取目标地址。 + /// 转发方式 + public ForwardAttribute(string? requestUri, HttpMethod httpMethod) + : this(requestUri) => + Method = httpMethod; + + /// + /// 转发地址 + /// + public string? RequestUri { get; set; } + + /// + /// 转发方式 + /// + /// 若未设置,则自动采用当前请求方式作为转发方式。 + public HttpMethod? Method { get; set; } + + /// + /// 实例的配置名称 + /// + /// + /// 此属性用于指定 创建 实例时传递的名称。 + /// 该名称用于标识在服务容器中与特定 实例相关的配置。 + /// + public string? HttpClientName { get; set; } + + /// + /// + /// + public HttpCompletionOption CompletionOption { get; set; } = HttpCompletionOption.ResponseContentRead; + + /// + /// 是否转发查询参数(URL 参数) + /// + /// 默认值为:true + public bool WithQueryParameters { get; set; } = true; + + /// + /// 是否转发请求标头 + /// + /// 默认值为:true + public bool WithRequestHeaders { get; set; } = true; + + /// + /// 是否转发响应状态码 + /// + /// 默认值为:true + public bool WithResponseStatusCode { get; set; } = true; + + /// + /// 是否转发响应标头 + /// + /// 默认值为:true + public bool WithResponseHeaders { get; set; } = true; + + /// + /// 是否转发响应内容标头 + /// + /// 默认值为:true + public bool WithResponseContentHeaders { get; set; } = true; + + /// + /// 忽略在转发时需要跳过的请求标头列表 + /// + public string[]? IgnoreRequestHeaders { get; set; } + + /// + /// 忽略在转发时需要跳过的响应标头列表 + /// + /// + /// 若响应标头中包含 Content-Length,且其值与实际响应体大小不符,则可能引发“Error while copying content to a + /// stream.”。忽略此标头有助于解决因长度不匹配引起的错误。 + /// + public string[]? IgnoreResponseHeaders { get; set; } + + /// + /// 是否重新设置 Host 请求标头 + /// + /// 在一些目标服务器中,可能需要校验该请求标头。默认值为:false + public bool ResetHostRequestHeader { get; set; } + + /// + public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + // 获取方法返回值类型 + var returnType = ((ControllerActionDescriptor)context.ActionDescriptor).MethodInfo.ReturnType; + + // 解析结果类型 + var resultType = ParseResultType(returnType); + + // 获取请求方式 + var httpMethod = Method ?? Helpers.ParseHttpMethod(context.HttpContext.Request.Method); + + // 转发并获取结果 + var result = await context.HttpContext.ForwardAsAsync(resultType, httpMethod, RequestUri, + builder => builder.SetHttpClientName(HttpClientName), CompletionOption, + new HttpContextForwardOptions + { + WithQueryParameters = WithQueryParameters, + WithRequestHeaders = WithRequestHeaders, + WithResponseStatusCode = WithResponseStatusCode, + WithResponseHeaders = WithResponseHeaders, + WithResponseContentHeaders = WithResponseContentHeaders, + IgnoreRequestHeaders = IgnoreRequestHeaders, + IgnoreResponseHeaders = IgnoreResponseHeaders, + ResetHostRequestHeader = ResetHostRequestHeader + }).ConfigureAwait(false); + + // 设置转发内容 + context.Result = result as IActionResult ?? new ObjectResult(result); + } + + /// + /// 解析结果类型 + /// + /// 方法返回值类型 + /// + /// + /// + internal static Type ParseResultType(Type returnType) => + returnType == typeof(void) || returnType == typeof(Task) + ? typeof(VoidContent) + : typeof(Task<>).IsDefinitionEqual(returnType) + ? returnType.GenericTypeArguments[0] + : returnType; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpFileTransferEventHandler.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpFileTransferEventHandler.cs new file mode 100644 index 000000000..ba79d6c93 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpFileTransferEventHandler.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 文件传输事件处理程序 +/// +public interface IHttpFileTransferEventHandler +{ + /// + /// 用于处理在文件开始传输时的操作 + /// + void OnTransferStarted(); + + /// + /// 用于传输进度发生变化时的操作 + /// + /// + /// + /// + /// + /// + /// + Task OnProgressChangedAsync(FileTransferProgress fileTransferProgress); + + /// + /// 用于处理在文件传输完成时的操作 + /// + /// 总耗时(毫秒) + void OnTransferCompleted(long duration); + + /// + /// 用于处理在文件传输发生异常时的操作 + /// + /// + /// + /// + void OnTransferFailed(Exception exception); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpLongPollingEventHandler.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpLongPollingEventHandler.cs new file mode 100644 index 000000000..1c1b6bc92 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpLongPollingEventHandler.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 长轮询事件处理程序 +/// +public interface IHttpLongPollingEventHandler +{ + /// + /// 用于接收服务器返回 200~299 状态码的数据的操作 + /// + /// + /// + /// + /// + /// + /// + Task OnDataReceivedAsync(HttpResponseMessage httpResponseMessage); + + /// + /// 用于接收服务器返回非 200~299 状态码的数据的操作 + /// + /// + /// + /// + /// + /// + /// + Task OnErrorAsync(HttpResponseMessage httpResponseMessage); + + /// + /// 用于响应标头包含 X-End-Of-Stream 时触发的操作 + /// + /// + /// + /// + /// + /// + /// + Task OnEndOfStreamAsync(HttpResponseMessage httpResponseMessage); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpRequestEventHandler.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpRequestEventHandler.cs new file mode 100644 index 000000000..fa8ab2df6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpRequestEventHandler.cs @@ -0,0 +1,45 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求事件处理程序 +/// +public interface IHttpRequestEventHandler +{ + /// + /// 用于处理在发送 HTTP 请求之前的操作 + /// + /// + /// + /// + void OnPreSendRequest(HttpRequestMessage httpRequestMessage); + + /// + /// 用于处理在收到 HTTP 响应之后的操作 + /// + /// + /// + /// + void OnPostReceiveResponse(HttpResponseMessage httpResponseMessage); + + /// + /// 用于处理在发送 HTTP 请求发生异常时的操作 + /// + /// + /// + /// + /// + /// + /// + void OnRequestFailed(Exception exception, HttpResponseMessage? httpResponseMessage); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpServerSentEventsEventHandler.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpServerSentEventsEventHandler.cs new file mode 100644 index 000000000..4b512635c --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Handlers/IHttpServerSentEventsEventHandler.cs @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// Server-Sent Events 事件处理程序 +/// +public interface IHttpServerSentEventsEventHandler +{ + /// + /// 用于在与事件源的连接打开时的操作 + /// + void OnOpen(); + + /// + /// 用于在从事件源接收到数据时的操作 + /// + /// + /// + /// + /// + /// + /// + Task OnMessageAsync(ServerSentEventsData serverSentEventsData); + + /// + /// 用于在事件源连接未能打开时的操作 + /// + /// + /// + /// + void OnError(Exception exception); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Helpers/Helpers.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Helpers/Helpers.cs new file mode 100644 index 000000000..660773047 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Helpers/Helpers.cs @@ -0,0 +1,201 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Net.Http.Headers; + +using System.Net; +using System.Text.RegularExpressions; + +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求模块帮助类 +/// +internal static partial class Helpers +{ + /// + /// 从互联网 URL 地址中加载流 + /// + /// 互联网 URL 地址 + /// 响应内容的最大缓存大小。默认值为:100MB。 + /// + /// + /// + /// + /// + internal static Stream GetStreamFromRemote(string requestUri, long maxResponseContentBufferSize = 104857600L) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(requestUri); + + // 检查 URL 地址是否是互联网地址 + if (!NetworkUtility.IsWebUrl(requestUri)) + { + throw new ArgumentException($"Invalid internet address: `{requestUri}`.", nameof(requestUri)); + } + + // 初始化 HttpClient 实例 + using var httpClient = new HttpClient(); + + // 限制流大小 + httpClient.MaxResponseContentBufferSize = maxResponseContentBufferSize; + + // 启用性能优化(返回 Stream 内容时,请勿启用此配置,否则流将因压缩而变得不可读。) + // httpClient.PerformanceOptimization(); + + // 设置默认 User-Agent + httpClient.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.UserAgent, + Constants.USER_AGENT_OF_BROWSER); + + try + { + // 发送 HTTP 远程请求 + var httpResponseMessage = httpClient.Send(new HttpRequestMessage(HttpMethod.Get, requestUri), + HttpCompletionOption.ResponseHeadersRead); + + // 确保请求成功 + httpResponseMessage.EnsureSuccessStatusCode(); + + // 读取流并返回 + return httpResponseMessage.Content.ReadAsStream(); + } + catch (Exception e) + { + throw new InvalidOperationException($"Failed to load stream from internet address: `{requestUri}`.", e); + } + } + + /// + /// 从 中解析文件的名称 + /// + /// + /// + /// + /// + /// + /// + internal static string? GetFileNameFromUri(Uri? uri) + { + // 空检查 + if (uri is null) + { + return null; + } + + // 获取 URL 的绝对路径 + var path = uri.AbsolutePath; + + // 使用 / 分割路径,并获取最后一个部分作为潜在的文件的名称 + var parts = path.Split('/'); + var fileName = parts.Length > 0 ? parts[^1] : string.Empty; + + // 检查文件的名称是否为空或仅由点组成 + if (string.IsNullOrEmpty(fileName) || fileName.Trim('.').Length == 0) + { + return string.Empty; + } + + // 查找文件的名称中的查询字符串开始位置。如果存在查询字符串,则去除它 + var queryStartIndex = fileName.IndexOf('?'); + if (queryStartIndex != -1) + { + fileName = fileName[..queryStartIndex]; + } + + // 检查文件的名称是否包含有效的扩展名 + var lastDotIndex = fileName.LastIndexOf('.'); + if (lastDotIndex == -1 || lastDotIndex == fileName.Length - 1) + { + return string.Empty; + } + + // 使用 UTF-8 解码文件的名称 + return Uri.UnescapeDataString(fileName); + } + + /// + /// 解析 HTTP 谓词 + /// + /// HTTP 谓词 + /// + /// + /// + internal static HttpMethod ParseHttpMethod(string? httpMethod) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod); + + return HttpMethod.Parse(httpMethod); + } + + /// + /// 验证字符串是否是 application/x-www-form-urlencoded 格式 + /// + /// 字符串 + /// + /// + /// + internal static bool IsFormUrlEncodedFormat(string output) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(output); + + return FormUrlEncodedFormatRegex().IsMatch(output); + } + + /// + /// 检查 HTTP 状态码是否是重定向状态码 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsRedirectStatusCode(HttpStatusCode statusCode) => + statusCode is HttpStatusCode.Ambiguous or HttpStatusCode.Moved or HttpStatusCode.Redirect + or HttpStatusCode.RedirectMethod or HttpStatusCode.RedirectKeepVerb || (int)statusCode == 308; + + /// + /// 从给定的绝对 URI 中解析出基础地址 + /// + /// 请求地址 + /// + /// + /// + /// + internal static Uri ParseBaseAddress(Uri? requestUri) + { + // 空检查 + ArgumentNullException.ThrowIfNull(requestUri); + + // 检查是否是绝对地址 + if (!requestUri.IsAbsoluteUri) + { + throw new ArgumentException("The requestUri must be an absolute URI.", nameof(requestUri)); + } + + return new Uri( + $"{requestUri.Scheme}://{requestUri.Host}{(requestUri.IsDefaultPort ? string.Empty : $":{requestUri.Port}")}"); + } + + /// + /// application/x-www-form-urlencoded 格式正则表达式 + /// + /// + /// + /// + [GeneratedRegex( + @"^(?:(?:[a-zA-Z0-9-._~]+|%(?:[0-9A-Fa-f]{2}))+=(?:[a-zA-Z0-9-._~]*|%(?:[0-9A-Fa-f]{2}))+)(?:&(?:[a-zA-Z0-9-._~]+|%(?:[0-9A-Fa-f]{2}))+=(?:[a-zA-Z0-9-._~]*|%(?:[0-9A-Fa-f]{2}))+)*$")] + private static partial Regex FormUrlEncodedFormatRegex(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/FileDownloadManager.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/FileDownloadManager.cs new file mode 100644 index 000000000..bc9d14e31 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/FileDownloadManager.cs @@ -0,0 +1,562 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using System.Diagnostics; +using System.Threading.Channels; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// 文件下载管理器 +/// +internal sealed class FileDownloadManager +{ + /// + internal readonly HttpFileDownloadBuilder _httpFileDownloadBuilder; + + /// + internal readonly IHttpRemoteService _httpRemoteService; + + /// + /// 文件传输进度信息的通道 + /// + internal readonly Channel _progressChannel; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 自定义配置委托 + internal FileDownloadManager(IHttpRemoteService httpRemoteService, HttpFileDownloadBuilder httpFileDownloadBuilder, + Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteService); + ArgumentNullException.ThrowIfNull(httpFileDownloadBuilder); + + _httpRemoteService = httpRemoteService; + _httpFileDownloadBuilder = httpFileDownloadBuilder; + + // 初始化文件传输进度信息的通道 + _progressChannel = Channel.CreateUnbounded(); + + // 解析 IHttpFileTransferEventHandler 事件处理程序 + FileTransferEventHandler = (httpFileDownloadBuilder.FileTransferEventHandlerType is not null + ? httpRemoteService.ServiceProvider.GetService(httpFileDownloadBuilder.FileTransferEventHandlerType) + : null) as IHttpFileTransferEventHandler; + + // 构建 HttpRequestBuilder 实例 + RequestBuilder = httpFileDownloadBuilder.Build(httpRemoteService.ServiceProvider + .GetRequiredService>().Value, configure); + } + + /// + /// + /// + internal HttpRequestBuilder RequestBuilder { get; } + + /// + /// + /// + internal IHttpFileTransferEventHandler? FileTransferEventHandler { get; } + + /// + /// 开始下载 + /// + /// + /// + /// + internal void Start(CancellationToken cancellationToken = default) + { + // 创建关联的取消标识 + using var progressCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 初始化进度报告任务 + var reportProgressTask = ReportProgressAsync(progressCancellationTokenSource.Token); + + // 处理文件传输开始 + HandleTransferStarted(); + + // 初始化读取数据的缓冲区和记录进度所需的变量 + var bufferSize = _httpFileDownloadBuilder.BufferSize; + var buffer = new byte[bufferSize]; + var bytesReceived = 0L; + + // 获取临时文件路径 + var tempDestinationPath = Path.GetTempFileName(); + + // 声明 FileStream 变量 + FileStream? fileStream = null; + + // 初始化 Stopwatch 实例并开启计时操作 + var stopwatch = Stopwatch.StartNew(); + + try + { + // 发送 HTTP 远程请求 + var httpResponseMessage = _httpRemoteService.Send(RequestBuilder, HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + // 根据文件是否存在及配置的行为来决定是否应继续进行文件下载 + if (!ShouldContinueWithDownload(httpResponseMessage, out var destinationPath)) + { + // 处理文件存在且配置为跳过时的操作 + HandleFileExistAndSkip(); + + return; + } + + // 初始化 FileTransferProgress 实例 + var fileTransferProgress = + new FileTransferProgress(destinationPath, httpResponseMessage.Content.Headers.ContentLength ?? -1); + + // 初始化 FileStream 实例,使用文件流创建文件,设置写入模式,并允许其他进程同时读取文件 + fileStream = new FileStream(tempDestinationPath, FileMode.Create, FileAccess.Write, FileShare.Read, + bufferSize); + + // 获取 HTTP 响应体中的内容流 + using var contentStream = httpResponseMessage.Content.ReadAsStream(cancellationToken); + + // 循环读取数据直到取消请求或读取完毕 + int numBytesRead; + while (!cancellationToken.IsCancellationRequested && + (numBytesRead = contentStream.Read(buffer, 0, buffer.Length)) > 0) + { + // 将读取的数据写入文件 + fileStream.Write(buffer, 0, numBytesRead); + + // 更新文件传输进度 + bytesReceived += numBytesRead; + fileTransferProgress.UpdateProgress(bytesReceived, stopwatch.Elapsed); + + // 发送文件传输进度到通道 + _progressChannel.Writer.TryWrite(fileTransferProgress); + } + + // 移动临时文件至文件保存的目标路径 + MoveTempFileToDestinationPath(fileStream, tempDestinationPath, destinationPath); + + // 计算文件传输总花费时间 + var duration = stopwatch.ElapsedMilliseconds; + + // 处理文件传输完成 + HandleTransferCompleted(duration); + } + catch (Exception e) + { + // 清理临时文件 + fileStream?.Close(); + if (File.Exists(tempDestinationPath)) + { + File.Delete(tempDestinationPath); + } + + // 处理文件传输失败 + HandleTransferFailed(e); + + throw; + } + finally + { + // 释放 FileStream 实例 + fileStream?.Dispose(); + + // 停止计时 + stopwatch.Stop(); + + // 关闭通道 + _progressChannel.Writer.Complete(); + + // 等待进度报告任务完成 + progressCancellationTokenSource.Cancel(); + reportProgressTask.Wait(cancellationToken); + } + } + + /// + /// 开始下载 + /// + /// + /// + /// + internal async Task StartAsync(CancellationToken cancellationToken = default) + { + // 创建关联的取消标识 + using var progressCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 初始化进度报告任务 + var reportProgressTask = ReportProgressAsync(progressCancellationTokenSource.Token); + + // 处理文件传输开始 + HandleTransferStarted(); + + // 初始化读取数据的缓冲区和记录进度所需的变量 + var bufferSize = _httpFileDownloadBuilder.BufferSize; + var buffer = new byte[bufferSize]; + var bytesReceived = 0L; + + // 获取临时文件路径 + var tempDestinationPath = Path.GetTempFileName(); + + // 声明 FileStream 变量 + FileStream? fileStream = null; + + // 初始化 Stopwatch 实例并开启计时操作 + var stopwatch = Stopwatch.StartNew(); + + try + { + // 发送 HTTP 远程请求 + var httpResponseMessage = await _httpRemoteService.SendAsync(RequestBuilder, + HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + // 根据文件是否存在及配置的行为来决定是否应继续进行文件下载 + if (!ShouldContinueWithDownload(httpResponseMessage, out var destinationPath)) + { + // 处理文件存在且配置为跳过时的操作 + HandleFileExistAndSkip(); + + return; + } + + // 初始化 FileTransferProgress 实例 + var fileTransferProgress = + new FileTransferProgress(destinationPath, httpResponseMessage.Content.Headers.ContentLength ?? -1); + + // 初始化 FileStream 实例,使用文件流创建文件,设置写入模式,并允许其他进程同时读取文件 + fileStream = new FileStream(tempDestinationPath, FileMode.Create, FileAccess.Write, FileShare.Read, + bufferSize, true); + + // 获取 HTTP 响应体中的内容流 + using var contentStream = await httpResponseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + // 循环读取数据直到取消请求或读取完毕 + int numBytesRead; + while (!cancellationToken.IsCancellationRequested && + (numBytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + // 将读取的数据写入文件 + await fileStream.WriteAsync(buffer.AsMemory(0, numBytesRead), cancellationToken).ConfigureAwait(false); + + // 更新文件传输进度 + bytesReceived += numBytesRead; + fileTransferProgress.UpdateProgress(bytesReceived, stopwatch.Elapsed); + + // 发送文件传输进度到通道 + await _progressChannel.Writer.WriteAsync(fileTransferProgress, cancellationToken).ConfigureAwait(false); + } + + // 移动临时文件至文件保存的目标路径 + MoveTempFileToDestinationPath(fileStream, tempDestinationPath, destinationPath); + + // 计算文件传输总花费时间 + var duration = stopwatch.ElapsedMilliseconds; + + // 处理文件传输完成 + HandleTransferCompleted(duration); + } + catch (Exception e) + { + // 清理临时文件 + fileStream?.Close(); + if (File.Exists(tempDestinationPath)) + { + File.Delete(tempDestinationPath); + } + + // 处理文件传输失败 + HandleTransferFailed(e); + + throw; + } + finally + { + // 释放 FileStream 实例 + if (fileStream is not null) + { + await fileStream.DisposeAsync().ConfigureAwait(false); + } + + // 停止计时 + stopwatch.Stop(); + + // 关闭通道 + _progressChannel.Writer.Complete(); + + // 等待进度报告任务完成 + await progressCancellationTokenSource.CancelAsync().ConfigureAwait(false); + await reportProgressTask.ConfigureAwait(false); + } + } + + /// + /// 文件传输进度报告任务 + /// + /// + /// + /// + internal async Task ReportProgressAsync(CancellationToken cancellationToken) + { + // 空检查 + if (_httpFileDownloadBuilder.OnProgressChanged is null && FileTransferEventHandler is null) + { + return; + } + + try + { + // 从进度通道中读取所有的进度信息 + await foreach (var fileTransferProgress in _progressChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + try + { + // 处理文件传输进度变化 + await HandleProgressChangedAsync(fileTransferProgress).ConfigureAwait(false); + + // 根据配置的进度更新(通知)的间隔时间延迟进度报告 + await Task.Delay(_httpFileDownloadBuilder.ProgressInterval, cancellationToken).ConfigureAwait(false); + } + // 捕获当通道关闭或操作被取消时的异常 + catch (Exception e) when (cancellationToken.IsCancellationRequested || + e is ChannelClosedException or OperationCanceledException) + { + // 处理文件传输进度变化 + await HandleProgressChangedAsync(fileTransferProgress).ConfigureAwait(false); + + break; + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + } + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + // 任务被取消 + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + + /// + /// 处理文件传输开始 + /// + internal void HandleTransferStarted() + { + // 空检查 + if (FileTransferEventHandler is not null) + { + DelegateExtensions.TryInvoke(FileTransferEventHandler.OnTransferStarted); + } + + _httpFileDownloadBuilder.OnTransferStarted.TryInvoke(); + } + + /// + /// 处理文件传输完成 + /// + /// 文件传输总花费时间 + internal void HandleTransferCompleted(long duration) + { + // 空检查 + if (FileTransferEventHandler is not null) + { + DelegateExtensions.TryInvoke(FileTransferEventHandler.OnTransferCompleted, duration); + } + + _httpFileDownloadBuilder.OnTransferCompleted.TryInvoke(duration); + } + + /// + /// 处理文件传输失败 + /// + /// + /// + /// + internal void HandleTransferFailed(Exception e) + { + // 空检查 + if (FileTransferEventHandler is not null) + { + DelegateExtensions.TryInvoke(FileTransferEventHandler.OnTransferFailed, e); + } + + _httpFileDownloadBuilder.OnTransferFailed.TryInvoke(e); + } + + /// + /// 处理文件存在且配置为跳过时的操作 + /// + internal void HandleFileExistAndSkip() => _httpFileDownloadBuilder.OnFileExistAndSkip.TryInvoke(); + + /// + /// 处理文件传输进度变化 + /// + /// + /// + /// + internal async Task HandleProgressChangedAsync(FileTransferProgress fileTransferProgress) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fileTransferProgress); + + // 空检查 + if (FileTransferEventHandler is not null) + { + await DelegateExtensions.TryInvokeAsync(FileTransferEventHandler.OnProgressChangedAsync, + fileTransferProgress).ConfigureAwait(false); + } + + await _httpFileDownloadBuilder.OnProgressChanged.TryInvokeAsync(fileTransferProgress).ConfigureAwait(false); + } + + /// + /// 根据文件是否存在及配置的行为来决定是否应继续进行文件下载 + /// + /// + /// + /// + /// 文件保存的目标路径 + /// + /// + /// + /// + internal bool ShouldContinueWithDownload(HttpResponseMessage httpResponseMessage, out string destinationPath) + { + // 生成完整的文件存储路径 + destinationPath = Path.GetFullPath(Path.Combine( + Path.GetDirectoryName(_httpFileDownloadBuilder.DestinationPath) ?? string.Empty, + GetFileName(httpResponseMessage))); + + // 检查文件是否存在 + if (!File.Exists(destinationPath)) + { + return true; + } + + // 检查文件存在时的行为 + switch (_httpFileDownloadBuilder.FileExistsBehavior) + { + case FileExistsBehavior.CreateNew: + throw new InvalidOperationException($"The destination path `{destinationPath}` already exists."); + case FileExistsBehavior.Skip: + // 输出调试事件 + Debugging.File( + $"The destination path `{destinationPath}` already exists; skipping the file download operation."); + return false; + case FileExistsBehavior.Overwrite: + default: + break; + } + + return true; + } + + /// + /// 获取文件下载名 + /// + /// + /// + /// + /// + /// + /// + internal string GetFileName(HttpResponseMessage httpResponseMessage) + { + // 获取文件下载保存的文件的名称 + var fileName = Path.GetFileName(_httpFileDownloadBuilder.DestinationPath); + + // 空检查 + if (!string.IsNullOrWhiteSpace(fileName)) + { + return fileName; + } + + // 尝试从响应标头 Content-Disposition 中解析 + var contentDisposition = httpResponseMessage.Content.Headers.ContentDisposition; + if (!string.IsNullOrWhiteSpace(contentDisposition?.FileNameStar)) + { + // 使用 UTF-8 解码文件的名称 + fileName = Uri.UnescapeDataString(contentDisposition.FileNameStar); + } + else if (!string.IsNullOrWhiteSpace(contentDisposition?.FileName)) + { + // 使用 UTF-8 解码文件的名称 + fileName = Uri.UnescapeDataString(contentDisposition.FileName); + } + + // 空检查 + if (string.IsNullOrWhiteSpace(fileName)) + { + // 尝试从原始的请求地址中解析 + fileName = Helpers.GetFileNameFromUri(httpResponseMessage.RequestMessage?.RequestUri); + } + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + + return fileName; + } + + /// + /// 移动临时文件至文件保存的目标路径 + /// + /// + /// + /// + /// 临时文件路径 + /// 文件保存的目标路径 + internal static void MoveTempFileToDestinationPath(FileStream fileStream, string tempDestinationPath, + string destinationPath) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fileStream); + ArgumentException.ThrowIfNullOrWhiteSpace(tempDestinationPath); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + // 检查临时文件是否存在 + if (!File.Exists(tempDestinationPath)) + { + throw new FileNotFoundException($"The temp destination path `{tempDestinationPath}` does not exist."); + } + + // 获取文件保存的目标目录 + var destinationDirectory = Path.GetDirectoryName(destinationPath); + + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + + // 如果目录不存在则创建 + if (!Directory.Exists(destinationDirectory)) + { + Directory.CreateDirectory(destinationDirectory); + } + + // 如果下载成功,则移动临时文件到文件保存的目标路径(文件存在则替换) + fileStream.Close(); + File.Move(tempDestinationPath, destinationPath, true); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/FileUploadManager.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/FileUploadManager.cs new file mode 100644 index 000000000..b4901eba5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/FileUploadManager.cs @@ -0,0 +1,320 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using System.Diagnostics; +using System.Threading.Channels; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// 文件上传管理器 +/// +internal sealed class FileUploadManager +{ + /// + internal readonly HttpFileUploadBuilder _httpFileUploadBuilder; + + /// + internal readonly IHttpRemoteService _httpRemoteService; + + /// + /// 文件传输进度信息的通道 + /// + internal readonly Channel _progressChannel; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 自定义配置委托 + internal FileUploadManager(IHttpRemoteService httpRemoteService, HttpFileUploadBuilder httpFileUploadBuilder, + Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteService); + ArgumentNullException.ThrowIfNull(httpFileUploadBuilder); + + _httpRemoteService = httpRemoteService; + _httpFileUploadBuilder = httpFileUploadBuilder; + + // 初始化文件传输进度信息的通道 + _progressChannel = Channel.CreateUnbounded(); + + // 解析 IHttpFileTransferEventHandler 事件处理程序 + FileTransferEventHandler = (httpFileUploadBuilder.FileTransferEventHandlerType is not null + ? httpRemoteService.ServiceProvider.GetService(httpFileUploadBuilder.FileTransferEventHandlerType) + : null) as IHttpFileTransferEventHandler; + + // 构建 HttpRequestBuilder 实例 + RequestBuilder = httpFileUploadBuilder.Build(httpRemoteService.ServiceProvider + .GetRequiredService>().Value, _progressChannel, configure); + } + + /// + /// + /// + internal HttpRequestBuilder RequestBuilder { get; } + + /// + /// + /// + internal IHttpFileTransferEventHandler? FileTransferEventHandler { get; } + + /// + /// 开始上传 + /// + /// + /// + /// + /// + /// + /// + /// + internal HttpResponseMessage Start(CancellationToken cancellationToken = default) + { + HttpResponseMessage httpResponseMessage; + + // 创建关联的取消标识 + using var progressCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 初始化进度报告任务 + var reportProgressTask = ReportProgressAsync(progressCancellationTokenSource.Token); + + // 处理文件传输开始 + HandleTransferStarted(); + + // 初始化 Stopwatch 实例并开启计时操作 + var stopwatch = Stopwatch.StartNew(); + + try + { + // 发送 HTTP 远程请求 + httpResponseMessage = _httpRemoteService.Send(RequestBuilder, cancellationToken); + + // 计算文件传输总花费时间 + var duration = stopwatch.ElapsedMilliseconds; + + // 处理文件传输完成 + HandleTransferCompleted(duration); + } + catch (Exception e) + { + // 处理文件传输失败 + HandleTransferFailed(e); + + throw; + } + finally + { + // 停止计时 + stopwatch.Stop(); + + // 关闭通道 + _progressChannel.Writer.Complete(); + + // 等待进度报告任务完成 + progressCancellationTokenSource.Cancel(); + reportProgressTask.Wait(cancellationToken); + } + + return httpResponseMessage; + } + + /// + /// 开始上传 + /// + /// + /// + /// + /// + /// + /// + internal async Task StartAsync(CancellationToken cancellationToken = default) + { + HttpResponseMessage httpResponseMessage; + + // 创建关联的取消标识 + using var progressCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 初始化进度报告任务 + var reportProgressTask = ReportProgressAsync(progressCancellationTokenSource.Token); + + // 处理文件传输开始 + HandleTransferStarted(); + + // 初始化 Stopwatch 实例并开启计时操作 + var stopwatch = Stopwatch.StartNew(); + + try + { + // 发送 HTTP 远程请求 + httpResponseMessage = await _httpRemoteService.SendAsync(RequestBuilder, cancellationToken).ConfigureAwait(false); + + // 计算文件传输总花费时间 + var duration = stopwatch.ElapsedMilliseconds; + + // 处理文件传输完成 + HandleTransferCompleted(duration); + } + catch (Exception e) + { + // 处理文件传输失败 + HandleTransferFailed(e); + + throw; + } + finally + { + // 停止计时 + stopwatch.Stop(); + + // 关闭通道 + _progressChannel.Writer.Complete(); + + // 等待进度报告任务完成 + await progressCancellationTokenSource.CancelAsync().ConfigureAwait(false); + await reportProgressTask.ConfigureAwait(false); + } + + return httpResponseMessage; + } + + /// + /// 文件传输进度报告任务 + /// + /// + /// + /// + internal async Task ReportProgressAsync(CancellationToken cancellationToken) + { + // 空检查 + if (_httpFileUploadBuilder.OnProgressChanged is null && FileTransferEventHandler is null) + { + return; + } + + try + { + // 从进度通道中读取所有的进度信息 + await foreach (var fileTransferProgress in _progressChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + try + { + // 处理文件传输进度变化 + await HandleProgressChangedAsync(fileTransferProgress).ConfigureAwait(false); + + // 根据配置的进度更新(通知)的间隔时间延迟进度报告 + await Task.Delay(_httpFileUploadBuilder.ProgressInterval, cancellationToken).ConfigureAwait(false); + } + // 捕获当通道关闭或操作被取消时的异常 + catch (Exception e) when (cancellationToken.IsCancellationRequested || + e is ChannelClosedException or OperationCanceledException) + { + // 处理文件传输进度变化 + await HandleProgressChangedAsync(fileTransferProgress).ConfigureAwait(false); + + break; + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + } + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + // 任务被取消 + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + + /// + /// 处理文件传输开始 + /// + internal void HandleTransferStarted() + { + // 空检查 + if (FileTransferEventHandler is not null) + { + DelegateExtensions.TryInvoke(FileTransferEventHandler.OnTransferStarted); + } + + _httpFileUploadBuilder.OnTransferStarted.TryInvoke(); + } + + /// + /// 处理文件传输完成 + /// + /// 文件传输总花费时间 + internal void HandleTransferCompleted(long duration) + { + // 空检查 + if (FileTransferEventHandler is not null) + { + DelegateExtensions.TryInvoke(FileTransferEventHandler.OnTransferCompleted, duration); + } + + _httpFileUploadBuilder.OnTransferCompleted.TryInvoke(duration); + } + + /// + /// 处理文件传输失败 + /// + /// + /// + /// + internal void HandleTransferFailed(Exception e) + { + // 空检查 + if (FileTransferEventHandler is not null) + { + DelegateExtensions.TryInvoke(FileTransferEventHandler.OnTransferFailed, e); + } + + _httpFileUploadBuilder.OnTransferFailed.TryInvoke(e); + } + + /// + /// 处理文件传输进度变化 + /// + /// + /// + /// + internal async Task HandleProgressChangedAsync(FileTransferProgress fileTransferProgress) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fileTransferProgress); + + // 空检查 + if (FileTransferEventHandler is not null) + { + await DelegateExtensions.TryInvokeAsync(FileTransferEventHandler.OnProgressChangedAsync, + fileTransferProgress).ConfigureAwait(false); + } + + await _httpFileUploadBuilder.OnProgressChanged.TryInvokeAsync(fileTransferProgress).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/LongPollingManager.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/LongPollingManager.cs new file mode 100644 index 000000000..685800350 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/LongPollingManager.cs @@ -0,0 +1,452 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using System.Threading.Channels; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// 长轮询管理器 +/// +internal sealed class LongPollingManager +{ + /// + /// 数据接收传输的通道 + /// + internal readonly Channel _dataChannel; + + /// + internal readonly HttpLongPollingBuilder _httpLongPollingBuilder; + + /// + internal readonly IHttpRemoteService _httpRemoteService; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 自定义配置委托 + internal LongPollingManager(IHttpRemoteService httpRemoteService, HttpLongPollingBuilder httpLongPollingBuilder, + Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteService); + ArgumentNullException.ThrowIfNull(httpLongPollingBuilder); + + _httpRemoteService = httpRemoteService; + _httpLongPollingBuilder = httpLongPollingBuilder; + + // 初始化数据接收传输的通道 + _dataChannel = Channel.CreateUnbounded(); + + // 解析 IHttpLongPollingEventHandler 事件处理程序 + LongPollingEventHandler = (httpLongPollingBuilder.LongPollingEventHandlerType is not null + ? httpRemoteService.ServiceProvider.GetService(httpLongPollingBuilder.LongPollingEventHandlerType) + : null) as IHttpLongPollingEventHandler; + + // 构建 HttpRequestBuilder 实例 + RequestBuilder = httpLongPollingBuilder.Build(httpRemoteService.ServiceProvider + .GetRequiredService>().Value, configure); + } + + /// + /// 当前重试次数 + /// + internal int CurrentRetries { get; private set; } + + /// + /// + /// + internal HttpRequestBuilder RequestBuilder { get; } + + /// + /// + /// + internal IHttpLongPollingEventHandler? LongPollingEventHandler { get; } + + /// + /// 开始请求 + /// + /// + /// + /// + /// + internal void Start(CancellationToken cancellationToken = default) + { + // 创建关联的取消标识 + using var fetchCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 初始化接收服务器响应数据任务 + var fetchResponseTask = FetchResponseAsync(fetchCancellationTokenSource.Token); + + // 声明取消接收标识 + var isCancelled = false; + + try + { + // 循环读取数据直到取消请求或读取完毕 + while (!cancellationToken.IsCancellationRequested) + { + // 发送 HTTP 远程请求 + var httpResponseMessage = _httpRemoteService.Send(RequestBuilder, cancellationToken); + + // 发送响应数据对象到通道 + _dataChannel.Writer.TryWrite(httpResponseMessage); + + // 检查是否应该终止长轮询 + if (ShouldTerminatePolling(httpResponseMessage)) + { + break; + } + + // 检查是否请求成功 + if (httpResponseMessage.IsSuccessStatusCode) + { + // 重置当前重试次数 + CurrentRetries = 0; + } + } + } + // 任务被取消 + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + // 标识客户端中止事件消息接收 + isCancelled = true; + + throw; + } + catch (Exception e) + { + // 检查是否达到了最大当前重试次数 + if (CurrentRetries < _httpLongPollingBuilder.MaxRetries) + { + // 重新开始接收 + Retry(cancellationToken); + } + else + { + throw new InvalidOperationException( + $"Failed to establish server connection after `{_httpLongPollingBuilder.MaxRetries}` attempts.", + e); + } + } + finally + { + if (isCancelled) + { + // 关闭通道 + _dataChannel.Writer.Complete(); + } + + // 等待接收服务器响应数据任务完成 + fetchCancellationTokenSource.Cancel(); + fetchResponseTask.Wait(cancellationToken); + } + } + + /// + /// 开始请求 + /// + /// + /// + /// + /// + internal async Task StartAsync(CancellationToken cancellationToken = default) + { + // 创建关联的取消标识 + using var fetchCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 初始化接收服务器响应数据任务 + var fetchResponseTask = FetchResponseAsync(fetchCancellationTokenSource.Token); + + // 声明取消接收标识 + var isCancelled = false; + + try + { + // 循环读取数据直到取消请求或读取完毕 + while (!cancellationToken.IsCancellationRequested) + { + // 发送 HTTP 远程请求 + var httpResponseMessage = await _httpRemoteService.SendAsync(RequestBuilder, cancellationToken).ConfigureAwait(false); + + // 发送响应数据对象到通道 + await _dataChannel.Writer.WriteAsync(httpResponseMessage, cancellationToken).ConfigureAwait(false); + + // 检查是否应该终止长轮询 + if (ShouldTerminatePolling(httpResponseMessage)) + { + break; + } + + // 检查是否请求成功 + if (httpResponseMessage.IsSuccessStatusCode) + { + // 重置当前重试次数 + CurrentRetries = 0; + } + } + } + // 任务被取消 + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + // 标识客户端中止事件消息接收 + isCancelled = true; + + throw; + } + catch (Exception e) + { + // 检查是否达到了最大当前重试次数 + if (CurrentRetries < _httpLongPollingBuilder.MaxRetries) + { + // 重新开始接收 + await RetryAsync(cancellationToken).ConfigureAwait(false); + } + else + { + throw new InvalidOperationException( + $"Failed to establish server connection after `{_httpLongPollingBuilder.MaxRetries}` attempts.", + e); + } + } + finally + { + if (isCancelled) + { + // 关闭通道 + _dataChannel.Writer.Complete(); + } + + // 等待接收服务器响应数据任务完成 + await fetchCancellationTokenSource.CancelAsync().ConfigureAwait(false); + await fetchResponseTask.ConfigureAwait(false); + } + } + + /// + /// 重新开始请求 + /// + /// + /// + /// + internal void Retry(CancellationToken cancellationToken = default) + { + // 递增当前重试次数 + CurrentRetries++; + + // 根据配置的重新连接的间隔时间延迟重新开始请求 + Task.Delay(_httpLongPollingBuilder.RetryInterval, cancellationToken).Wait(cancellationToken); + + // 重新开始接收 + Start(cancellationToken); + } + + /// + /// 重新开始请求 + /// + /// + /// + /// + internal async Task RetryAsync(CancellationToken cancellationToken = default) + { + // 递增当前重试次数 + CurrentRetries++; + + // 根据配置的重新连接的间隔时间延迟重新开始请求 + await Task.Delay(_httpLongPollingBuilder.RetryInterval, cancellationToken).ConfigureAwait(false); + + // 重新开始接收 + await StartAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// 检查是否应该终止长轮询 + /// + /// + /// + /// + /// + /// + /// + internal bool ShouldTerminatePolling(HttpResponseMessage httpResponseMessage) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpResponseMessage); + + // 检查响应标头中是否存在长轮询结束符 + if (httpResponseMessage.Headers.TryGetValues(Constants.X_END_OF_STREAM_HEADER, out _)) + { + return true; + } + + // 如果响应状态码不是成功的,则递增当前重试次数 + if (!httpResponseMessage.IsSuccessStatusCode) + { + CurrentRetries++; + } + + return CurrentRetries >= _httpLongPollingBuilder.MaxRetries; + } + + /// + /// 接收服务器响应数据任务 + /// + /// + /// + /// + internal async Task FetchResponseAsync(CancellationToken cancellationToken) + { + // 空检查 + if (_httpLongPollingBuilder.OnDataReceived is null && _httpLongPollingBuilder.OnError is null && + LongPollingEventHandler is null) + { + return; + } + + try + { + // 从数据接收传输的通道中读取所有的数据 + await foreach (var httpResponseMessage in _dataChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + try + { + // 处理服务器响应数据 + await HandleResponseAsync(httpResponseMessage).ConfigureAwait(false); + } + // 捕获当通道关闭或操作被取消时的异常 + catch (Exception e) when (cancellationToken.IsCancellationRequested || + e is ChannelClosedException or OperationCanceledException) + { + // 处理服务器响应数据 + await HandleResponseAsync(httpResponseMessage).ConfigureAwait(false); + + break; + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + } + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + // 任务被取消 + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + + /// + /// 处理服务器响应数据 + /// + /// + /// + /// + internal async Task HandleResponseAsync(HttpResponseMessage httpResponseMessage) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpResponseMessage); + + // 检查响应标头中是否存在长轮询结束符 + if (httpResponseMessage.Headers.TryGetValues(Constants.X_END_OF_STREAM_HEADER, out _)) + { + await HandleEndOfStreamAsync(httpResponseMessage).ConfigureAwait(false); + + return; + } + + // 处理服务器返回 200~299 状态码的数据 + if (httpResponseMessage.IsSuccessStatusCode) + { + await HandleDataReceivedAsync(httpResponseMessage).ConfigureAwait(false); + } + // 处理服务器返回非 200~299 状态码的数据 + else + { + await HandleErrorAsync(httpResponseMessage).ConfigureAwait(false); + } + } + + /// + /// 处理服务器返回 200~299 状态码的数据 + /// + /// + /// + /// + internal async Task HandleDataReceivedAsync(HttpResponseMessage httpResponseMessage) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpResponseMessage); + + // 空检查 + if (LongPollingEventHandler is not null) + { + await DelegateExtensions.TryInvokeAsync(LongPollingEventHandler.OnDataReceivedAsync, httpResponseMessage).ConfigureAwait(false); + } + + await _httpLongPollingBuilder.OnDataReceived.TryInvokeAsync(httpResponseMessage).ConfigureAwait(false); + } + + /// + /// 处理服务器返回非 200~299 状态码的数据 + /// + /// + /// + /// + internal async Task HandleErrorAsync(HttpResponseMessage httpResponseMessage) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpResponseMessage); + + // 空检查 + if (LongPollingEventHandler is not null) + { + await DelegateExtensions.TryInvokeAsync(LongPollingEventHandler.OnErrorAsync, httpResponseMessage).ConfigureAwait(false); + } + + await _httpLongPollingBuilder.OnError.TryInvokeAsync(httpResponseMessage).ConfigureAwait(false); + } + + /// + /// 处理服务器响应标头包含 X-End-Of-Stream 时触发的操作 + /// + /// + /// + /// + internal async Task HandleEndOfStreamAsync(HttpResponseMessage httpResponseMessage) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpResponseMessage); + + // 空检查 + if (LongPollingEventHandler is not null) + { + await DelegateExtensions.TryInvokeAsync(LongPollingEventHandler.OnEndOfStreamAsync, httpResponseMessage).ConfigureAwait(false); + } + + await _httpLongPollingBuilder.OnEndOfStream.TryInvokeAsync(httpResponseMessage).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/ServerSentEventsManager.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/ServerSentEventsManager.cs new file mode 100644 index 000000000..dc67983c3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/ServerSentEventsManager.cs @@ -0,0 +1,506 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Threading.Channels; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// Server-Sent Events 管理器 +/// +/// 参考文献:https://developer.mozilla.org/zh-CN/docs/Web/API/Server-sent_events/Using_server-sent_events。 +internal sealed class ServerSentEventsManager +{ + /// + internal readonly IHttpRemoteService _httpRemoteService; + + /// + internal readonly HttpServerSentEventsBuilder _httpServerSentEventsBuilder; + + /// + /// 事件消息传输的通道 + /// + internal readonly Channel _messageChannel; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 自定义配置委托 + internal ServerSentEventsManager(IHttpRemoteService httpRemoteService, + HttpServerSentEventsBuilder httpServerSentEventsBuilder, + Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteService); + ArgumentNullException.ThrowIfNull(httpServerSentEventsBuilder); + + _httpRemoteService = httpRemoteService; + _httpServerSentEventsBuilder = httpServerSentEventsBuilder; + CurrentRetryInterval = httpServerSentEventsBuilder.DefaultRetryInterval; + CurrentRetries = 0; + + // 初始化事件消息传输的通道 + _messageChannel = Channel.CreateUnbounded(); + + // 解析 IHttpServerSentEventsEventHandler 事件处理程序 + ServerSentEventsEventHandler = (httpServerSentEventsBuilder.ServerSentEventsEventHandlerType is not null + ? httpRemoteService.ServiceProvider.GetService(httpServerSentEventsBuilder.ServerSentEventsEventHandlerType) + : null) as IHttpServerSentEventsEventHandler; + + // 构建 HttpRequestBuilder 实例 + RequestBuilder = httpServerSentEventsBuilder.Build(httpRemoteService.ServiceProvider + .GetRequiredService>().Value, configure); + } + + /// + /// 当前重新连接的时间(毫秒) + /// + internal int CurrentRetryInterval { get; private set; } + + /// + /// 当前重试次数 + /// + internal int CurrentRetries { get; private set; } + + /// + /// + /// + internal HttpRequestBuilder RequestBuilder { get; } + + /// + /// + /// + internal IHttpServerSentEventsEventHandler? ServerSentEventsEventHandler { get; } + + /// + /// 开始接收 + /// + /// + /// + /// + /// + internal void Start(CancellationToken cancellationToken = default) + { + // 创建关联的取消标识 + using var messageCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 初始化接收事件消息任务 + var receiveDataTask = ReceiveDataAsync(messageCancellationTokenSource.Token); + + // 处理与事件源的连接打开 + HandleOpen(); + + // 声明取消接收标识 + var isCancelled = false; + + try + { + // 发送 HTTP 远程请求 + var httpResponseMessage = _httpRemoteService.Send(RequestBuilder, HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + // 获取 HTTP 响应体中的内容流 + using var contentStream = httpResponseMessage.Content.ReadAsStream(cancellationToken); + + // 初始化 StreamReader 实例 + using var reader = new StreamReader(contentStream, Encoding.UTF8); + + // 声明 ServerSentEventsData 变量 + ServerSentEventsData? serverSentEventsData = null; + + // 循环读取数据直到取消请求或读取完毕 + while (!cancellationToken.IsCancellationRequested && reader.ReadLine() is { } line) + { + // 尝试解析事件消息行文本 + if (!TryParseEventLine(line, ref serverSentEventsData)) + { + continue; + } + + // 检查是否已经收集了一个完整的事件 + if (!IsEventComplete(serverSentEventsData)) + { + continue; + } + + // 重置当前重试次数 + CurrentRetries = 0; + + // 发送事件数据到通道 + _messageChannel.Writer.TryWrite(serverSentEventsData); + + // 重置 ServerSentEventsData 实例,等待下一个事件 + serverSentEventsData = null; + } + } + // 任务被取消 + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + // 标识客户端中止事件消息接收 + isCancelled = true; + + throw; + } + catch (Exception e) + { + // 处理与事件源的连接错误 + HandleError(e); + + // 检查是否达到了最大当前重试次数 + if (CurrentRetries < _httpServerSentEventsBuilder.MaxRetries) + { + // 重新开始接收 + Retry(cancellationToken); + } + else + { + throw new InvalidOperationException( + $"Failed to establish Server-Sent Events connection after `{_httpServerSentEventsBuilder.MaxRetries}` attempts.", + e); + } + } + finally + { + if (isCancelled) + { + // 关闭通道 + _messageChannel.Writer.Complete(); + } + + // 等待接收事件消息任务完成 + messageCancellationTokenSource.Cancel(); + receiveDataTask.Wait(cancellationToken); + } + } + + /// + /// 开始接收 + /// + /// + /// + /// + /// + internal async Task StartAsync(CancellationToken cancellationToken = default) + { + // 创建关联的取消标识 + using var messageCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 初始化接收事件消息任务 + var receiveDataTask = ReceiveDataAsync(messageCancellationTokenSource.Token); + + // 处理与事件源的连接打开 + HandleOpen(); + + // 声明取消接收标识 + var isCancelled = false; + + try + { + // 发送 HTTP 远程请求 + var httpResponseMessage = await _httpRemoteService.SendAsync(RequestBuilder, + HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + // 获取 HTTP 响应体中的内容流 + using var contentStream = (await httpResponseMessage.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)); + + // 初始化 StreamReader 实例 + using var reader = new StreamReader(contentStream, Encoding.UTF8); + + // 声明 ServerSentEventsData 变量 + ServerSentEventsData? serverSentEventsData = null; + + // 循环读取数据直到取消请求或读取完毕 + while (!cancellationToken.IsCancellationRequested && + await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) + { + // 尝试解析事件消息行文本 + if (!TryParseEventLine(line, ref serverSentEventsData)) + { + continue; + } + + // 检查是否已经收集了一个完整的事件 + if (!IsEventComplete(serverSentEventsData)) + { + continue; + } + + // 重置当前重试次数 + CurrentRetries = 0; + + // 发送事件数据到通道 + await _messageChannel.Writer.WriteAsync(serverSentEventsData, cancellationToken).ConfigureAwait(false); + + // 重置 ServerSentEventsData 实例,等待下一个事件 + serverSentEventsData = null; + } + } + // 任务被取消 + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + // 标识客户端中止事件消息接收 + isCancelled = true; + + throw; + } + catch (Exception e) + { + // 处理与事件源的连接错误 + HandleError(e); + + // 检查是否达到了最大当前重试次数 + if (CurrentRetries < _httpServerSentEventsBuilder.MaxRetries) + { + // 重新开始接收 + await RetryAsync(cancellationToken).ConfigureAwait(false); + } + else + { + throw new InvalidOperationException( + $"Failed to establish Server-Sent Events connection after `{_httpServerSentEventsBuilder.MaxRetries}` attempts.", + e); + } + } + finally + { + if (isCancelled) + { + // 关闭通道 + _messageChannel.Writer.Complete(); + } + + // 等待接收事件消息任务完成 + await messageCancellationTokenSource.CancelAsync().ConfigureAwait(false); + await receiveDataTask.ConfigureAwait(false); + } + } + + /// + /// 重新开始接收 + /// + /// + /// + /// + internal void Retry(CancellationToken cancellationToken = default) + { + // 递增当前重试次数 + CurrentRetries++; + + // 根据配置的重新连接的间隔时间延迟重新开始接收 + Task.Delay(CurrentRetryInterval, cancellationToken).Wait(cancellationToken); + + // 重新开始接收 + Start(cancellationToken); + } + + /// + /// 重新开始接收 + /// + /// + /// + /// + internal async Task RetryAsync(CancellationToken cancellationToken = default) + { + // 递增当前重试次数 + CurrentRetries++; + + // 根据配置的重新连接的间隔时间延迟重新开始接收 + await Task.Delay(CurrentRetryInterval, cancellationToken).ConfigureAwait(false); + + // 重新开始接收 + await StartAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// 检查是否已经收集了一个完整的事件 + /// + /// + /// + /// + /// + /// + /// + internal static bool IsEventComplete(ServerSentEventsData serverSentEventsData) + { + // 空检查 + ArgumentNullException.ThrowIfNull(serverSentEventsData); + + return serverSentEventsData.Data.Length > 0; + } + + /// + /// 尝试解析事件消息行文本 + /// + /// + /// + /// + /// + /// + /// + /// + internal bool TryParseEventLine(string line, [NotNullWhen(true)] ref ServerSentEventsData? serverSentEventsData) + { + // 空检查(忽略空白行和注释行) + if (string.IsNullOrWhiteSpace(line) || line.StartsWith(':')) + { + return false; + } + + // 初始化 ServerSentEventsData 实例 + serverSentEventsData ??= new ServerSentEventsData(); + + // 采用冒号对行文本进行分割 + var parts = line.Split(':'); + var key = parts[0].Trim(); + + // 如果一行文本中不包含冒号,则整行文本会被解析成为字段名,其字段值为空 + var value = parts.Length > 1 ? parts[1].Trim() : string.Empty; + + switch (key) + { + case "event": + serverSentEventsData.Event = value; + break; + case "data": + serverSentEventsData.AppendData(value); + break; + case "id": + serverSentEventsData.Id = value; + break; + case "retry": + CurrentRetryInterval = serverSentEventsData.Retry = int.TryParse(value, out var retryInterval) + ? retryInterval + : _httpServerSentEventsBuilder.DefaultRetryInterval; + break; + // 所有其他的字段名都会被忽略 + default: + serverSentEventsData = null; + return false; + } + + return true; + } + + /// + /// 接收事件消息任务 + /// + /// + /// + /// + internal async Task ReceiveDataAsync(CancellationToken cancellationToken) + { + // 空检查 + if (_httpServerSentEventsBuilder.OnMessage is null && ServerSentEventsEventHandler is null) + { + return; + } + + try + { + // 从事件消息传输的通道中读取所有的事件消息 + await foreach (var serverSentEventsData in _messageChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + try + { + // 处理服务器发送的事件消息 + await HandleMessageReceivedAsync(serverSentEventsData).ConfigureAwait(false); + } + // 捕获当通道关闭或操作被取消时的异常 + catch (Exception e) when (cancellationToken.IsCancellationRequested || + e is ChannelClosedException or OperationCanceledException) + { + // 处理服务器发送的事件消息 + await HandleMessageReceivedAsync(serverSentEventsData).ConfigureAwait(false); + + break; + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + } + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + // 任务被取消 + } + catch (Exception e) + { + // 输出调试事件 + Debugging.Error(e.Message); + } + } + + /// + /// 处理与事件源的连接打开 + /// + internal void HandleOpen() + { + // 空检查 + if (ServerSentEventsEventHandler is not null) + { + DelegateExtensions.TryInvoke(ServerSentEventsEventHandler.OnOpen); + } + + _httpServerSentEventsBuilder.OnOpen.TryInvoke(); + } + + /// + /// 处理与事件源的连接错误 + /// + /// + /// + /// + internal void HandleError(Exception e) + { + // 空检查 + if (ServerSentEventsEventHandler is not null) + { + DelegateExtensions.TryInvoke(ServerSentEventsEventHandler.OnError, e); + } + + _httpServerSentEventsBuilder.OnError.TryInvoke(e); + } + + /// + /// 处理服务器发送的事件消息 + /// + /// + /// + /// + internal async Task HandleMessageReceivedAsync(ServerSentEventsData serverSentEventsData) + { + // 空检查 + ArgumentNullException.ThrowIfNull(serverSentEventsData); + + // 空检查 + if (ServerSentEventsEventHandler is not null) + { + await DelegateExtensions.TryInvokeAsync(ServerSentEventsEventHandler.OnMessageAsync, serverSentEventsData).ConfigureAwait(false); + } + + await _httpServerSentEventsBuilder.OnMessage.TryInvokeAsync(serverSentEventsData).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/StressTestHarnessManager.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/StressTestHarnessManager.cs new file mode 100644 index 000000000..5fd367dae --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Managers/StressTestHarnessManager.cs @@ -0,0 +1,207 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using System.Diagnostics; + +namespace ThingsGateway.HttpRemote; + +/// +/// 压力测试管理器 +/// +internal sealed class StressTestHarnessManager +{ + /// + internal readonly IHttpRemoteService _httpRemoteService; + + /// + internal readonly HttpStressTestHarnessBuilder _httpStressTestHarnessBuilder; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 自定义配置委托 + internal StressTestHarnessManager(IHttpRemoteService httpRemoteService, + HttpStressTestHarnessBuilder httpStressTestHarnessBuilder, + Action? configure = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteService); + ArgumentNullException.ThrowIfNull(httpStressTestHarnessBuilder); + + _httpRemoteService = httpRemoteService; + _httpStressTestHarnessBuilder = httpStressTestHarnessBuilder; + + // 构建 HttpRequestBuilder 实例 + RequestBuilder = httpStressTestHarnessBuilder.Build(httpRemoteService.ServiceProvider + .GetRequiredService>().Value, configure); + } + + /// + /// + /// + internal HttpRequestBuilder RequestBuilder { get; } + + /// + /// 开始测试 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal StressTestHarnessResult Start( + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default) => + StartAsync(completionOption, cancellationToken).GetAwaiter().GetResult(); + + /// + /// 开始测试 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal async Task StartAsync( + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default) + { + // 初始化压力测试次数和轮次 + var numberOfRequests = _httpStressTestHarnessBuilder.NumberOfRequests; + var numberOfRounds = _httpStressTestHarnessBuilder.NumberOfRounds; + + // 初始化总的成功/失败的请求数量 + var totalSuccessfulRequests = 0L; + var totalFailedRequests = 0L; + + // 用于记录每个请求的响应时间 + var allResponseTimes = new List(); + + // 初始化总的测试时间 + var totalTime = TimeSpan.Zero; + + // 初始化信号量来控制并发度 + var semaphoreSlim = new SemaphoreSlim(_httpStressTestHarnessBuilder.MaxDegreeOfParallelism); + + // 初始化 Stopwatch 实例并开启计时操作 + var stopwatch = Stopwatch.StartNew(); + + // 循环执行指定轮次 + for (var round = 0; round < numberOfRounds && !cancellationToken.IsCancellationRequested; round++) + { + // 初始化 Task 数组来存储所有并发任务 + var tasks = new Task[numberOfRequests]; + + // 重置响应时间数组 + var responseTimes = new long[numberOfRequests]; + + // 重新开始计时 + stopwatch.Restart(); + + // 循环创建指定并发请求数量的任务 + for (var i = 0; i < numberOfRequests && !cancellationToken.IsCancellationRequested; i++) + { + var index = i; + + // 创建新的异步任务 + tasks[i] = Task.Run(async () => + { + // 等待信号量 + await semaphoreSlim.WaitAsync(cancellationToken).ConfigureAwait(false); + + // 请求开始时间 + var requestStart = Stopwatch.GetTimestamp(); + + try + { + // 发送 HTTP 远程请求 + var httpResponseMessage = + await _httpRemoteService.SendAsync(RequestBuilder, completionOption, cancellationToken).ConfigureAwait(false); + + // 检查响应状态码是否是成功状态 + if (httpResponseMessage.IsSuccessStatusCode) + { + // 原子递增成功请求计数 + Interlocked.Increment(ref totalSuccessfulRequests); + } + else + { + // 原子递增失败请求计数 + Interlocked.Increment(ref totalFailedRequests); + } + } + // 任务被取消 + catch (Exception e) when (cancellationToken.IsCancellationRequested || + e is OperationCanceledException) + { + throw; + } + catch (Exception) + { + // 原子递增失败请求计数 + Interlocked.Increment(ref totalFailedRequests); + } + finally + { + // 计算并存储请求的响应时间 + var requestEnd = Stopwatch.GetTimestamp(); + responseTimes[index] = requestEnd - requestStart; + + // 释放信号量 + semaphoreSlim.Release(); + } + }, cancellationToken); + } + + // 等待所有任务完成 + await Task.WhenAll(tasks).ConfigureAwait(false); + + // 记录本轮测试结束时间,并累加总的测试时间 + totalTime += stopwatch.Elapsed; + + // 将本轮的响应时间追加到总的响应时间数组中 + allResponseTimes.AddRange(responseTimes); + } + + // 停止计时 + stopwatch.Stop(); + + // 获取请求总用时(秒) + var totalTimeInSeconds = totalTime.TotalSeconds; + + // 释放资源集合 + RequestBuilder.ReleaseResources(); + + return new StressTestHarnessResult( + numberOfRequests * numberOfRounds, + totalTimeInSeconds, + totalSuccessfulRequests, + totalFailedRequests, + allResponseTimes.ToArray()); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/DigestCredentials.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/DigestCredentials.cs new file mode 100644 index 000000000..befe02f87 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/DigestCredentials.cs @@ -0,0 +1,230 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Net.Http.Headers; + +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +using ThingsGateway.HttpRemote.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// 摘要认证 +/// +public sealed class DigestCredentials +{ + /// + /// 用户名 + /// + public string? Username { get; private init; } + + /// + /// 密码 + /// + public string? Password { get; private init; } + + /// + /// 服务器提供的认证领域 + /// + /// 服务器通过 WWW-Authenticate 响应标头返回。 + public string? Realm { get; private init; } + + /// + /// 服务器提供的随机数 + /// + /// 服务器通过 WWW-Authenticate 响应标头返回。 + public string? Nonce { get; private init; } + + /// + /// 保护质量 + /// + /// 服务器通过 WWW-Authenticate 响应标头返回。 + public string? Qop { get; private init; } + + /// + /// 非一次性计数器 + /// + public int Nc { get; private init; } + + /// + /// 客户端提供的随机数 + /// + public string? CNonce { get; private init; } + + /// + /// 服务器提供的不透明数据 + /// + /// 服务器通过 WWW-Authenticate 响应标头返回,客户端需原样回去。 + public string? Opaque { get; private init; } + + /// + /// 获取 Digest 摘要认证授权凭证 + /// + /// 请求地址 + /// 用户名 + /// 密码 + /// + /// + /// + /// + /// + /// + /// + public static string GetDigestCredentials(string? requestUri, string username, string password, + HttpMethod httpMethod) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(requestUri); + ArgumentException.ThrowIfNullOrWhiteSpace(username); + ArgumentException.ThrowIfNullOrWhiteSpace(password); + ArgumentNullException.ThrowIfNull(httpMethod); + + // 初始化 HttpClient 实例 + using var httpClient = new HttpClient(); + + // 设置默认 User-Agent + httpClient.DefaultRequestHeaders.TryAddWithoutValidation(HeaderNames.UserAgent, + Constants.USER_AGENT_OF_BROWSER); + + // 启用性能优化 + httpClient.PerformanceOptimization(); + + try + { + // 发送 HTTP 远程请求 + var httpResponseMessage = httpClient.Send(new HttpRequestMessage(httpMethod, requestUri), + HttpCompletionOption.ResponseHeadersRead); + + // 检查响应状态码是否是 401 且响应标头是否包含 WWW-Authenticate + if (httpResponseMessage is not + { StatusCode: HttpStatusCode.Unauthorized, Headers.WwwAuthenticate.Count: > 0 }) + { + throw new InvalidOperationException( + "Unable to initiate digest authentication: The server did not return a 401 Unauthorized status or the `WWW-Authenticate` header is missing."); + } + + // 创建 DigestCredentials 实例并生成授权凭证 + var digestCredentials = + Create(username, password, httpResponseMessage.Headers.WwwAuthenticate.First().ToString()) + .GenerateCredentials(httpResponseMessage.RequestMessage?.RequestUri?.PathAndQuery, httpMethod); + + return digestCredentials; + } + catch (Exception e) + { + throw new InvalidOperationException("Failed to obtain digest credentials.", e); + } + } + + /// + /// 创建 实例 + /// + /// 用户名 + /// 密码 + /// 服务器响应标头 WWW-Authenticate 的值 + /// + /// + /// + internal static DigestCredentials Create(string username, string password, string wwwAuthenticateValue) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(username); + ArgumentException.ThrowIfNullOrWhiteSpace(password); + ArgumentException.ThrowIfNullOrWhiteSpace(wwwAuthenticateValue); + + // 从响应标头 WWW-Authenticate 的值中解析各个参数 + var realm = ExtractParameterValueFromHeader("realm", wwwAuthenticateValue); + var nonce = ExtractParameterValueFromHeader("nonce", wwwAuthenticateValue); + var qop = ExtractParameterValueFromHeader("qop", wwwAuthenticateValue); + var opaque = ExtractParameterValueFromHeader("opaque", wwwAuthenticateValue); + var cnonce = RandomNumberGenerator.GetInt32(123400, 9999999).ToString(); + + // 初始化 DigestCredentials 实例 + return new DigestCredentials + { + Username = username, + Password = password, + Realm = realm, + Nonce = nonce, + Qop = qop, + Nc = 1, // 注意 + CNonce = cnonce, + Opaque = opaque + }; + } + + /// + /// 生成摘要认证授权凭证 + /// + /// 请求相对地址(不包含主机地址) + /// + /// + /// + /// + /// + /// + internal string GenerateCredentials(string? digestUri, HttpMethod method) + { + var ha1 = GenerateMd5Hash($"{Username}:{Realm}:{Password}"); + var ha2 = GenerateMd5Hash($"{method}:{digestUri}"); + + var digestResponse = + GenerateMd5Hash( + $"{ha1}:{Nonce}:{Nc:00000000}:{CNonce}:{Qop}:{ha2}"); + + var credentials = + $"username=\"{Username}\", realm=\"{Realm}\", nonce=\"{Nonce}\", uri=\"{digestUri}\", " + + $"algorithm=MD5, qop={Qop}, nc={Nc:00000000}, cnonce=\"{CNonce}\", " + + $"response=\"{digestResponse}\", opaque=\"{Opaque}\""; + + return credentials; + } + + /// + /// 从服务器响应标头 WWW-Authenticate 的值中提取参数值 + /// + /// 参数名 + /// 服务器响应标头 WWW-Authenticate 的值 + /// + /// + /// + internal static string? ExtractParameterValueFromHeader(string name, string wwwAuthenticateValue) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(wwwAuthenticateValue); + + var match = new Regex($""" + {name}="([^"]*)" + """).Match(wwwAuthenticateValue); + + return match.Success ? match.Groups[1].Value : null; + } + + /// + /// 生成 MD5 哈希 + /// + /// 值 + /// + /// + /// + internal static string GenerateMd5Hash(string input) + { + // 空检查 + ArgumentNullException.ThrowIfNull(input); + + return string.Concat(MD5.HashData(Encoding.UTF8.GetBytes(input)).Select(x => x.ToString("x2"))); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/FileTransferProgress.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/FileTransferProgress.cs new file mode 100644 index 000000000..dcba64dff --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/FileTransferProgress.cs @@ -0,0 +1,155 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Extensions; +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// 文件传输的进度信息 +/// +public sealed class FileTransferProgress +{ + /// + /// 使用一个小的正值来防止除零错误 + /// + internal const double _epsilon = double.Epsilon; + + /// + /// + /// + /// 文件路径 + /// 文件的总大小 + /// 文件的名称 + internal FileTransferProgress(string filePath, long totalFileSize, string? fileName = null) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + TotalFileSize = totalFileSize; + + FilePath = filePath; + FileName = fileName ?? Path.GetFileName(filePath); + } + + /// + /// 文件路径 + /// + public string FilePath { get; } + + /// + /// 文件的名称 + /// + public string FileName { get; } + + /// + /// 文件的总大小 + /// + /// 以字节为单位。 + public long TotalFileSize { get; } + + /// + /// 已传输的数据量 + /// + /// 以字节为单位。 + public long Transferred { get; private set; } + + /// + /// 已完成的传输百分比 + /// + public double PercentageComplete { get; private set; } + + /// + /// 当前的传输速率 + /// + /// 以字节/秒为单位。 + public double TransferRate { get; private set; } + + /// + /// 从开始传输到现在的持续时间 + /// + public TimeSpan TimeElapsed { get; private set; } + + /// + /// 预估剩余传输时间 + /// + public TimeSpan EstimatedTimeRemaining { get; private set; } + + /// + public override string ToString() => + StringUtility.FormatKeyValuesSummary([ + new KeyValuePair>("File Name", [FileName]), + new KeyValuePair>("File Path", [FilePath]), + new KeyValuePair>("Total File Size", + [$"{TotalFileSize.ToSizeUnits("MB"):F2} MB"]), + new KeyValuePair>("Transferred", [$"{Transferred.ToSizeUnits("MB"):F2} MB"]), + new KeyValuePair>("Percentage Complete", [$"{PercentageComplete:F2}%"]), + new KeyValuePair>("Transfer Rate", + [$"{TransferRate.ToSizeUnits("MB"):F2} MB/s"]), + new KeyValuePair>("Time Elapsed (s)", [$"{TimeElapsed.TotalSeconds:F2}"]), + new KeyValuePair>("Estimated Time Remaining (s)", + [$"{EstimatedTimeRemaining.TotalSeconds:F2}"]) + ], "Transfer Progress")!; + + /// + /// 输出简要进度字符串 + /// + /// + /// + /// + public string ToSummaryString() => + $"Transferred {Transferred.ToSizeUnits("MB"):F2} MB of {TotalFileSize.ToSizeUnits("MB"):F2} MB ({PercentageComplete:F2}% complete, Speed: {TransferRate.ToSizeUnits("MB"):F2} MB/s, Time: {TimeElapsed.TotalSeconds:F2}s, ETA: {EstimatedTimeRemaining.TotalSeconds:F2}s), File: {FileName}, Path: {FilePath}."; + + /// + /// 更新文件传输进度 + /// + /// 已传输的数据量 + /// 从开始传输到现在的持续时间 + internal void UpdateProgress(long transferred, TimeSpan timeElapsed) + { + // 计算已完成的传输百分比和当前的传输速率 + var percentageComplete = TotalFileSize > 0 ? 100.0 * transferred / TotalFileSize : -1; + var transferRate = timeElapsed.TotalSeconds > _epsilon ? transferred / timeElapsed.TotalSeconds : 0; + + // 更新内部进度状态 + Transferred = transferred; + TimeElapsed = timeElapsed; + PercentageComplete = percentageComplete; + TransferRate = transferRate; + + // 计算预估剩余传输时间 + EstimatedTimeRemaining = CalculateEstimatedTimeRemaining(); + } + + /// + /// 计算预估剩余传输时间 + /// + /// + /// + /// + internal TimeSpan CalculateEstimatedTimeRemaining() + { + // 如果文件大小小于等于 0 或传输速率为 0 或接近 0,则认为无法预估 + if (TotalFileSize <= 0 || TransferRate <= _epsilon) + { + return TimeSpan.MaxValue; + } + + // 计算剩余时间 + var secondsRemaining = (TotalFileSize - Transferred) / TransferRate; + + // 如果剩余时间超过最大值,则返回最大值 + return secondsRemaining > TimeSpan.MaxValue.TotalSeconds + ? TimeSpan.MaxValue + : TimeSpan.FromSeconds(secondsRemaining); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/FileTypeMapper.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/FileTypeMapper.cs new file mode 100644 index 000000000..db29c36ae --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/FileTypeMapper.cs @@ -0,0 +1,490 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; + +namespace ThingsGateway.HttpRemote; + +/// +/// 据文件扩展名提供内容类型 +/// +public sealed class FileTypeMapper +{ + /// + /// + /// + public FileTypeMapper() : this(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { ".323", "text/h323" }, + { ".3g2", "video/3gpp2" }, + { ".3gp2", "video/3gpp2" }, + { ".3gp", "video/3gpp" }, + { ".3gpp", "video/3gpp" }, + { ".aac", "audio/aac" }, + { ".aaf", "application/octet-stream" }, + { ".aca", "application/octet-stream" }, + { ".accdb", "application/msaccess" }, + { ".accde", "application/msaccess" }, + { ".accdt", "application/msaccess" }, + { ".acx", "application/internet-property-stream" }, + { ".adt", "audio/vnd.dlna.adts" }, + { ".adts", "audio/vnd.dlna.adts" }, + { ".afm", "application/octet-stream" }, + { ".ai", "application/postscript" }, + { ".aif", "audio/x-aiff" }, + { ".aifc", "audio/aiff" }, + { ".aiff", "audio/aiff" }, + { ".appcache", "text/cache-manifest" }, + { ".application", "application/x-ms-application" }, + { ".art", "image/x-jg" }, + { ".asd", "application/octet-stream" }, + { ".asf", "video/x-ms-asf" }, + { ".asi", "application/octet-stream" }, + { ".asm", "text/plain" }, + { ".asr", "video/x-ms-asf" }, + { ".asx", "video/x-ms-asf" }, + { ".atom", "application/atom+xml" }, + { ".au", "audio/basic" }, + { ".avi", "video/x-msvideo" }, + { ".axs", "application/olescript" }, + { ".bas", "text/plain" }, + { ".bcpio", "application/x-bcpio" }, + { ".bin", "application/octet-stream" }, + { ".bmp", "image/bmp" }, + { ".c", "text/plain" }, + { ".cab", "application/vnd.ms-cab-compressed" }, + { ".calx", "application/vnd.ms-office.calx" }, + { ".cat", "application/vnd.ms-pki.seccat" }, + { ".cdf", "application/x-cdf" }, + { ".chm", "application/octet-stream" }, + { ".class", "application/x-java-applet" }, + { ".clp", "application/x-msclip" }, + { ".cmx", "image/x-cmx" }, + { ".cnf", "text/plain" }, + { ".cod", "image/cis-cod" }, + { ".cpio", "application/x-cpio" }, + { ".cpp", "text/plain" }, + { ".crd", "application/x-mscardfile" }, + { ".crl", "application/pkix-crl" }, + { ".crt", "application/x-x509-ca-cert" }, + { ".csh", "application/x-csh" }, + { ".css", "text/css" }, + { ".csv", "text/csv" }, // https://tools.ietf.org/html/rfc7111#section-5.1 + { ".cur", "application/octet-stream" }, + { ".dcr", "application/x-director" }, + { ".deploy", "application/octet-stream" }, + { ".der", "application/x-x509-ca-cert" }, + { ".dib", "image/bmp" }, + { ".dir", "application/x-director" }, + { ".disco", "text/xml" }, + { ".dlm", "text/dlm" }, + { ".doc", "application/msword" }, + { ".docm", "application/vnd.ms-word.document.macroEnabled.12" }, + { ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, + { ".dot", "application/msword" }, + { ".dotm", "application/vnd.ms-word.template.macroEnabled.12" }, + { ".dotx", "application/vnd.openxmlformats-officedocument.wordprocessingml.template" }, + { ".dsp", "application/octet-stream" }, + { ".dtd", "text/xml" }, + { ".dvi", "application/x-dvi" }, + { ".dvr-ms", "video/x-ms-dvr" }, + { ".dwf", "drawing/x-dwf" }, + { ".dwp", "application/octet-stream" }, + { ".dxr", "application/x-director" }, + { ".eml", "message/rfc822" }, + { ".emz", "application/octet-stream" }, + { ".eot", "application/vnd.ms-fontobject" }, + { ".eps", "application/postscript" }, + { ".etx", "text/x-setext" }, + { ".evy", "application/envoy" }, + { + ".exe", "application/vnd.microsoft.portable-executable" + }, // https://www.iana.org/assignments/media-types/application/vnd.microsoft.portable-executable + { ".fdf", "application/vnd.fdf" }, + { ".fif", "application/fractals" }, + { ".fla", "application/octet-stream" }, + { ".flr", "x-world/x-vrml" }, + { ".flv", "video/x-flv" }, + { ".gif", "image/gif" }, + { ".gtar", "application/x-gtar" }, + { ".gz", "application/x-gzip" }, + { ".h", "text/plain" }, + { ".hdf", "application/x-hdf" }, + { ".hdml", "text/x-hdml" }, + { ".hhc", "application/x-oleobject" }, + { ".hhk", "application/octet-stream" }, + { ".hhp", "application/octet-stream" }, + { ".hlp", "application/winhlp" }, + { ".hqx", "application/mac-binhex40" }, + { ".hta", "application/hta" }, + { ".htc", "text/x-component" }, + { ".htm", "text/html" }, + { ".html", "text/html" }, + { ".htt", "text/webviewhtml" }, + { ".hxt", "text/html" }, + { ".ical", "text/calendar" }, + { ".icalendar", "text/calendar" }, + { ".ico", "image/x-icon" }, + { ".ics", "text/calendar" }, + { ".ief", "image/ief" }, + { ".ifb", "text/calendar" }, + { ".iii", "application/x-iphone" }, + { ".inf", "application/octet-stream" }, + { ".ins", "application/x-internet-signup" }, + { ".isp", "application/x-internet-signup" }, + { ".IVF", "video/x-ivf" }, + { ".jar", "application/java-archive" }, + { ".java", "application/octet-stream" }, + { ".jck", "application/liquidmotion" }, + { ".jcz", "application/liquidmotion" }, + { ".jfif", "image/pjpeg" }, + { ".jpb", "application/octet-stream" }, + { ".jpe", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".jpg", "image/jpeg" }, + { ".js", "text/javascript" }, + { ".json", "application/json" }, + { ".jsx", "text/jscript" }, + { ".latex", "application/x-latex" }, + { ".lit", "application/x-ms-reader" }, + { ".lpk", "application/octet-stream" }, + { ".lsf", "video/x-la-asf" }, + { ".lsx", "video/x-la-asf" }, + { ".lzh", "application/octet-stream" }, + { ".m13", "application/x-msmediaview" }, + { ".m14", "application/x-msmediaview" }, + { ".m1v", "video/mpeg" }, + { ".m2ts", "video/vnd.dlna.mpeg-tts" }, + { ".m3u", "audio/x-mpegurl" }, + { ".m4a", "audio/mp4" }, + { ".m4v", "video/mp4" }, + { ".man", "application/x-troff-man" }, + { ".manifest", "application/x-ms-manifest" }, + { ".map", "text/plain" }, + { ".markdown", "text/markdown" }, + { ".md", "text/markdown" }, + { ".mdb", "application/x-msaccess" }, + { ".mdp", "application/octet-stream" }, + { ".me", "application/x-troff-me" }, + { ".mht", "message/rfc822" }, + { ".mhtml", "message/rfc822" }, + { ".mid", "audio/mid" }, + { ".midi", "audio/mid" }, + { ".mix", "application/octet-stream" }, + { ".mjs", "text/javascript" }, + { ".mmf", "application/x-smaf" }, + { ".mno", "text/xml" }, + { ".mny", "application/x-msmoney" }, + { ".mov", "video/quicktime" }, + { ".movie", "video/x-sgi-movie" }, + { ".mp2", "video/mpeg" }, + { ".mp3", "audio/mpeg" }, + { ".mp4", "video/mp4" }, + { ".mp4v", "video/mp4" }, + { ".mpa", "video/mpeg" }, + { ".mpe", "video/mpeg" }, + { ".mpeg", "video/mpeg" }, + { ".mpg", "video/mpeg" }, + { ".mpp", "application/vnd.ms-project" }, + { ".mpv2", "video/mpeg" }, + { ".ms", "application/x-troff-ms" }, + { ".msi", "application/octet-stream" }, + { ".mso", "application/octet-stream" }, + { ".mvb", "application/x-msmediaview" }, + { ".mvc", "application/x-miva-compiled" }, + { ".nc", "application/x-netcdf" }, + { ".nsc", "video/x-ms-asf" }, + { ".nws", "message/rfc822" }, + { ".ocx", "application/octet-stream" }, + { ".oda", "application/oda" }, + { ".odc", "text/x-ms-odc" }, + { ".ods", "application/oleobject" }, + { ".oga", "audio/ogg" }, + { ".ogg", "video/ogg" }, + { ".ogv", "video/ogg" }, + { ".ogx", "application/ogg" }, + { ".one", "application/onenote" }, + { ".onea", "application/onenote" }, + { ".onetoc", "application/onenote" }, + { ".onetoc2", "application/onenote" }, + { ".onetmp", "application/onenote" }, + { ".onepkg", "application/onenote" }, + { ".osdx", "application/opensearchdescription+xml" }, + { ".otf", "font/otf" }, + { ".p10", "application/pkcs10" }, + { ".p12", "application/x-pkcs12" }, + { ".p7b", "application/x-pkcs7-certificates" }, + { ".p7c", "application/pkcs7-mime" }, + { ".p7m", "application/pkcs7-mime" }, + { ".p7r", "application/x-pkcs7-certreqresp" }, + { ".p7s", "application/pkcs7-signature" }, + { ".pbm", "image/x-portable-bitmap" }, + { ".pcx", "application/octet-stream" }, + { ".pcz", "application/octet-stream" }, + { ".pdf", "application/pdf" }, + { ".pfb", "application/octet-stream" }, + { ".pfm", "application/octet-stream" }, + { ".pfx", "application/x-pkcs12" }, + { ".pgm", "image/x-portable-graymap" }, + { ".pko", "application/vnd.ms-pki.pko" }, + { ".pma", "application/x-perfmon" }, + { ".pmc", "application/x-perfmon" }, + { ".pml", "application/x-perfmon" }, + { ".pmr", "application/x-perfmon" }, + { ".pmw", "application/x-perfmon" }, + { ".png", "image/png" }, + { ".pnm", "image/x-portable-anymap" }, + { ".pnz", "image/png" }, + { ".pot", "application/vnd.ms-powerpoint" }, + { ".potm", "application/vnd.ms-powerpoint.template.macroEnabled.12" }, + { ".potx", "application/vnd.openxmlformats-officedocument.presentationml.template" }, + { ".ppam", "application/vnd.ms-powerpoint.addin.macroEnabled.12" }, + { ".ppm", "image/x-portable-pixmap" }, + { ".pps", "application/vnd.ms-powerpoint" }, + { ".ppsm", "application/vnd.ms-powerpoint.slideshow.macroEnabled.12" }, + { ".ppsx", "application/vnd.openxmlformats-officedocument.presentationml.slideshow" }, + { ".ppt", "application/vnd.ms-powerpoint" }, + { ".pptm", "application/vnd.ms-powerpoint.presentation.macroEnabled.12" }, + { ".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, + { ".prf", "application/pics-rules" }, + { ".prm", "application/octet-stream" }, + { ".prx", "application/octet-stream" }, + { ".ps", "application/postscript" }, + { ".psd", "application/octet-stream" }, + { ".psm", "application/octet-stream" }, + { ".psp", "application/octet-stream" }, + { ".pub", "application/x-mspublisher" }, + { ".qt", "video/quicktime" }, + { ".qtl", "application/x-quicktimeplayer" }, + { ".qxd", "application/octet-stream" }, + { ".ra", "audio/x-pn-realaudio" }, + { ".ram", "audio/x-pn-realaudio" }, + { ".rar", "application/octet-stream" }, + { ".ras", "image/x-cmu-raster" }, + { ".rf", "image/vnd.rn-realflash" }, + { ".rgb", "image/x-rgb" }, + { ".rm", "application/vnd.rn-realmedia" }, + { ".rmi", "audio/mid" }, + { ".roff", "application/x-troff" }, + { ".rpm", "audio/x-pn-realaudio-plugin" }, + { ".rtf", "application/rtf" }, + { ".rtx", "text/richtext" }, + { ".scd", "application/x-msschedule" }, + { ".sct", "text/scriptlet" }, + { ".sea", "application/octet-stream" }, + { ".setpay", "application/set-payment-initiation" }, + { ".setreg", "application/set-registration-initiation" }, + { ".sgml", "text/sgml" }, + { ".sh", "application/x-sh" }, + { ".shar", "application/x-shar" }, + { ".sit", "application/x-stuffit" }, + { ".sldm", "application/vnd.ms-powerpoint.slide.macroEnabled.12" }, + { ".sldx", "application/vnd.openxmlformats-officedocument.presentationml.slide" }, + { ".smd", "audio/x-smd" }, + { ".smi", "application/octet-stream" }, + { ".smx", "audio/x-smd" }, + { ".smz", "audio/x-smd" }, + { ".snd", "audio/basic" }, + { ".snp", "application/octet-stream" }, + { ".spc", "application/x-pkcs7-certificates" }, + { ".spl", "application/futuresplash" }, + { ".spx", "audio/ogg" }, + { ".src", "application/x-wais-source" }, + { ".ssm", "application/streamingmedia" }, + { ".sst", "application/vnd.ms-pki.certstore" }, + { ".stl", "application/vnd.ms-pki.stl" }, + { ".sv4cpio", "application/x-sv4cpio" }, + { ".sv4crc", "application/x-sv4crc" }, + { ".svg", "image/svg+xml" }, + { ".svgz", "image/svg+xml" }, + { ".swf", "application/x-shockwave-flash" }, + { ".t", "application/x-troff" }, + { ".tar", "application/x-tar" }, + { ".tcl", "application/x-tcl" }, + { ".tex", "application/x-tex" }, + { ".texi", "application/x-texinfo" }, + { ".texinfo", "application/x-texinfo" }, + { ".tgz", "application/x-compressed" }, + { ".thmx", "application/vnd.ms-officetheme" }, + { ".thn", "application/octet-stream" }, + { ".tif", "image/tiff" }, + { ".tiff", "image/tiff" }, + { ".toc", "application/octet-stream" }, + { ".tr", "application/x-troff" }, + { ".trm", "application/x-msterminal" }, + { ".ts", "video/vnd.dlna.mpeg-tts" }, + { ".tsv", "text/tab-separated-values" }, + { ".ttc", "application/x-font-ttf" }, + { ".ttf", "application/x-font-ttf" }, + { ".tts", "video/vnd.dlna.mpeg-tts" }, + { ".txt", "text/plain" }, + { ".u32", "application/octet-stream" }, + { ".uls", "text/iuls" }, + { ".ustar", "application/x-ustar" }, + { ".vbs", "text/vbscript" }, + { ".vcf", "text/x-vcard" }, + { ".vcs", "text/plain" }, + { ".vdx", "application/vnd.ms-visio.viewer" }, + { ".vml", "text/xml" }, + { ".vsd", "application/vnd.visio" }, + { ".vss", "application/vnd.visio" }, + { ".vst", "application/vnd.visio" }, + { ".vsto", "application/x-ms-vsto" }, + { ".vsw", "application/vnd.visio" }, + { ".vsx", "application/vnd.visio" }, + { ".vtx", "application/vnd.visio" }, + { ".wasm", "application/wasm" }, + { ".wav", "audio/wav" }, + { ".wax", "audio/x-ms-wax" }, + { ".wbmp", "image/vnd.wap.wbmp" }, + { ".wcm", "application/vnd.ms-works" }, + { ".wdb", "application/vnd.ms-works" }, + { ".webm", "video/webm" }, + { ".webmanifest", "application/manifest+json" }, // https://w3c.github.io/manifest/#media-type-registration + { ".webp", "image/webp" }, + { ".wks", "application/vnd.ms-works" }, + { ".wm", "video/x-ms-wm" }, + { ".wma", "audio/x-ms-wma" }, + { ".wmd", "application/x-ms-wmd" }, + { ".wmf", "application/x-msmetafile" }, + { ".wml", "text/vnd.wap.wml" }, + { ".wmlc", "application/vnd.wap.wmlc" }, + { ".wmls", "text/vnd.wap.wmlscript" }, + { ".wmlsc", "application/vnd.wap.wmlscriptc" }, + { ".wmp", "video/x-ms-wmp" }, + { ".wmv", "video/x-ms-wmv" }, + { ".wmx", "video/x-ms-wmx" }, + { ".wmz", "application/x-ms-wmz" }, + { ".woff", "application/font-woff" }, // https://www.w3.org/TR/WOFF/#appendix-b + { ".woff2", "font/woff2" }, // https://www.w3.org/TR/WOFF2/#IMT + { ".wps", "application/vnd.ms-works" }, + { ".wri", "application/x-mswrite" }, + { ".wrl", "x-world/x-vrml" }, + { ".wrz", "x-world/x-vrml" }, + { ".wsdl", "text/xml" }, + { ".wtv", "video/x-ms-wtv" }, + { ".wvx", "video/x-ms-wvx" }, + { ".x", "application/directx" }, + { ".xaf", "x-world/x-vrml" }, + { ".xaml", "application/xaml+xml" }, + { ".xap", "application/x-silverlight-app" }, + { ".xbap", "application/x-ms-xbap" }, + { ".xbm", "image/x-xbitmap" }, + { ".xdr", "text/plain" }, + { ".xht", "application/xhtml+xml" }, + { ".xhtml", "application/xhtml+xml" }, + { ".xla", "application/vnd.ms-excel" }, + { ".xlam", "application/vnd.ms-excel.addin.macroEnabled.12" }, + { ".xlc", "application/vnd.ms-excel" }, + { ".xlm", "application/vnd.ms-excel" }, + { ".xls", "application/vnd.ms-excel" }, + { ".xlsb", "application/vnd.ms-excel.sheet.binary.macroEnabled.12" }, + { ".xlsm", "application/vnd.ms-excel.sheet.macroEnabled.12" }, + { ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, + { ".xlt", "application/vnd.ms-excel" }, + { ".xltm", "application/vnd.ms-excel.template.macroEnabled.12" }, + { ".xltx", "application/vnd.openxmlformats-officedocument.spreadsheetml.template" }, + { ".xlw", "application/vnd.ms-excel" }, + { ".xml", "text/xml" }, + { ".xof", "x-world/x-vrml" }, + { ".xpm", "image/x-xpixmap" }, + { ".xps", "application/vnd.ms-xpsdocument" }, + { ".xsd", "text/xml" }, + { ".xsf", "text/xml" }, + { ".xsl", "text/xml" }, + { ".xslt", "text/xml" }, + { ".xsn", "application/octet-stream" }, + { ".xtp", "application/octet-stream" }, + { ".xwd", "image/x-xwindowdump" }, + { ".z", "application/x-compress" }, + { ".zip", "application/x-zip-compressed" }, + { ".m3u8", "application/vnd.apple.mpegurl" }, + { ".cdr", "application/cdr" }, + { ".apk", "application/vnd.android.package-archive" }, + { ".pem", "application/x-pem-file" }, + { ".deb", "application/vnd.debian.binary-package" }, + { ".7z", "application/x-7z-compressed" }, + { ".nupkg", "application/vnd.nuget.package" }, + { ".snupkg", "application/vnd.nuget.package" } + }) + { + } + + /// + /// + /// + /// 文件拓展名及其对应内容类型映射字典 + public FileTypeMapper(IDictionary mapping) + { + // 空检查 + ArgumentNullException.ThrowIfNull(mapping); + + Mappings = mapping; + } + + /// + /// 文件拓展名及其对应内容类型映射字典 + /// + public IDictionary Mappings { get; } + + /// + /// 尝试根据文件路径获取拓展名 + /// + /// 文件路径 + /// 内容类型 + /// + /// + /// + public bool TryGetContentType(string subpath, [MaybeNullWhen(false)] out string contentType) + { + // 根据文件路径获取拓展名 + var extension = GetExtension(subpath); + + // 空检查 + if (extension is not null) + { + return Mappings.TryGetValue(extension, out contentType); + } + + contentType = null; + return false; + } + + /// + /// 根据文件路径获取拓展名 + /// + /// 文件路径 + /// 默认内容类型 + /// + /// + /// + internal static string GetContentType(string subpath, string defaultContentType = "application/octet-stream") => + new FileTypeMapper().TryGetContentType(subpath, out var contentType) ? contentType : defaultContentType; + + /// + /// 根据文件路径获取拓展名 + /// + /// 文件路径 + /// + /// + /// + internal static string? GetExtension(string path) + { + // 空检查 + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + var index = path.LastIndexOf('.'); + return index < 0 ? null : path[index..]; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/HttpClientPooling.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/HttpClientPooling.cs new file mode 100644 index 000000000..be49dece5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/HttpClientPooling.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 管理 实例及及其释放操作 +/// +internal sealed class HttpClientPooling +{ + /// + /// + /// + /// + /// + /// + /// 用于释放 实例的方法委托 + internal HttpClientPooling(HttpClient httpClient, Action? release) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpClient); + + Instance = httpClient; + Release = release; + } + + /// + /// + /// + internal HttpClient Instance { get; } + + /// + /// 用于释放 实例的方法委托 + /// + internal Action? Release { get; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/HttpRemoteResult.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/HttpRemoteResult.cs new file mode 100644 index 000000000..c4b3eb33a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/HttpRemoteResult.cs @@ -0,0 +1,186 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Net.Http.Headers; + +using System.Net; +using System.Net.Http.Headers; + +using ThingsGateway.HttpRemote.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求结果 +/// +/// 用于将原始的 进行包装转换。 +/// 转换的目标类型 +public sealed class HttpRemoteResult +{ + /// + /// + /// + /// + /// + /// + public HttpRemoteResult(HttpResponseMessage httpResponseMessage) + { + ResponseMessage = httpResponseMessage; + + // 初始化 + Initialize(); + } + + /// + public HttpResponseMessage ResponseMessage { get; } + + /// + /// 内容类型 + /// + public string? ContentType { get; private set; } + + /// + /// 字符集 + /// + public string? CharSet { get; private set; } + + /// + /// 内容编码 + /// + public ICollection ContentEncoding { get; private set; } = null!; + + /// + /// 内容大小 + /// + public long? ContentLength { get; private set; } + + /// + /// 原始响应标头 Set-Cookie 集合 + /// + public List? RawSetCookies { get; private set; } + + /// + /// 集合 + /// + public IList? SetCookies { get; private set; } + + /// + /// 响应状态码 + /// + public HttpStatusCode StatusCode { get; private set; } + + /// + /// 是否请求成功 + /// + public bool IsSuccessStatusCode { get; private set; } + + /// + /// + /// + /// 注意 HEAD 请求不包含响应体。 + public TResult? Result { get; internal init; } + + /// + /// 请求耗时(毫秒) + /// + public long RequestDuration { get; internal init; } + + /// + /// 响应标头 + /// + public HttpResponseHeaders Headers { get; private set; } = null!; + + /// + /// 响应体标头 + /// + public HttpContentHeaders ContentHeaders { get; private set; } = null!; + + /// + /// 初始化 + /// + internal void Initialize() + { + // 解析响应状态码 + ParseStatusCode(); + + // 解析响应标头 + ParseHeaders(); + + // 解析响应内容标头部分信息 + ParseContentMetadata(ResponseMessage.Content.Headers); + + // 解析响应标头 Set-Cookie 集合 + ParseSetCookies(ResponseMessage.Headers); + } + + /// + /// 解析响应状态码 + /// + internal void ParseStatusCode() + { + StatusCode = ResponseMessage.StatusCode; + IsSuccessStatusCode = ResponseMessage.IsSuccessStatusCode; + } + + /// + /// 解析响应标头 + /// + internal void ParseHeaders() + { + Headers = ResponseMessage.Headers; + ContentHeaders = ResponseMessage.Content.Headers; + } + + /// + /// 解析响应体标头元数据 + /// + /// + /// + /// + internal void ParseContentMetadata(HttpContentHeaders contentHeaders) + { + ContentLength = contentHeaders.ContentLength; + ContentType = contentHeaders.ContentType?.MediaType; + CharSet = contentHeaders.ContentType?.CharSet; + ContentEncoding = contentHeaders.ContentEncoding; + } + + /// + /// 解析响应标头 Set-Cookie 集合 + /// + /// + /// + /// + internal void ParseSetCookies(HttpResponseHeaders responseHeaders) + { + // 检查响应标头是否包含 Set-Cookie 设置 + if (!responseHeaders.TryGetValues(HeaderNames.SetCookie, out var setCookieValues)) + { + return; + } + + RawSetCookies = setCookieValues.ToList(); + SetCookies = SetCookieHeaderValue.ParseList(RawSetCookies); + } + + /// + public override string ToString() + { + // 格式化请求条目 + var requestEntry = ResponseMessage.RequestMessage?.ProfilerHeaders(); + + // 格式化常规和响应条目 + var generalAndResponseEntry = ResponseMessage.ProfilerGeneralAndHeaders(generalCustomKeyValues: + [new KeyValuePair>("Request Duration (ms)", [$"{RequestDuration:N2}"])]); + + return $"{requestEntry}\r\n{generalAndResponseEntry}"; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/Logging.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/Logging.cs new file mode 100644 index 000000000..8c43158ef --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/Logging.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程服务日志类别 +/// +public sealed class Logging; \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/MultipartFile.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/MultipartFile.cs new file mode 100644 index 000000000..fc1a0300d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/MultipartFile.cs @@ -0,0 +1,177 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// 多部分表单文件 +/// +/// 使用 MultipartFile.CreateFrom[Source] 静态方法创建。 +public sealed class MultipartFile +{ + /// + /// + /// + internal MultipartFile() + { + } + + /// + /// 表单名称 + /// + public string? Name { get; private set; } + + /// + /// 文件的名称 + /// + public string? FileName { get; private set; } + + /// + /// 内容类型 + /// + public string? ContentType { get; private set; } + + /// + /// 内容编码 + /// + public Encoding? ContentEncoding { get; private set; } + + /// + /// 文件来源 + /// + public object? Source { get; private set; } + + /// + /// + /// + internal FileSourceType FileSourceType { get; private set; } + + /// + /// 从字节数组中添加文件 + /// + /// 字节数组 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public static MultipartFile CreateFromByteArray(byte[] byteArray, string name = "file", string? fileName = null, + string? contentType = null, Encoding? contentEncoding = null) => + new() + { + Name = name, + FileName = fileName, + ContentType = contentType, + ContentEncoding = contentEncoding, + Source = byteArray, + FileSourceType = FileSourceType.ByteArray + }; + + /// + /// 从 中添加文件 + /// + /// + /// + /// + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public static MultipartFile CreateFromStream(Stream stream, string name = "file", string? fileName = null, + string? contentType = null, Encoding? contentEncoding = null) => + new() + { + Name = name, + FileName = fileName, + ContentType = contentType, + ContentEncoding = contentEncoding, + Source = stream, + FileSourceType = FileSourceType.Stream + }; + + /// + /// 从本地路径中添加文件 + /// + /// 文件路径 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public static MultipartFile CreateFromPath(string filePath, string name = "file", string? fileName = null, + string? contentType = null, Encoding? contentEncoding = null) => + new() + { + Name = name, + FileName = fileName, + ContentType = contentType, + ContentEncoding = contentEncoding, + Source = filePath, + FileSourceType = FileSourceType.Path + }; + + /// + /// 从 Base64 字符串中添加文件 + /// + /// 文件大小限制在 100MB 以内。 + /// Base64 字符串 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public static MultipartFile CreateFromBase64String(string base64String, string name = "file", + string? fileName = null, string? contentType = null, Encoding? contentEncoding = null) => + new() + { + Name = name, + FileName = fileName, + ContentType = contentType, + ContentEncoding = contentEncoding, + Source = base64String, + FileSourceType = FileSourceType.Base64String + }; + + /// + /// 从互联网 URL 中添加文件 + /// + /// 文件大小限制在 100MB 以内。 + /// 互联网 URL 地址 + /// 表单名称 + /// 文件的名称 + /// 内容类型 + /// 内容编码 + /// + /// + /// + public static MultipartFile CreateFromRemote(string url, string name = "file", string? fileName = null, + string? contentType = null, Encoding? contentEncoding = null) => + new() + { + Name = name, + FileName = fileName, + ContentType = contentType, + ContentEncoding = contentEncoding, + Source = url, + FileSourceType = FileSourceType.Remote + }; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/MultipartFormDataItem.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/MultipartFormDataItem.cs new file mode 100644 index 000000000..2a5cfce11 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/MultipartFormDataItem.cs @@ -0,0 +1,58 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// 条目 +/// +internal sealed class MultipartFormDataItem +{ + /// + /// + /// + /// 表单名称 + internal MultipartFormDataItem(string name) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + } + + /// + /// 表单名称 + /// + internal string Name { get; } + + /// + /// 内容类型 + /// + internal string? ContentType { get; init; } + + /// + /// 内容编码 + /// + internal Encoding? ContentEncoding { get; init; } + + /// + /// 原始请求内容 + /// + /// 此属性值最终将转换为 类型实例。 + internal object? RawContent { get; init; } + + /// + /// 文件的名称 + /// + internal string? FileName { get; init; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/ProgressFileStream.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/ProgressFileStream.cs new file mode 100644 index 000000000..6521d8386 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/ProgressFileStream.cs @@ -0,0 +1,190 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics; +using System.Threading.Channels; + +namespace ThingsGateway.HttpRemote; + +/// +/// 带读写进度的文件流 +/// +internal sealed class ProgressFileStream : Stream +{ + /// + /// 文件大小 + /// + internal readonly long _fileLength; + + /// + internal readonly Stream _fileStream; + + /// + internal readonly FileTransferProgress _fileTransferProgress; + + /// + /// 文件传输进度信息的通道 + /// + internal readonly Channel _progressChannel; + + /// + internal readonly Stopwatch _stopwatch; + + /// + /// 是否已经开始读取或写入 + /// + internal bool _hasStarted; + + /// + /// 已传输的数据量 + /// + internal long _transferred; + + /// + /// + /// + /// + /// + /// + /// 文件路径或文件的名称 + /// 文件传输进度信息的通道 + /// 文件的名称 + internal ProgressFileStream(Stream fileStream, string filePath, Channel progressChannel, + string? fileName = null) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fileStream); + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + ArgumentNullException.ThrowIfNull(progressChannel); + + _fileStream = fileStream; + _fileLength = fileStream.Length; + _progressChannel = progressChannel; + + // 初始化 FileTransferProgress 实例 + _fileTransferProgress = new FileTransferProgress(filePath, _fileLength, fileName); + + // 初始化 Stopwatch 实例并开启计时操作 + _stopwatch = Stopwatch.StartNew(); + _hasStarted = false; + } + + /// + public override bool CanRead => _fileStream.CanRead; + + /// + public override bool CanSeek => _fileStream.CanSeek; + + /// + public override bool CanWrite => _fileStream.CanWrite; + + /// + public override bool CanTimeout => _fileStream.CanTimeout; + + /// + public override long Length => _fileLength; + + /// + public override long Position + { + get => _fileStream.Position; + set + { + _fileStream.Position = value; + + // 恢复进度信息初始状态 + // ReSharper disable once InvertIf + if (_hasStarted && value == 0) + { + _transferred = 0; + _stopwatch.Restart(); + } + } + } + + /// + public override void Flush() => _fileStream.Flush(); + + /// + public override int Read(byte[] buffer, int offset, int count) + { + // 确保进度信息已初始化 + EnsureInitialized(); + + // 从文件流读取数据到缓冲区 + var bytesRead = _fileStream.Read(buffer, offset, count); + + // 报告进度 + if (bytesRead > 0) + { + ReportProgress(bytesRead); + } + + return bytesRead; + } + + /// + public override long Seek(long offset, SeekOrigin origin) => _fileStream.Seek(offset, origin); + + /// + public override void SetLength(long value) => _fileStream.SetLength(value); + + /// + public override void Write(byte[] buffer, int offset, int count) + { + // 确保进度信息已初始化 + EnsureInitialized(); + + // 向文件流写入数据 + _fileStream.Write(buffer, offset, count); + + // 报告进度 + ReportProgress(count); + } + + /// + protected override void Dispose(bool disposing) + { + // 释放托管资源 + if (disposing) + { + _fileStream.Dispose(); + _stopwatch.Stop(); + } + + base.Dispose(disposing); + } + + /// + /// 报告进度 + /// + /// 增加的数据量 + internal void ReportProgress(int increment) + { + // 更新当前已传输的数据量 + _transferred += increment; + _fileTransferProgress.UpdateProgress(_transferred, _stopwatch.Elapsed); + + // 发送文件传输进度到通道 + _progressChannel.Writer.TryWrite(_fileTransferProgress); + } + + /// + /// 确保进度信息已初始化 + /// + internal void EnsureInitialized() + { + if (!_hasStarted && Position == 0) + { + _hasStarted = true; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/RateLimitedStream.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/RateLimitedStream.cs new file mode 100644 index 000000000..48791f39e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/RateLimitedStream.cs @@ -0,0 +1,228 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics; + +namespace ThingsGateway.HttpRemote; + +/// +/// 带应用速率限制的流 +/// +/// +/// 基于令牌桶算法(Token Bucket Algorithm)实现流量控制和速率限制。 +/// 参考文献:https://baike.baidu.com/item/令牌桶算法/6597000。 +/// +public sealed class RateLimitedStream : Stream +{ + /// + /// 单次读取或写入操作中处理的最大数据块大小 + /// + internal const int CHUNK_SIZE = 4096; + + /// + /// 每秒允许传输的最大字节数 + /// + internal readonly double _bytesPerSecond; + + /// + internal readonly Stream _innerStream; + + /// + /// 用于同步访问的锁对象 + /// + internal readonly object _lockObject = new(); + + /// + /// 用来计算时间间隔的计时器 + /// + internal readonly Stopwatch _stopwatch; + + /// + /// 当前可用的令牌数量(字节数) + /// + internal double _availableTokens; + + /// + /// 上次令牌补充的时间戳 + /// + internal long _lastTokenRefillTime; + + /// + /// + /// + /// + /// + /// + /// 每秒允许传输的最大字节数 + public RateLimitedStream(Stream innerStream, double bytesPerSecond) + { + // 空检查 + ArgumentNullException.ThrowIfNull(innerStream); + + // 小于或等于 0 检查 + if (bytesPerSecond <= 0) + { + throw new ArgumentOutOfRangeException(nameof(bytesPerSecond), + "The bytes per second must be greater than zero."); + } + + _innerStream = innerStream; + _bytesPerSecond = bytesPerSecond; + + // 开始计时 + _stopwatch = Stopwatch.StartNew(); + + // 记录初始时间 + _lastTokenRefillTime = _stopwatch.ElapsedMilliseconds; + + // 初始化可用令牌数 + _availableTokens = bytesPerSecond; + } + + /// + public override bool CanRead => _innerStream.CanRead; + + /// + public override bool CanSeek => _innerStream.CanSeek; + + /// + public override bool CanWrite => _innerStream.CanWrite; + + /// + public override bool CanTimeout => _innerStream.CanTimeout; + + /// + public override long Length => _innerStream.Length; + + /// + public override long Position + { + get => _innerStream.Position; + set => _innerStream.Position = value; + } + + /// + public override void Flush() => _innerStream.Flush(); + + /// + public override int Read(byte[] buffer, int offset, int count) + { + // 确保单次读取不会超过预设的数据块大小 + var adjustedCount = Math.Min(count, CHUNK_SIZE); + + // 等待直到有足够令牌可用 + WaitForTokens(adjustedCount); + + // 从内部流读取数据到缓冲区 + return _innerStream.Read(buffer, offset, adjustedCount); + } + + /// + public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin); + + /// + public override void SetLength(long value) => _innerStream.SetLength(value); + + /// + public override void Write(byte[] buffer, int offset, int count) + { + // 确保单次写入不会超过预设的数据块大小 + var adjustedCount = Math.Min(count, CHUNK_SIZE); + + // 等待直到有足够令牌可用 + WaitForTokens(adjustedCount); + + // 向内部流写入数据 + _innerStream.Write(buffer, offset, adjustedCount); + } + + /// + protected override void Dispose(bool disposing) + { + // 释放托管资源 + if (disposing) + { + _innerStream.Dispose(); + _stopwatch.Stop(); + } + + base.Dispose(disposing); + } + + /// + /// 补充令牌的方法 + /// + internal void RefillTokens() + { + // 获取当前计时器的时间 + var now = _stopwatch.ElapsedMilliseconds; + + // 计算自上次填充令牌以来经过的时间 + var timePassed = now - _lastTokenRefillTime; + + // 如果时间没有流逝或者流逝时间不足以产生新的令牌,则直接返回 + if (timePassed <= 0) + { + return; + } + + // 据每秒允许的最大字节数以及经过的时间计算可以补充的令牌数量 + var newTokens = _bytesPerSecond * timePassed / 1000.0; + + // 更新可用令牌,但不超过每秒允许的最大值 + _availableTokens = Math.Min(_bytesPerSecond, _availableTokens + newTokens); + + // 更新最后一次填充令牌的时间戳 + _lastTokenRefillTime = now; + } + + /// + /// 等待直到有足够令牌可用 + /// + /// 需要等待的令牌数量 + internal void WaitForTokens(int desiredTokens) + { + while (true) + { + // 防止并发访问问题 + lock (_lockObject) + { + // 尝试补充令牌 + RefillTokens(); + + // 检查是否已有足够的令牌 + if (_availableTokens >= desiredTokens) + { + // 扣除所需的令牌数量 + _availableTokens -= desiredTokens; + + // 如果有足够的令牌,退出循环 + return; + } + } + + // 如果没有足够的令牌,计算还需要多少令牌 + var requiredTokens = desiredTokens - _availableTokens; + + // 计算为了获得所需令牌需要等待的时间 + var waitTime = (int)(requiredTokens * 1000.0 / _bytesPerSecond); + + // 添加一点额外延迟用来确保精确性,具体是增加了 5% 的延迟 + waitTime = (int)(waitTime * 1.05); + + // 确保不会一次性等待过长时间,最多等待 100 毫秒 + if (waitTime > 0) + { + Thread.Sleep(Math.Min(100, waitTime)); + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/ServerSentEventsData.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/ServerSentEventsData.cs new file mode 100644 index 000000000..fb87af0ca --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/ServerSentEventsData.cs @@ -0,0 +1,73 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// Server-Sent Events 事件流格式 +/// +/// 参考文献:https://developer.mozilla.org/zh-CN/docs/Web/API/Server-sent_events/Using_server-sent_events#%E5%AD%97%E6%AE%B5。 +public sealed class ServerSentEventsData +{ + /// + /// 消息数据构建器 + /// + internal readonly StringBuilder _dataBuffer; + + /// + /// 消息数据缓存字段 + /// + internal string? _cachedData; + + /// + /// + /// + internal ServerSentEventsData() => _dataBuffer = new StringBuilder(); + + /// + /// 事件类型 + /// + /// + /// 一个用于标识事件类型的字符串。如果指定了这个字符串,浏览器会将具有指定事件名称的事件分派给相应的监听器;网站源代码应该使用 addEventListener() + /// 来监听指定的事件。如果一个消息没有指定事件名称,那么 onmessage 处理程序就会被调用。 + /// + public string? Event { get; internal set; } + + /// + /// 消息 + /// + /// 消息的数据字段。当 EventSource 接收到多个以 data: 开头的连续行时,会将它们连接起来,在它们之间插入一个换行符。末尾的换行符会被删除。 + public string Data => _cachedData ??= _dataBuffer.ToString(); + + /// + /// 事件 ID + /// + /// 事件 ID,会成为当前 EventSource 对象的内部属性“最后一个事件 ID 的属性值。 + public string? Id { get; internal set; } + + /// + /// 重新连接的时间 + /// + /// 重新连接的时间。如果与服务器的连接丢失,浏览器将等待指定的时间,然后尝试重新连接。这必须是一个整数,以毫秒为单位指定重新连接的时间。如果指定了一个非整数值,该字段将被忽略。 + public int Retry { get; internal set; } + + /// + /// 追加消息数据 + /// + /// 消息数据 + internal void AppendData(string? value) + { + _dataBuffer.Append(value); + _cachedData = null; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/StressTestHarnessResult.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/StressTestHarnessResult.cs new file mode 100644 index 000000000..4a382fcf3 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/StressTestHarnessResult.cs @@ -0,0 +1,227 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics; + +using ThingsGateway.Utilities; + +namespace ThingsGateway.HttpRemote; + +/// +/// 压力测试结果 +/// +public sealed class StressTestHarnessResult +{ + /// + /// 用于将 ticks 转换为毫秒 + /// + internal static readonly double _ticksPerMillisecond = Stopwatch.Frequency / 1000.0; + + /// + /// + /// + /// 总请求次数 + /// 总用时(秒) + /// 成功请求次数 + /// 失败请求次数 + /// 请求的响应时间数组 + /// + public StressTestHarnessResult(long totalRequests, double totalTimeInSeconds, long successfulRequests, + long failedRequests, long[] responseTimes) + { + // 检查请求的响应时间数组长度是否等于总请求次数 + if (responseTimes.Length != totalRequests) + { + throw new ArgumentException( + $"The number of response times ({responseTimes.Length}) does not match the total number of requests ({totalRequests}).", + nameof(responseTimes) + ); + } + + TotalRequests = totalRequests; + TotalTimeInSeconds = totalTimeInSeconds; + SuccessfulRequests = successfulRequests; + FailedRequests = failedRequests; + + // 计算每秒查询率 (QPS) + CalculateQueriesPerSecond(totalRequests, totalTimeInSeconds); + + // 计算最小、最大和平均响应时间(毫秒) + CalculateMinMaxAvgResponseTime(responseTimes, totalRequests); + + // 计算各个百分位的响应时间(毫秒) + CalculatePercentiles(responseTimes); + } + + /// + /// 总请求次数 + /// + public long TotalRequests { get; } + + /// + /// 总用时(秒) + /// + public double TotalTimeInSeconds { get; } + + /// + /// 成功请求次数 + /// + public long SuccessfulRequests { get; } + + /// + /// 失败请求次数 + /// + public long FailedRequests { get; } + + /// + /// 每秒查询率 (QPS) + /// + public double QueriesPerSecond { get; private set; } + + /// + /// 最小响应时间(毫秒) + /// + public double MinResponseTime { get; private set; } + + /// + /// 最大响应时间(毫秒) + /// + public double MaxResponseTime { get; private set; } + + /// + /// 平均响应时间(毫秒) + /// + public double AverageResponseTime { get; private set; } + + /// + /// P10 响应时间(毫秒) + /// + public double Percentile10ResponseTime { get; private set; } + + /// + /// P25 响应时间(毫秒) + /// + public double Percentile25ResponseTime { get; private set; } + + /// + /// P50 响应时间(毫秒) + /// + public double Percentile50ResponseTime { get; private set; } + + /// + /// P75 响应时间(毫秒) + /// + public double Percentile75ResponseTime { get; private set; } + + /// + /// P90 响应时间(毫秒) + /// + public double Percentile90ResponseTime { get; private set; } + + /// + /// P99 响应时间(毫秒) + /// + public double Percentile99ResponseTime { get; private set; } + + /// + /// P99.99 响应时间(毫秒) + /// + public double Percentile9999ResponseTime { get; private set; } + + /// + public override string ToString() => + StringUtility.FormatKeyValuesSummary([ + new KeyValuePair>("Total Requests", [$"{TotalRequests}"]), + new KeyValuePair>("Total Time (s)", [$"{TotalTimeInSeconds:N2}"]), + new KeyValuePair>("Successful Requests", [$"{SuccessfulRequests}"]), + new KeyValuePair>("Failed Requests", [$"{FailedRequests}"]), + new KeyValuePair>("QPS", [$"{QueriesPerSecond:N2}"]), + new KeyValuePair>("Min RT (ms)", [$"{MinResponseTime:N2}"]), + new KeyValuePair>("Max RT (ms)", [$"{MaxResponseTime:N2}"]), + new KeyValuePair>("Avg RT (ms)", [$"{AverageResponseTime:N2}"]), + new KeyValuePair>("P10 RT (ms)", [$"{Percentile10ResponseTime:N2}"]), + new KeyValuePair>("P25 RT (ms)", [$"{Percentile25ResponseTime:N2}"]), + new KeyValuePair>("P50 RT (ms)", [$"{Percentile50ResponseTime:N2}"]), + new KeyValuePair>("P75 RT (ms)", [$"{Percentile75ResponseTime:N2}"]), + new KeyValuePair>("P90 RT (ms)", [$"{Percentile90ResponseTime:N2}"]), + new KeyValuePair>("P99 RT (ms)", [$"{Percentile99ResponseTime:N2}"]), + new KeyValuePair>("P99.99 RT (ms)", [$"{Percentile9999ResponseTime:N2}"]) + ], "Stress Test Harness Result")!; + + /// + /// 计算每秒查询率 (QPS) + /// + /// 总请求次数 + /// 总用时(秒) + internal void CalculateQueriesPerSecond(long totalRequests, double totalTimeInSeconds) => + QueriesPerSecond = totalTimeInSeconds > 0 ? totalRequests / totalTimeInSeconds : 0; + + /// + /// 计算最小、最大和平均响应时间(毫秒) + /// + /// 每个请求的响应时间数组 + /// 总请求次数 + internal void CalculateMinMaxAvgResponseTime(long[] responseTimes, long totalRequests) + { + // 计算最小响应时间和最大响应时间并转换为毫秒 + MinResponseTime = responseTimes.Min() / _ticksPerMillisecond; + MaxResponseTime = responseTimes.Max() / _ticksPerMillisecond; + + // 计算总响应时间 + var totalResponseTime = responseTimes.Sum(); + + // 计算平均响应时间 + var averageResponseTime = totalResponseTime > 0 + ? totalResponseTime / totalRequests + : 0L; + + // 将平均响应时间转换为毫秒 + AverageResponseTime = averageResponseTime / _ticksPerMillisecond; + } + + /// + /// 计算各个百分位的响应时间(毫秒) + /// + /// 请求的响应时间数组 + internal void CalculatePercentiles(long[] responseTimes) + { + // 对请求响应时间数组进行排序 + var sortedResponseTimes = responseTimes.OrderBy(t => t).ToArray(); + + // 计算百分位数的响应时间并转换为毫秒 + Percentile10ResponseTime = CalculatePercentile(sortedResponseTimes, 0.1); + Percentile25ResponseTime = CalculatePercentile(sortedResponseTimes, 0.25); + Percentile50ResponseTime = CalculatePercentile(sortedResponseTimes, 0.5); + Percentile75ResponseTime = CalculatePercentile(sortedResponseTimes, 0.75); + Percentile90ResponseTime = CalculatePercentile(sortedResponseTimes, 0.9); + Percentile99ResponseTime = CalculatePercentile(sortedResponseTimes, 0.99); + Percentile9999ResponseTime = CalculatePercentile(sortedResponseTimes, 0.9999); + } + + /// + /// 计算百分位数并转换为毫秒 + /// + /// 排序后的请求的响应时间数组 + /// 百分位数 + /// + /// + /// + internal static double CalculatePercentile(long[] sortedResponseTimes, double percentile) + { + var index = (int)Math.Ceiling(percentile * sortedResponseTimes.Length) - 1; + if (index >= sortedResponseTimes.Length) + { + index = sortedResponseTimes.Length - 1; + } + + return sortedResponseTimes[index] / _ticksPerMillisecond; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/VoidContent.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/VoidContent.cs new file mode 100644 index 000000000..c3df7bdaf --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Models/VoidContent.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// 用于标识无需接收 HTTP 远程请求返回值 +/// +public sealed class VoidContent; \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Options/HttpContextForwardOptions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Options/HttpContextForwardOptions.cs new file mode 100644 index 000000000..cad5fe0ae --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Options/HttpContextForwardOptions.cs @@ -0,0 +1,75 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Http; + +namespace ThingsGateway.HttpRemote; + +/// +/// 转发配置选项 +/// +public sealed class HttpContextForwardOptions +{ + /// + /// 是否转发查询参数(URL 参数) + /// + /// 默认值为:true + public bool WithQueryParameters { get; set; } = true; + + /// + /// 是否转发请求标头 + /// + /// 默认值为:true + public bool WithRequestHeaders { get; set; } = true; + + /// + /// 是否转发响应状态码 + /// + /// 默认值为:true + public bool WithResponseStatusCode { get; set; } = true; + + /// + /// 是否转发响应标头 + /// + /// 默认值为:true + public bool WithResponseHeaders { get; set; } = true; + + /// + /// 是否转发响应内容标头 + /// + /// 默认值为:true + public bool WithResponseContentHeaders { get; set; } = true; + + /// + /// 是否重新设置 Host 请求标头 + /// + /// 在一些目标服务器中,可能需要校验该请求标头。默认值为:false + public bool ResetHostRequestHeader { get; set; } + + /// + /// 忽略在转发时需要跳过的请求标头列表 + /// + public string[]? IgnoreRequestHeaders { get; set; } + + /// + /// 忽略在转发时需要跳过的响应标头列表 + /// + /// + /// 若响应标头中包含 Content-Length,且其值与实际响应体大小不符,则可能引发“Error while copying content to a + /// stream.”。忽略此标头有助于解决因长度不匹配引起的错误。 + /// + public string[]? IgnoreResponseHeaders { get; set; } + + /// + /// 用于在转发响应之前执行自定义操作 + /// + public Action? OnForward { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Options/HttpRemoteOptions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Options/HttpRemoteOptions.cs new file mode 100644 index 000000000..027634cba --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Options/HttpRemoteOptions.cs @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求选项 +/// +public sealed class HttpRemoteOptions +{ + /// + /// 默认 JSON 序列化配置 + /// + /// 参考文献:https://learn.microsoft.com/zh-cn/dotnet/standard/serialization/system-text-json/configure-options。 + public static readonly JsonSerializerOptions JsonSerializerOptionsDefault = new(JsonSerializerOptions.Default) + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; + + /// + /// 默认请求内容类型 + /// + public string? DefaultContentType { get; set; } = Constants.TEXT_PLAIN_MIME_TYPE; + + /// + /// 默认文件下载保存目录 + /// + public string? DefaultFileDownloadDirectory { get; set; } + + /// + /// 请求分析工具日志级别 + /// + /// 默认值为 + public LogLevel ProfilerLogLevel { get; set; } = LogLevel.Warning; + + /// + /// 指示请求是否应遵循重定向响应 + /// + /// 默认值为:true + public bool AllowAutoRedirect { get; set; } = true; + + /// + /// 请求所遵循的最大重定向数 + /// + /// 默认值为:50 次。 + public int MaximumAutomaticRedirections { get; set; } = 50; + + /// + /// JSON 序列化配置 + /// + public JsonSerializerOptions JsonSerializerOptions { get; set; } = new(JsonSerializerOptionsDefault); + + /// + /// + /// + /// 支持作为替换 URL 地址中配置模板参数的提供源。 + public IConfiguration? Configuration { get; set; } + + /// + /// 自定义 HTTP 声明式 集合提供器 + /// + /// 返回多个包含实现 集合的集合。 + internal IReadOnlyList>>? HttpDeclarativeExtractors { get; set; } + + /// + /// 指示是否配置(注册)了日志程序 + /// + internal bool IsLoggingRegistered { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/ByteArrayContentProcessor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/ByteArrayContentProcessor.cs new file mode 100644 index 000000000..57ddee03a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/ByteArrayContentProcessor.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.Http.Headers; +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// 字节数组内容处理器 +/// +public class ByteArrayContentProcessor : HttpContentProcessorBase +{ + /// + public override bool CanProcess(object? rawContent, string contentType) => + rawContent is (ByteArrayContent or byte[]) and not (FormUrlEncodedContent or StringContent); + + /// + public override HttpContent? Process(object? rawContent, string contentType, Encoding? encoding) + { + // 尝试解析 HttpContent 类型 + if (TryProcess(rawContent, contentType, encoding, out var httpContent)) + { + return httpContent; + } + + // 检查是否是字节数组类型 + if (rawContent is byte[] bytes) + { + // 初始化 ByteArrayContent 实例 + var byteArrayContent = new ByteArrayContent(bytes); + byteArrayContent.Headers.ContentType = new MediaTypeHeaderValue(contentType) + { + CharSet = encoding?.BodyName + }; + + return byteArrayContent; + } + + throw new InvalidOperationException( + $"Expected a byte array, but received an object of type `{rawContent.GetType()}`."); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/FormUrlEncodedContentProcessor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/FormUrlEncodedContentProcessor.cs new file mode 100644 index 000000000..8c49caaa5 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/FormUrlEncodedContentProcessor.cs @@ -0,0 +1,53 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// URL 编码的表单内容处理器 +/// +public class FormUrlEncodedContentProcessor : HttpContentProcessorBase +{ + /// + public override bool CanProcess(object? rawContent, string contentType) => + rawContent is FormUrlEncodedContent || contentType.IsIn([MediaTypeNames.Application.FormUrlEncoded], + StringComparer.OrdinalIgnoreCase); + + /// + public override HttpContent? Process(object? rawContent, string contentType, Encoding? encoding) + { + // 尝试解析 HttpContent 类型 + if (TryProcess(rawContent, contentType, encoding, out var httpContent)) + { + return httpContent; + } + + // 将原始请求类型转换为字符串字典类型 + var nameValueCollection = rawContent.ObjectToDictionary()! + .ToDictionary(u => u.Key.ToCultureString(CultureInfo.InvariantCulture)!, + u => u.Value?.ToCultureString(CultureInfo.InvariantCulture) + ); + + // 初始化 FormUrlEncodedContent 实例 + var formUrlEncodedContent = new FormUrlEncodedContent(nameValueCollection); + formUrlEncodedContent.Headers.ContentType = + new MediaTypeHeaderValue(contentType) { CharSet = encoding?.BodyName }; + + return formUrlEncodedContent; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/HttpContentProcessorBase.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/HttpContentProcessorBase.cs new file mode 100644 index 000000000..d96ff9c76 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/HttpContentProcessorBase.cs @@ -0,0 +1,64 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; +using System.Net.Http.Headers; +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// 内容处理器基类 +/// +public abstract class HttpContentProcessorBase : IHttpContentProcessor +{ + /// + public IServiceProvider? ServiceProvider { get; set; } + + /// + public abstract bool CanProcess(object? rawContent, string contentType); + + /// + public abstract HttpContent? Process(object? rawContent, string contentType, Encoding? encoding); + + /// + /// 尝试解析 类型 + /// + /// 原始请求内容 + /// 内容类型 + /// 内容编码 + /// + /// + /// + /// + /// + /// + public virtual bool TryProcess([NotNullWhen(false)] object? rawContent, string contentType, Encoding? encoding, + out HttpContent? httpContent) + { + switch (rawContent) + { + case null: + httpContent = null; + return true; + case HttpContent content: + // 设置 Content-Type + content.Headers.ContentType ??= + new MediaTypeHeaderValue(contentType) { CharSet = encoding?.BodyName }; + + httpContent = content; + return true; + default: + httpContent = null; + return false; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/IHttpContentProcessor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/IHttpContentProcessor.cs new file mode 100644 index 000000000..2e726db53 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/IHttpContentProcessor.cs @@ -0,0 +1,47 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// 请求内容处理器 +/// +/// 用于将原始请求内容转换成 实例 +public interface IHttpContentProcessor +{ + /// + /// + /// + IServiceProvider? ServiceProvider { get; set; } + + /// + /// 判断当前处理器是否可以处理指定的内容类型 + /// + /// 原始请求内容 + /// 内容类型 + /// + /// + /// + bool CanProcess(object? rawContent, string contentType); + + /// + /// 将原始请求内容转换为 实例 + /// + /// 原始请求内容 + /// 内容类型 + /// 内容编码 + /// + /// + /// + HttpContent? Process(object? rawContent, string contentType, Encoding? encoding); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/MessagePackContentProcessor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/MessagePackContentProcessor.cs new file mode 100644 index 000000000..9f1cc783f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/MessagePackContentProcessor.cs @@ -0,0 +1,120 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Collections.Concurrent; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// application/msgpack 内容处理器 +/// +/// 要使用 application/msgpack 内容处理器需在项目中安装 MessagePack 依赖包。https://www.nuget.org/packages/MessagePack。 +public class MessagePackContentProcessor : HttpContentProcessorBase +{ + /// + /// MessagePack 序列化器委托字典缓存 + /// + internal static readonly ConcurrentDictionary> _serializerCache = new(); + + /// + /// 初始化 MessagePack 序列化器委托 + /// + internal static readonly Lazy> _messagePackSerializerLazy = new(() => + { + // 尝试加载 MessagePack 包中的 MessagePackSerializer 类型 + var messagePackSerializerType = Type.GetType("MessagePack.MessagePackSerializer, MessagePack"); + + // 空检查 + if (messagePackSerializerType is null) + { + throw new InvalidOperationException("Please ensure the `MessagePack` package is installed."); + } + + // 查找方法:public static byte[] Serialize(T, MessagePackSerializerOptions?, CancellationToken); + var serializeMethod = messagePackSerializerType.GetMethods(BindingFlags.Public | BindingFlags.Static) + .SingleOrDefault(u => + u is { Name: "Serialize", IsGenericMethod: true } && u.ReturnType == typeof(byte[]) && + u.GetParameters().Length == 3 && + u.GetGenericArguments().Length == 1)!; + + // 返回调用委托 + return CreateSerializerDelegate(serializeMethod); + }); + + /// + /// MessagePack 序列化器委托 + /// + internal static Func MessagePackSerializer => _messagePackSerializerLazy.Value; + + /// + public override bool CanProcess(object? rawContent, string contentType) => + contentType.IsIn(["application/msgpack"], StringComparer.OrdinalIgnoreCase); + + /// + public override HttpContent? Process(object? rawContent, string contentType, Encoding? encoding) + { + // 尝试解析 HttpContent 类型 + if (TryProcess(rawContent, contentType, encoding, out var httpContent)) + { + return httpContent; + } + + // 将原始请求内容转换为字节数组 + var content = rawContent as byte[] ?? MessagePackSerializer(rawContent); + + // 初始化 ByteArrayContent 实例 + var byteArrayContent = new ByteArrayContent(content); + byteArrayContent.Headers.ContentType = new MediaTypeHeaderValue(contentType) { CharSet = encoding?.BodyName }; + + return byteArrayContent; + } + + /// + /// 创建 MessagePack 序列化器委托 + /// + /// + /// + /// + /// + /// + /// + internal static Func CreateSerializerDelegate(MethodInfo serializeMethod) + { + // 空检查 + ArgumentNullException.ThrowIfNull(serializeMethod); + + return obj => + { + // 获取对象类型 + var objType = obj.GetType(); + + // 查找 MessagePack 序列化器委托字典缓存是否存在该类型 + if (_serializerCache.TryGetValue(objType, out var serializer)) + { + return serializer(obj); + } + + // 创建 MessagePack 序列化器委托 + serializer = o => + (byte[])serializeMethod.MakeGenericMethod(objType).Invoke(null, [o, null, default(CancellationToken)])!; + + // 添加到 MessagePack 序列化器委托字典缓存中 + _serializerCache.TryAdd(objType, serializer); + + return serializer(obj); + }; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/MultipartFormDataContentProcessor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/MultipartFormDataContentProcessor.cs new file mode 100644 index 000000000..bd3cef318 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/MultipartFormDataContentProcessor.cs @@ -0,0 +1,40 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.Mime; +using System.Text; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// 多部分表单内容数据内容处理器 +/// +public class MultipartFormDataContentProcessor : HttpContentProcessorBase +{ + /// + public override bool CanProcess(object? rawContent, string contentType) => + rawContent is MultipartFormDataContent || + contentType.IsIn([MediaTypeNames.Multipart.FormData], StringComparer.OrdinalIgnoreCase); + + /// + public override HttpContent? Process(object? rawContent, string contentType, Encoding? encoding) + { + // 尝试解析 HttpContent 类型 + if (TryProcess(rawContent, contentType, encoding, out var httpContent)) + { + return httpContent; + } + + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/ReadOnlyMemoryContentProcessor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/ReadOnlyMemoryContentProcessor.cs new file mode 100644 index 000000000..01e39f92d --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/ReadOnlyMemoryContentProcessor.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.Http.Headers; +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// 内容处理器 +/// +public class ReadOnlyMemoryContentProcessor : HttpContentProcessorBase +{ + /// + public override bool CanProcess(object? rawContent, string contentType) => + rawContent is ReadOnlyMemoryContent or ReadOnlyMemory; + + /// + public override HttpContent? Process(object? rawContent, string contentType, Encoding? encoding) + { + // 尝试解析 HttpContent 类型 + if (TryProcess(rawContent, contentType, encoding, out var httpContent)) + { + return httpContent; + } + + // 检查是否是 ReadOnlyMemory 类型 + if (rawContent is ReadOnlyMemory readOnlyMemory) + { + // 初始化 ReadOnlyMemoryContent 实例 + var readOnlyMemoryContent = new ReadOnlyMemoryContent(readOnlyMemory); + readOnlyMemoryContent.Headers.ContentType = new MediaTypeHeaderValue(contentType) + { + CharSet = encoding?.BodyName + }; + + return readOnlyMemoryContent; + } + + throw new InvalidOperationException( + $"Expected a ReadOnlyMemory, but received an object of type `{rawContent.GetType()}`."); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/StreamContentProcessor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/StreamContentProcessor.cs new file mode 100644 index 000000000..28e80fd0e --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/StreamContentProcessor.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.Http.Headers; +using System.Text; + +namespace ThingsGateway.HttpRemote; + +/// +/// 流内容处理器 +/// +public class StreamContentProcessor : HttpContentProcessorBase +{ + /// + public override bool CanProcess(object? rawContent, string contentType) => + rawContent is StreamContent or Stream; + + /// + public override HttpContent? Process(object? rawContent, string contentType, Encoding? encoding) + { + // 尝试解析 HttpContent 类型 + if (TryProcess(rawContent, contentType, encoding, out var httpContent)) + { + return httpContent; + } + + // 检查是否是流类型 + if (rawContent is Stream stream) + { + // 初始化 StreamContent 实例 + var streamContent = new StreamContent(stream); + streamContent.Headers.ContentType = + new MediaTypeHeaderValue(contentType) { CharSet = encoding?.BodyName }; + + return streamContent; + } + + throw new InvalidOperationException( + $"Expected a stream, but received an object of type `{rawContent.GetType()}`."); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/StringContentForFormUrlEncodedContentProcessor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/StringContentForFormUrlEncodedContentProcessor.cs new file mode 100644 index 000000000..3360054fb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/StringContentForFormUrlEncodedContentProcessor.cs @@ -0,0 +1,100 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Globalization; +using System.Net.Http.Headers; +using System.Text; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// URL 编码的表单内容处理器 +/// +/// +/// 解决 无法设置编码问题。 的编码格式不是 utf-8,而是 +/// Encoding.Latin1。 +/// +public class StringContentForFormUrlEncodedContentProcessor : FormUrlEncodedContentProcessor +{ + /// + public override HttpContent? Process(object? rawContent, string contentType, Encoding? encoding) + { + // 尝试解析 HttpContent 类型 + if (TryProcess(rawContent, contentType, encoding, out var httpContent)) + { + return httpContent; + } + + // 如果原始内容是字符串类型且不是有效的 application/x-www-form-urlencoded 格式 + if (rawContent is string rawString && !Helpers.IsFormUrlEncodedFormat(rawString)) + { + throw new FormatException("The content must contain only form url encoded string."); + } + + // 将原始请求内容转换为字符串 + var content = rawContent as string ?? GetContentString( + // 将原始请求类型转换为字符串字典类型 + rawContent.ObjectToDictionary()! + .ToDictionary(u => u.Key.ToCultureString(CultureInfo.InvariantCulture)!, + u => u.Value?.ToCultureString(CultureInfo.InvariantCulture) + ) + ); + + // 初始化 StringContent 实例 + var stringContent = new StringContent(content, encoding, + new MediaTypeHeaderValue(contentType) { CharSet = encoding?.BodyName }); + + return stringContent; + } + + /// + /// 获取 URL 编码的表单内容格式 + /// + /// 键值对集合 + /// + /// + /// + internal static string GetContentString(params IEnumerable> nameValueCollection) + { + // 空检查 + ArgumentNullException.ThrowIfNull(nameValueCollection); + + // 初始化 StringBuilder 实例 + var stringBuilder = new StringBuilder(); + + // 生成 {key}={value}&... 格式 + foreach (var nameValue in nameValueCollection) + { + if (stringBuilder.Length > 0) + { + stringBuilder.Append('&'); + } + + stringBuilder.Append(Encode(nameValue.Key)); + stringBuilder.Append('='); + stringBuilder.Append(Encode(nameValue.Value)); + } + + return stringBuilder.ToString(); + } + + /// + /// 对数据进行 URL 编码 + /// + /// 数据 + /// + /// + /// + internal static string Encode(string? data) => + string.IsNullOrEmpty(data) ? string.Empty : Uri.EscapeDataString(data).Replace("%20", "+"); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/StringContentProcessor.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/StringContentProcessor.cs new file mode 100644 index 000000000..a5a1d2710 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Processors/StringContentProcessor.cs @@ -0,0 +1,66 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Net.Mime; +using System.Text; +using System.Text.Json; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// 字符串内容处理器 +/// +public class StringContentProcessor : HttpContentProcessorBase +{ + /// + public override bool CanProcess(object? rawContent, string contentType) => + rawContent is StringContent or JsonContent || + contentType.IsIn([ + MediaTypeNames.Application.Json, + MediaTypeNames.Application.JsonPatch, + MediaTypeNames.Application.Xml, + MediaTypeNames.Application.XmlPatch, + MediaTypeNames.Text.Xml, + MediaTypeNames.Text.Html, + MediaTypeNames.Text.Plain + ], StringComparer.OrdinalIgnoreCase); + + /// + public override HttpContent? Process(object? rawContent, string contentType, Encoding? encoding) + { + // 尝试解析 HttpContent 类型 + if (TryProcess(rawContent, contentType, encoding, out var httpContent)) + { + return httpContent; + } + + // 将原始请求内容转换为字符串 + var content = rawContent.GetType().IsBasicType() || rawContent is JsonElement + ? rawContent.ToCultureString(CultureInfo.InvariantCulture) + : JsonSerializer.Serialize(rawContent, + ServiceProvider?.GetRequiredService>().Value.JsonSerializerOptions ?? + HttpRemoteOptions.JsonSerializerOptionsDefault); + + // 初始化 StringContent 实例 + var stringContent = new StringContent(content!, encoding, + new MediaTypeHeaderValue(contentType) { CharSet = encoding?.BodyName }); + + return stringContent; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/HttpRemoteService.Extensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/HttpRemoteService.Extensions.cs new file mode 100644 index 000000000..a549dde0a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/HttpRemoteService.Extensions.cs @@ -0,0 +1,176 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.HttpRemote; + +/// +/// +/// +internal sealed partial class HttpRemoteService +{ + /// + public void DownloadFile(string? requestUri, string? destinationPath, + Func? onProgressChanged = null, + FileExistsBehavior fileExistsBehavior = FileExistsBehavior.CreateNew, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default) => + Send( + HttpRequestBuilder.DownloadFile(requestUri, destinationPath, onProgressChanged, fileExistsBehavior, + configure), + requestConfigure, cancellationToken); + + /// + public Task DownloadFileAsync(string? requestUri, string? destinationPath, + Func? onProgressChanged = null, + FileExistsBehavior fileExistsBehavior = FileExistsBehavior.CreateNew, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default) => + SendAsync( + HttpRequestBuilder.DownloadFile(requestUri, destinationPath, onProgressChanged, fileExistsBehavior, + configure), + requestConfigure, cancellationToken); + + /// + public void Send(HttpFileDownloadBuilder httpFileDownloadBuilder, Action? configure = null, + CancellationToken cancellationToken = default) => + new FileDownloadManager(this, httpFileDownloadBuilder, configure).Start(cancellationToken); + + /// + public Task SendAsync(HttpFileDownloadBuilder httpFileDownloadBuilder, Action? configure = null, + CancellationToken cancellationToken = default) => + new FileDownloadManager(this, httpFileDownloadBuilder, configure).StartAsync(cancellationToken); + + /// + public HttpResponseMessage UploadFile(string? requestUri, string filePath, string name = "file", + Func? onProgressChanged = null, string? fileName = null, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default) => + Send(HttpRequestBuilder.UploadFile(requestUri, filePath, name, onProgressChanged, fileName, configure), + requestConfigure, + cancellationToken); + + /// + public Task UploadFileAsync(string? requestUri, string filePath, string name = "file", + Func? onProgressChanged = null, string? fileName = null, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.UploadFile(requestUri, filePath, name, onProgressChanged, fileName, configure), + requestConfigure, + cancellationToken); + + /// + public HttpResponseMessage Send(HttpFileUploadBuilder httpFileUploadBuilder, + Action? configure = null, CancellationToken cancellationToken = default) => + new FileUploadManager(this, httpFileUploadBuilder, configure).Start(cancellationToken); + + /// + public Task SendAsync(HttpFileUploadBuilder httpFileUploadBuilder, + Action? configure = null, CancellationToken cancellationToken = default) => + new FileUploadManager(this, httpFileUploadBuilder, configure).StartAsync(cancellationToken); + + /// + public void ServerSentEvents(string? requestUri, Func onMessage, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default) => + Send(HttpRequestBuilder.ServerSentEvents(requestUri, onMessage, configure), requestConfigure, + cancellationToken); + + /// + public Task ServerSentEventsAsync(string? requestUri, Func onMessage, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.ServerSentEvents(requestUri, onMessage, configure), requestConfigure, + cancellationToken); + + /// + public void Send(HttpServerSentEventsBuilder httpServerSentEventsBuilder, + Action? configure = null, CancellationToken cancellationToken = default) => + new ServerSentEventsManager(this, httpServerSentEventsBuilder, configure).Start(cancellationToken); + + /// + public Task SendAsync(HttpServerSentEventsBuilder httpServerSentEventsBuilder, + Action? configure = null, CancellationToken cancellationToken = default) => + new ServerSentEventsManager(this, httpServerSentEventsBuilder, configure).StartAsync(cancellationToken); + + /// + public StressTestHarnessResult StressTestHarness(string? requestUri, int numberOfRequests = 100, + Action? configure = null, Action? requestConfigure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default) => + Send(HttpRequestBuilder.StressTestHarness(requestUri, numberOfRequests, configure), requestConfigure, + completionOption, cancellationToken); + + /// + public Task StressTestHarnessAsync(string? requestUri, int numberOfRequests = 100, + Action? configure = null, Action? requestConfigure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.StressTestHarness(requestUri, numberOfRequests, configure), requestConfigure, + completionOption, cancellationToken); + + /// + public StressTestHarnessResult Send(HttpStressTestHarnessBuilder httpStressTestHarnessBuilder, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default) => + new StressTestHarnessManager(this, httpStressTestHarnessBuilder, configure).Start(completionOption, + cancellationToken); + + /// + public Task SendAsync(HttpStressTestHarnessBuilder httpStressTestHarnessBuilder, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default) => + new StressTestHarnessManager(this, httpStressTestHarnessBuilder, configure).StartAsync(completionOption, + cancellationToken); + + /// + public void LongPolling(string? requestUri, Func onDataReceived, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default) => + Send(HttpRequestBuilder.LongPolling(requestUri, onDataReceived, configure), requestConfigure, + cancellationToken); + + /// + public Task LongPollingAsync(string? requestUri, Func onDataReceived, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.LongPolling(requestUri, onDataReceived, configure), requestConfigure, + cancellationToken); + + /// + public void Send(HttpLongPollingBuilder httpLongPollingBuilder, Action? configure = null, + CancellationToken cancellationToken = default) => + new LongPollingManager(this, httpLongPollingBuilder, configure).Start(cancellationToken); + + /// + public Task SendAsync(HttpLongPollingBuilder httpLongPollingBuilder, Action? configure = null, + CancellationToken cancellationToken = default) => + new LongPollingManager(this, httpLongPollingBuilder, configure).StartAsync(cancellationToken); + + /// + public object? Declarative(MethodInfo method, object[] args) => + SendAs(HttpRequestBuilder.Declarative(method, args)); + + /// + public Task DeclarativeAsync(MethodInfo method, object[] args) => + SendAsAsync(HttpRequestBuilder.Declarative(method, args)); + + /// + public object? SendAs(HttpDeclarativeBuilder httpDeclarativeBuilder) => + new DeclarativeManager(this, httpDeclarativeBuilder).Start(); + + /// + public Task SendAsAsync(HttpDeclarativeBuilder httpDeclarativeBuilder) => + new DeclarativeManager(this, httpDeclarativeBuilder).StartAsync(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/HttpRemoteService.HttpMethods.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/HttpRemoteService.HttpMethods.cs new file mode 100644 index 000000000..9bc03fd78 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/HttpRemoteService.HttpMethods.cs @@ -0,0 +1,968 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// +/// +internal sealed partial class HttpRemoteService +{ + /// + public HttpResponseMessage Get(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Get(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public HttpResponseMessage Get(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Get, requestUri, configure), completionOption, cancellationToken); + + /// + public Task GetAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => GetAsync(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public Task GetAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAsync( + HttpRequestBuilder.Create(HttpMethod.Get, requestUri, configure), completionOption, cancellationToken); + + /// + public TResult? GetAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => GetAs(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public TResult? GetAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAs( + HttpRequestBuilder.Create(HttpMethod.Get, requestUri, configure), completionOption, cancellationToken); + + /// + public Task GetAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => GetAsAsync(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task GetAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsAsync(HttpRequestBuilder.Create(HttpMethod.Get, requestUri, configure), completionOption, + cancellationToken); + + /// + public HttpRemoteResult Get(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Get(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public HttpRemoteResult Get(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Get, requestUri, configure), completionOption, cancellationToken); + + /// + public Task> GetAsync(string? requestUri, + Action? configure = null, CancellationToken cancellationToken = default) => + GetAsync(requestUri, HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task> GetAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.Create(HttpMethod.Get, requestUri, configure), completionOption, + cancellationToken); + + /// + public string? GetAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => GetAs(requestUri, configure, cancellationToken); + + /// + public Stream? GetAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => GetAs(requestUri, configure, cancellationToken); + + /// + public byte[]? GetAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => GetAs(requestUri, configure, cancellationToken); + + /// + public string? GetAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + GetAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Stream? GetAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + GetAs(requestUri, completionOption, configure, cancellationToken); + + /// + public byte[]? GetAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + GetAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Task GetAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => GetAsAsync(requestUri, configure, cancellationToken); + + /// + public Task GetAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => GetAsAsync(requestUri, configure, cancellationToken); + + /// + public Task GetAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => GetAsAsync(requestUri, configure, cancellationToken); + + /// + public Task GetAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + GetAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task GetAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + GetAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task GetAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + GetAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public HttpResponseMessage Put(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Put(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public HttpResponseMessage Put(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Put, requestUri, configure), completionOption, cancellationToken); + + /// + public Task PutAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PutAsync(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public Task PutAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAsync( + HttpRequestBuilder.Create(HttpMethod.Put, requestUri, configure), completionOption, cancellationToken); + + /// + public TResult? PutAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PutAs(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public TResult? PutAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAs( + HttpRequestBuilder.Create(HttpMethod.Put, requestUri, configure), completionOption, cancellationToken); + + /// + public Task PutAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PutAsAsync(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task PutAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsAsync(HttpRequestBuilder.Create(HttpMethod.Put, requestUri, configure), completionOption, + cancellationToken); + + /// + public HttpRemoteResult Put(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Put(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public HttpRemoteResult Put(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Put, requestUri, configure), completionOption, cancellationToken); + + /// + public Task> PutAsync(string? requestUri, + Action? configure = null, CancellationToken cancellationToken = default) => + PutAsync(requestUri, HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task> PutAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.Create(HttpMethod.Put, requestUri, configure), completionOption, + cancellationToken); + + /// + public string? PutAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PutAs(requestUri, configure, cancellationToken); + + /// + public Stream? PutAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PutAs(requestUri, configure, cancellationToken); + + /// + public byte[]? PutAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PutAs(requestUri, configure, cancellationToken); + + /// + public string? PutAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PutAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Stream? PutAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PutAs(requestUri, completionOption, configure, cancellationToken); + + /// + public byte[]? PutAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PutAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Task PutAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PutAsAsync(requestUri, configure, cancellationToken); + + /// + public Task PutAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PutAsAsync(requestUri, configure, cancellationToken); + + /// + public Task PutAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PutAsAsync(requestUri, configure, cancellationToken); + + /// + public Task PutAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PutAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task PutAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PutAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task PutAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PutAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public HttpResponseMessage Post(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Post(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public HttpResponseMessage Post(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Post, requestUri, configure), completionOption, cancellationToken); + + /// + public Task PostAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PostAsync(requestUri, + HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public Task PostAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAsync( + HttpRequestBuilder.Create(HttpMethod.Post, requestUri, configure), completionOption, cancellationToken); + + /// + public TResult? PostAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PostAs(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public TResult? PostAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAs( + HttpRequestBuilder.Create(HttpMethod.Post, requestUri, configure), completionOption, cancellationToken); + + /// + public Task PostAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PostAsAsync(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task PostAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsAsync(HttpRequestBuilder.Create(HttpMethod.Post, requestUri, configure), completionOption, + cancellationToken); + + /// + public HttpRemoteResult Post(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Post(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public HttpRemoteResult Post(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Post, requestUri, configure), completionOption, cancellationToken); + + /// + public Task> PostAsync(string? requestUri, + Action? configure = null, CancellationToken cancellationToken = default) => + PostAsync(requestUri, HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task> PostAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.Create(HttpMethod.Post, requestUri, configure), completionOption, + cancellationToken); + + /// + public string? PostAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PostAs(requestUri, configure, cancellationToken); + + /// + public Stream? PostAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PostAs(requestUri, configure, cancellationToken); + + /// + public byte[]? PostAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PostAs(requestUri, configure, cancellationToken); + + /// + public string? PostAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PostAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Stream? PostAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PostAs(requestUri, completionOption, configure, cancellationToken); + + /// + public byte[]? PostAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PostAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Task PostAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PostAsAsync(requestUri, configure, cancellationToken); + + /// + public Task PostAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PostAsAsync(requestUri, configure, cancellationToken); + + /// + public Task PostAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PostAsAsync(requestUri, configure, cancellationToken); + + /// + public Task PostAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PostAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task PostAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PostAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task PostAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PostAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public HttpResponseMessage Delete(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Delete(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public HttpResponseMessage Delete(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Delete, requestUri, configure), completionOption, cancellationToken); + + /// + public Task DeleteAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => DeleteAsync(requestUri, + HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public Task DeleteAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAsync( + HttpRequestBuilder.Create(HttpMethod.Delete, requestUri, configure), completionOption, cancellationToken); + + /// + public TResult? DeleteAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => DeleteAs(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public TResult? DeleteAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAs( + HttpRequestBuilder.Create(HttpMethod.Delete, requestUri, configure), completionOption, cancellationToken); + + /// + public Task DeleteAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => DeleteAsAsync(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task DeleteAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsAsync(HttpRequestBuilder.Create(HttpMethod.Delete, requestUri, configure), completionOption, + cancellationToken); + + /// + public HttpRemoteResult Delete(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Delete(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public HttpRemoteResult Delete(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Delete, requestUri, configure), completionOption, cancellationToken); + + /// + public Task> DeleteAsync(string? requestUri, + Action? configure = null, CancellationToken cancellationToken = default) => + DeleteAsync(requestUri, HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task> DeleteAsync(string? requestUri, + HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.Create(HttpMethod.Delete, requestUri, configure), completionOption, + cancellationToken); + + /// + public string? DeleteAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => DeleteAs(requestUri, configure, cancellationToken); + + /// + public Stream? DeleteAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => DeleteAs(requestUri, configure, cancellationToken); + + /// + public byte[]? DeleteAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => DeleteAs(requestUri, configure, cancellationToken); + + /// + public string? DeleteAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + DeleteAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Stream? DeleteAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + DeleteAs(requestUri, completionOption, configure, cancellationToken); + + /// + public byte[]? DeleteAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + DeleteAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Task DeleteAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + DeleteAsAsync(requestUri, configure, cancellationToken); + + /// + public Task DeleteAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + DeleteAsAsync(requestUri, configure, cancellationToken); + + /// + public Task DeleteAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + DeleteAsAsync(requestUri, configure, cancellationToken); + + /// + public Task DeleteAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + DeleteAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task DeleteAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + DeleteAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task DeleteAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + DeleteAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public HttpResponseMessage Head(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Head(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public HttpResponseMessage Head(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Head, requestUri, configure), completionOption, cancellationToken); + + /// + public Task HeadAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => HeadAsync(requestUri, + HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public Task HeadAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAsync( + HttpRequestBuilder.Create(HttpMethod.Head, requestUri, configure), completionOption, cancellationToken); + + /// + public TResult? HeadAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => HeadAs(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public TResult? HeadAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAs( + HttpRequestBuilder.Create(HttpMethod.Head, requestUri, configure), completionOption, cancellationToken); + + /// + public Task HeadAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => HeadAsAsync(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task HeadAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsAsync(HttpRequestBuilder.Create(HttpMethod.Head, requestUri, configure), completionOption, + cancellationToken); + + /// + public HttpRemoteResult Head(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Head(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public HttpRemoteResult Head(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Head, requestUri, configure), completionOption, cancellationToken); + + /// + public Task> HeadAsync(string? requestUri, + Action? configure = null, CancellationToken cancellationToken = default) => + HeadAsync(requestUri, HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task> HeadAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.Create(HttpMethod.Head, requestUri, configure), completionOption, + cancellationToken); + + /// + public string? HeadAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => HeadAs(requestUri, configure, cancellationToken); + + /// + public Stream? HeadAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => HeadAs(requestUri, configure, cancellationToken); + + /// + public byte[]? HeadAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => HeadAs(requestUri, configure, cancellationToken); + + /// + public string? HeadAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + HeadAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Stream? HeadAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + HeadAs(requestUri, completionOption, configure, cancellationToken); + + /// + public byte[]? HeadAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + HeadAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Task HeadAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => HeadAsAsync(requestUri, configure, cancellationToken); + + /// + public Task HeadAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => HeadAsAsync(requestUri, configure, cancellationToken); + + /// + public Task HeadAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => HeadAsAsync(requestUri, configure, cancellationToken); + + /// + public Task HeadAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + HeadAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task HeadAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + HeadAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task HeadAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + HeadAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public HttpResponseMessage Options(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Options(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public HttpResponseMessage Options(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Options, requestUri, configure), completionOption, cancellationToken); + + /// + public Task OptionsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => OptionsAsync(requestUri, + HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public Task OptionsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAsync( + HttpRequestBuilder.Create(HttpMethod.Options, requestUri, configure), completionOption, cancellationToken); + + /// + public TResult? OptionsAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => OptionsAs(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public TResult? OptionsAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAs( + HttpRequestBuilder.Create(HttpMethod.Options, requestUri, configure), completionOption, cancellationToken); + + /// + public Task OptionsAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => OptionsAsAsync(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task OptionsAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsAsync(HttpRequestBuilder.Create(HttpMethod.Options, requestUri, configure), completionOption, + cancellationToken); + + /// + public HttpRemoteResult Options(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Options(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public HttpRemoteResult Options(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Options, requestUri, configure), completionOption, cancellationToken); + + /// + public Task> OptionsAsync(string? requestUri, + Action? configure = null, CancellationToken cancellationToken = default) => + OptionsAsync(requestUri, HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task> OptionsAsync(string? requestUri, + HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.Create(HttpMethod.Options, requestUri, configure), completionOption, + cancellationToken); + + /// + public string? OptionsAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => OptionsAs(requestUri, configure, cancellationToken); + + /// + public Stream? OptionsAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => OptionsAs(requestUri, configure, cancellationToken); + + /// + public byte[]? OptionsAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => OptionsAs(requestUri, configure, cancellationToken); + + /// + public string? OptionsAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + OptionsAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Stream? OptionsAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + OptionsAs(requestUri, completionOption, configure, cancellationToken); + + /// + public byte[]? OptionsAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + OptionsAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Task OptionsAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + OptionsAsAsync(requestUri, configure, cancellationToken); + + /// + public Task OptionsAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + OptionsAsAsync(requestUri, configure, cancellationToken); + + /// + public Task OptionsAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + OptionsAsAsync(requestUri, configure, cancellationToken); + + /// + public Task OptionsAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + OptionsAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task OptionsAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + OptionsAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task OptionsAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + OptionsAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public HttpResponseMessage Trace(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Trace(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public HttpResponseMessage Trace(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Trace, requestUri, configure), completionOption, cancellationToken); + + /// + public Task TraceAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => TraceAsync(requestUri, + HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public Task TraceAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAsync( + HttpRequestBuilder.Create(HttpMethod.Trace, requestUri, configure), completionOption, cancellationToken); + + /// + public TResult? TraceAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => TraceAs(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public TResult? TraceAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAs( + HttpRequestBuilder.Create(HttpMethod.Trace, requestUri, configure), completionOption, cancellationToken); + + /// + public Task TraceAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => TraceAsAsync(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task TraceAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsAsync(HttpRequestBuilder.Create(HttpMethod.Trace, requestUri, configure), completionOption, + cancellationToken); + + /// + public HttpRemoteResult Trace(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Trace(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public HttpRemoteResult Trace(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Trace, requestUri, configure), completionOption, cancellationToken); + + /// + public Task> TraceAsync(string? requestUri, + Action? configure = null, CancellationToken cancellationToken = default) => + TraceAsync(requestUri, HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task> TraceAsync(string? requestUri, + HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.Create(HttpMethod.Trace, requestUri, configure), completionOption, + cancellationToken); + + /// + public string? TraceAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => TraceAs(requestUri, configure, cancellationToken); + + /// + public Stream? TraceAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => TraceAs(requestUri, configure, cancellationToken); + + /// + public byte[]? TraceAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => TraceAs(requestUri, configure, cancellationToken); + + /// + public string? TraceAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + TraceAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Stream? TraceAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + TraceAs(requestUri, completionOption, configure, cancellationToken); + + /// + public byte[]? TraceAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + TraceAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Task TraceAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + TraceAsAsync(requestUri, configure, cancellationToken); + + /// + public Task TraceAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + TraceAsAsync(requestUri, configure, cancellationToken); + + /// + public Task TraceAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + TraceAsAsync(requestUri, configure, cancellationToken); + + /// + public Task TraceAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + TraceAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task TraceAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + TraceAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task TraceAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + TraceAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public HttpResponseMessage Patch(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Patch(requestUri, HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public HttpResponseMessage Patch(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Patch, requestUri, configure), completionOption, cancellationToken); + + /// + public Task PatchAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PatchAsync(requestUri, + HttpCompletionOption.ResponseContentRead, + configure, cancellationToken); + + /// + public Task PatchAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAsync( + HttpRequestBuilder.Create(HttpMethod.Patch, requestUri, configure), completionOption, cancellationToken); + + /// + public TResult? PatchAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PatchAs(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public TResult? PatchAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => SendAs( + HttpRequestBuilder.Create(HttpMethod.Patch, requestUri, configure), completionOption, cancellationToken); + + /// + public Task PatchAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PatchAsAsync(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task PatchAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsAsync(HttpRequestBuilder.Create(HttpMethod.Patch, requestUri, configure), completionOption, + cancellationToken); + + /// + public HttpRemoteResult Patch(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => Patch(requestUri, + HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public HttpRemoteResult Patch(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => Send( + HttpRequestBuilder.Create(HttpMethod.Patch, requestUri, configure), completionOption, cancellationToken); + + /// + public Task> PatchAsync(string? requestUri, + Action? configure = null, CancellationToken cancellationToken = default) => + PatchAsync(requestUri, HttpCompletionOption.ResponseContentRead, configure, cancellationToken); + + /// + public Task> PatchAsync(string? requestUri, + HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + SendAsync(HttpRequestBuilder.Create(HttpMethod.Patch, requestUri, configure), completionOption, + cancellationToken); + + /// + public string? PatchAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PatchAs(requestUri, configure, cancellationToken); + + /// + public Stream? PatchAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PatchAs(requestUri, configure, cancellationToken); + + /// + public byte[]? PatchAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => PatchAs(requestUri, configure, cancellationToken); + + /// + public string? PatchAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PatchAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Stream? PatchAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PatchAs(requestUri, completionOption, configure, cancellationToken); + + /// + public byte[]? PatchAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PatchAs(requestUri, completionOption, configure, cancellationToken); + + /// + public Task PatchAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + PatchAsAsync(requestUri, configure, cancellationToken); + + /// + public Task PatchAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + PatchAsAsync(requestUri, configure, cancellationToken); + + /// + public Task PatchAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default) => + PatchAsAsync(requestUri, configure, cancellationToken); + + /// + public Task PatchAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PatchAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task PatchAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PatchAsAsync(requestUri, completionOption, configure, cancellationToken); + + /// + public Task PatchAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default) => + PatchAsAsync(requestUri, completionOption, configure, cancellationToken); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/HttpRemoteService.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/HttpRemoteService.cs new file mode 100644 index 000000000..540aecec4 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/HttpRemoteService.cs @@ -0,0 +1,949 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Reflection; +using System.Text.RegularExpressions; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// +/// +internal sealed partial class HttpRemoteService : IHttpRemoteService +{ + /// + internal readonly IHttpClientFactory _httpClientFactory; + + /// + internal readonly IHttpContentConverterFactory _httpContentConverterFactory; + + /// + internal readonly IHttpContentProcessorFactory _httpContentProcessorFactory; + + /// + internal readonly HttpRemoteOptions _httpRemoteOptions; + + /// + internal readonly ILogger _logger; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public HttpRemoteService(IServiceProvider serviceProvider, ILogger logger, + IHttpClientFactory httpClientFactory, + IHttpContentProcessorFactory httpContentProcessorFactory, + IHttpContentConverterFactory httpContentConverterFactory, + IOptions httpRemoteOptions) + { + // 空检查 + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(httpClientFactory); + ArgumentNullException.ThrowIfNull(httpContentProcessorFactory); + ArgumentNullException.ThrowIfNull(httpContentConverterFactory); + ArgumentNullException.ThrowIfNull(httpRemoteOptions); + + ServiceProvider = serviceProvider; + _httpRemoteOptions = httpRemoteOptions.Value; + + _logger = logger; + _httpClientFactory = httpClientFactory; + _httpContentProcessorFactory = httpContentProcessorFactory; + _httpContentConverterFactory = httpContentConverterFactory; + } + + /// + public IServiceProvider ServiceProvider { get; } + + /// + public HttpResponseMessage Send(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => + Send(httpRequestBuilder, HttpCompletionOption.ResponseContentRead, cancellationToken); + + /// + public HttpResponseMessage Send(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) + { + // 发送 HTTP 远程请求 + var (httpResponseMessage, _) = SendCoreAsync(httpRequestBuilder, completionOption, default, + (httpClient, httpRequestMessage, option, token) => + httpClient.Send(httpRequestMessage, option, token), cancellationToken).GetAwaiter().GetResult(); + + return httpResponseMessage; + } + + /// + public Task SendAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => + SendAsync(httpRequestBuilder, HttpCompletionOption.ResponseContentRead, cancellationToken); + + /// + public async Task SendAsync(HttpRequestBuilder httpRequestBuilder, + HttpCompletionOption completionOption, CancellationToken cancellationToken = default) + { + // 发送 HTTP 远程请求 + var (httpResponseMessage, _) = await SendCoreAsync(httpRequestBuilder, completionOption, + (httpClient, httpRequestMessage, option, token) => + httpClient.SendAsync(httpRequestMessage, option, token), default, cancellationToken).ConfigureAwait(false); + + return httpResponseMessage; + } + + /// + public TResult? SendAs(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => SendAs(httpRequestBuilder, + HttpCompletionOption.ResponseContentRead, cancellationToken); + + /// + public TResult? SendAs(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) + { + // 发送 HTTP 远程请求 + var (httpResponseMessage, requestDuration) = SendCoreAsync(httpRequestBuilder, completionOption, default, + (httpClient, httpRequestMessage, option, token) => + httpClient.Send(httpRequestMessage, option, token), cancellationToken).GetAwaiter().GetResult(); + + // 获取结果类型 + var resultType = typeof(TResult); + + // 检查类型是否是 HttpRemoteResult 类型 + if (!typeof(HttpRemoteResult<>).IsDefinitionEqual(resultType)) + { + // 将 HttpResponseMessage 转换为 TResult 实例 + return _httpContentConverterFactory.Read(httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + cancellationToken); + } + + // 将 HttpResponseMessage 转换为 HttpRemoteResult 泛型类型 T 的实例 + var result = _httpContentConverterFactory.Read(resultType.GetGenericArguments()[0], + httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + cancellationToken); + + // 动态创建 HttpRemoteResult 实例并转换为 TResult 实例 + return (TResult)DynamicCreateHttpRemoteResult(resultType, httpResponseMessage, result, requestDuration); + } + + /// + public string? SendAsString(HttpRequestBuilder httpRequestBuilder, CancellationToken cancellationToken = default) => + SendAs(httpRequestBuilder, cancellationToken); + + /// + public string? SendAsString(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) => + SendAs(httpRequestBuilder, completionOption, cancellationToken); + + /// + public byte[]? SendAsByteArray(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => + SendAs(httpRequestBuilder, cancellationToken); + + /// + public byte[]? SendAsByteArray(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) => + SendAs(httpRequestBuilder, completionOption, cancellationToken); + + /// + public Stream? SendAsStream(HttpRequestBuilder httpRequestBuilder, CancellationToken cancellationToken = default) => + SendAs(httpRequestBuilder, cancellationToken); + + /// + public Stream? SendAsStream(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) => + SendAs(httpRequestBuilder, completionOption, cancellationToken); + + /// + public Task SendAsAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => SendAsAsync(httpRequestBuilder, + HttpCompletionOption.ResponseContentRead, cancellationToken); + + /// + public async Task SendAsAsync(HttpRequestBuilder httpRequestBuilder, + HttpCompletionOption completionOption, CancellationToken cancellationToken = default) + { + // 发送 HTTP 远程请求 + var (httpResponseMessage, requestDuration) = await SendCoreAsync(httpRequestBuilder, completionOption, + (httpClient, httpRequestMessage, option, token) => + httpClient.SendAsync(httpRequestMessage, option, token), default, cancellationToken).ConfigureAwait(false); + + // 获取结果类型 + var resultType = typeof(TResult); + + // 检查类型是否是 HttpRemoteResult 类型 + if (!typeof(HttpRemoteResult<>).IsDefinitionEqual(resultType)) + { + // 将 HttpResponseMessage 转换为 TResult 实例 + return await _httpContentConverterFactory.ReadAsync(httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + cancellationToken).ConfigureAwait(false); + } + + // 将 HttpResponseMessage 转换为 HttpRemoteResult 泛型类型 T 的实例 + var result = await _httpContentConverterFactory.ReadAsync(resultType.GetGenericArguments()[0], + httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + cancellationToken).ConfigureAwait(false); + + // 动态创建 HttpRemoteResult 实例并转换为 TResult 实例 + return (TResult)DynamicCreateHttpRemoteResult(resultType, httpResponseMessage, result, requestDuration); + } + + /// + public Task SendAsStringAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => + SendAsAsync(httpRequestBuilder, cancellationToken); + + /// + public Task SendAsStringAsync(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) => + SendAsAsync(httpRequestBuilder, completionOption, cancellationToken); + + /// + public Task SendAsByteArrayAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => + SendAsAsync(httpRequestBuilder, cancellationToken); + + /// + public Task SendAsByteArrayAsync(HttpRequestBuilder httpRequestBuilder, + HttpCompletionOption completionOption, CancellationToken cancellationToken = default) => + SendAsAsync(httpRequestBuilder, completionOption, cancellationToken); + + /// + public Task SendAsStreamAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => + SendAsAsync(httpRequestBuilder, cancellationToken); + + /// + public Task SendAsStreamAsync(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) => + SendAsAsync(httpRequestBuilder, completionOption, cancellationToken); + + /// + public object? SendAs(Type resultType, HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => SendAs(resultType, httpRequestBuilder, + HttpCompletionOption.ResponseContentRead, cancellationToken); + + /// + public object? SendAs(Type resultType, HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default) + { + // 发送 HTTP 远程请求 + var (httpResponseMessage, requestDuration) = SendCoreAsync(httpRequestBuilder, completionOption, default, + (httpClient, httpRequestMessage, option, token) => + httpClient.Send(httpRequestMessage, option, token), cancellationToken).GetAwaiter().GetResult(); + + // 检查类型是否是 HttpRemoteResult 类型 + if (!typeof(HttpRemoteResult<>).IsDefinitionEqual(resultType)) + { + // 将 HttpResponseMessage 转换为 resultType 类型实例 + return _httpContentConverterFactory.Read(resultType, httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + cancellationToken); + } + + // 将 HttpResponseMessage 转换为 HttpRemoteResult 泛型类型 T 的实例 + var result = _httpContentConverterFactory.Read(resultType.GetGenericArguments()[0], + httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + cancellationToken); + + // 动态创建 HttpRemoteResult 实例并转换为 resultType 类型实例 + return DynamicCreateHttpRemoteResult(resultType, httpResponseMessage, result, requestDuration); + } + + /// + public Task SendAsAsync(Type resultType, HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => SendAsAsync(resultType, httpRequestBuilder, + HttpCompletionOption.ResponseContentRead, cancellationToken); + + /// + public async Task SendAsAsync(Type resultType, HttpRequestBuilder httpRequestBuilder, + HttpCompletionOption completionOption, CancellationToken cancellationToken = default) + { + // 发送 HTTP 远程请求 + var (httpResponseMessage, requestDuration) = await SendCoreAsync(httpRequestBuilder, completionOption, + (httpClient, httpRequestMessage, option, token) => + httpClient.SendAsync(httpRequestMessage, option, token), default, cancellationToken).ConfigureAwait(false); + + // 检查类型是否是 HttpRemoteResult 类型 + if (!typeof(HttpRemoteResult<>).IsDefinitionEqual(resultType)) + { + // 将 HttpResponseMessage 转换为 resultType 类型实例 + return await _httpContentConverterFactory.ReadAsync(resultType, httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + cancellationToken).ConfigureAwait(false); + } + + // 将 HttpResponseMessage 转换为 HttpRemoteResult 泛型类型 T 的实例 + var result = await _httpContentConverterFactory.ReadAsync(resultType.GetGenericArguments()[0], + httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), + cancellationToken).ConfigureAwait(false); + + // 动态创建 HttpRemoteResult 实例并转换为 resultType 类型实例 + return DynamicCreateHttpRemoteResult(resultType, httpResponseMessage, result, requestDuration); + } + + /// + public HttpRemoteResult Send(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => + Send(httpRequestBuilder, HttpCompletionOption.ResponseContentRead, cancellationToken); + + /// + public HttpRemoteResult Send(HttpRequestBuilder httpRequestBuilder, + HttpCompletionOption completionOption, CancellationToken cancellationToken = default) + { + // 发送 HTTP 远程请求 + var (httpResponseMessage, requestDuration) = SendCoreAsync(httpRequestBuilder, completionOption, default, + (httpClient, httpRequestMessage, option, token) => + httpClient.Send(httpRequestMessage, option, token), cancellationToken).GetAwaiter().GetResult(); + + // 将 HttpResponseMessage 转换为 TResult 实例 + var result = _httpContentConverterFactory.Read(httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), cancellationToken); + + // 初始化 HttpRemoteResult 实例 + var httpRemoteResult = new HttpRemoteResult(httpResponseMessage) + { + Result = result, + RequestDuration = requestDuration + }; + + return httpRemoteResult; + } + + /// + public Task> SendAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default) => SendAsync(httpRequestBuilder, + HttpCompletionOption.ResponseContentRead, cancellationToken); + + /// + public async Task> SendAsync(HttpRequestBuilder httpRequestBuilder, + HttpCompletionOption completionOption, CancellationToken cancellationToken = default) + { + // 发送 HTTP 远程请求 + var (httpResponseMessage, requestDuration) = await SendCoreAsync(httpRequestBuilder, completionOption, + (httpClient, httpRequestMessage, option, token) => + httpClient.SendAsync(httpRequestMessage, option, token), default, cancellationToken).ConfigureAwait(false); + + // 将 HttpResponseMessage 转换为 TResult 实例 + var result = await _httpContentConverterFactory.ReadAsync(httpResponseMessage, + httpRequestBuilder.HttpContentConverterProviders?.SelectMany(u => u.Invoke()).ToArray(), cancellationToken).ConfigureAwait(false); + + // 初始化 HttpRemoteResult 实例 + var httpRemoteResult = new HttpRemoteResult(httpResponseMessage) + { + Result = result, + RequestDuration = requestDuration + }; + + return httpRemoteResult; + } + + /// + /// 发送 HTTP 远程请求并处理 实例 + /// + /// + /// + /// + /// + /// + /// + /// 异步发送 HTTP 请求的委托 + /// 同步发送 HTTP 请求的委托 + /// + /// + /// + /// + /// + /// + internal async Task<(HttpResponseMessage ResponseMessage, long RequestDuration)> SendCoreAsync( + HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + Func>? + sendAsyncMethod, + Func? sendMethod, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRequestBuilder); + + // 空检查 + if (sendAsyncMethod is null && sendMethod is null) + { + throw new InvalidOperationException("Both `sendAsyncMethod` and `sendMethod` cannot be null."); + } + + // 解析 IHttpRequestEventHandler 事件处理程序 + var requestEventHandler = + (httpRequestBuilder.RequestEventHandlerType is not null + ? ServiceProvider.GetService(httpRequestBuilder.RequestEventHandlerType) + : null) as IHttpRequestEventHandler; + + // 创建带有默认值的 HttpClient 实例 + var httpClientPooling = CreateHttpClientWithDefaults(httpRequestBuilder); + var httpClient = httpClientPooling.Instance; + + // 构建 HttpRequestMessage 实例 + var httpRequestMessage = + httpRequestBuilder.Build(_httpRemoteOptions, _httpContentProcessorFactory, httpClient.BaseAddress); + + // 处理发送 HTTP 请求之前 + HandlePreSendRequest(httpRequestBuilder, requestEventHandler, httpRequestMessage); + + // 检查是否启用请求分析工具 + if (httpRequestBuilder.ProfilerEnabled) + { + await ProfilerDelegatingHandler.LogRequestAsync(_logger, _httpRemoteOptions, httpRequestMessage, + cancellationToken).ConfigureAwait(false); + } + + // 创建关联的超时 Token 标识 + using var timeoutCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 设置单次请求超时时间控制 + if (httpRequestBuilder.Timeout is not null && httpRequestBuilder.Timeout.Value != TimeSpan.Zero) + { + timeoutCancellationTokenSource.CancelAfter(httpRequestBuilder.Timeout.Value); + } + + HttpResponseMessage? httpResponseMessage = null; + + // 初始化 Stopwatch 实例并开启计时操作 + var stopwatch = Stopwatch.StartNew(); + + try + { + // 调用发送 HTTP 请求委托 + httpResponseMessage = sendAsyncMethod is not null + ? await sendAsyncMethod(httpClient, httpRequestMessage, completionOption, + timeoutCancellationTokenSource.Token).ConfigureAwait(false) + : sendMethod!(httpClient, httpRequestMessage, completionOption, timeoutCancellationTokenSource.Token); + + // 处理重定向问题 + var redirections = 0; + while (Helpers.IsRedirectStatusCode(httpResponseMessage.StatusCode) && + _httpRemoteOptions.AllowAutoRedirect && + redirections < _httpRemoteOptions.MaximumAutomaticRedirections) + { + // 获取重定向地址 + var redirectUrl = httpResponseMessage.Headers.Location; + + // 空检查 + if (redirectUrl is null) + { + break; + } + + // 构建新的 HttpRequestMessage 实例(TODO:未来考虑克隆新的 HttpRequestBuilder 实例) + var newHttpRequestMessage = httpRequestBuilder + // 处理相对地址 + .RewriteRequestUri(redirectUrl.IsAbsoluteUri + ? redirectUrl + : new Uri(Helpers.ParseBaseAddress(httpRequestMessage.RequestUri), redirectUrl)) + .Build(_httpRemoteOptions, _httpContentProcessorFactory, httpClient.BaseAddress); + + // 释放前一个 HttpResponseMessage 实例 + httpResponseMessage.Dispose(); + + // 重新调用发送 HTTP 请求委托 + httpResponseMessage = sendAsyncMethod is not null + ? await sendAsyncMethod(httpClient, newHttpRequestMessage, completionOption, + timeoutCancellationTokenSource.Token).ConfigureAwait(false) + : sendMethod!(httpClient, newHttpRequestMessage, completionOption, + timeoutCancellationTokenSource.Token); + + // 递增重定向次数 + redirections++; + } + + // 获取请求耗时 + var requestDuration = stopwatch.ElapsedMilliseconds; + + // 调用状态码处理程序 + if (sendAsyncMethod is not null) + { + await InvokeStatusCodeHandlersAsync(httpRequestBuilder, httpResponseMessage, + timeoutCancellationTokenSource.Token).ConfigureAwait(false); + } + else + { + // ReSharper disable once MethodHasAsyncOverload + InvokeStatusCodeHandlers(httpRequestBuilder, httpResponseMessage, timeoutCancellationTokenSource.Token); + } + + // 检查是否启用请求分析工具 + if (httpRequestBuilder.ProfilerEnabled) + { + await ProfilerDelegatingHandler.LogResponseAsync(_logger, _httpRemoteOptions, httpResponseMessage, + requestDuration, cancellationToken).ConfigureAwait(false); + } + + // 检查 HTTP 响应内容长度是否在设定的最大缓冲区大小限制内 + CheckContentLengthWithinLimit(httpRequestBuilder, httpResponseMessage); + + // 如果 HTTP 响应的 IsSuccessStatusCode 属性是 false,则引发异常 + if (httpRequestBuilder.EnsureSuccessStatusCodeEnabled) + { + httpResponseMessage.EnsureSuccessStatusCode(); + } + + return (httpResponseMessage, requestDuration); + } + catch (Exception e) + { + // 处理发送 HTTP 请求发生异常 + HandleRequestFailed(httpRequestBuilder, requestEventHandler, e, httpResponseMessage); + + throw; + } + finally + { + // 停止计时 + stopwatch.Stop(); + + // 处理收到 HTTP 响应之后 + HandlePostReceiveResponse(httpRequestBuilder, requestEventHandler, httpResponseMessage); + + // 释放资源集合 + if (!httpRequestBuilder.HttpClientPoolingEnabled) + { + httpRequestBuilder.ReleaseResources(); + } + } + } + + /// + /// 处理发送 HTTP 请求之前 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static void HandlePreSendRequest(HttpRequestBuilder httpRequestBuilder, + IHttpRequestEventHandler? requestEventHandler, HttpRequestMessage httpRequestMessage) + { + // 空检查 + if (requestEventHandler is not null) + { + DelegateExtensions.TryInvoke(requestEventHandler.OnPreSendRequest, httpRequestMessage); + } + + httpRequestBuilder.OnPreSendRequest.TryInvoke(httpRequestMessage); + } + + /// + /// 处理收到 HTTP 响应之后 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static void HandlePostReceiveResponse(HttpRequestBuilder httpRequestBuilder, + IHttpRequestEventHandler? requestEventHandler, HttpResponseMessage? httpResponseMessage) + { + // 空检查 + if (httpResponseMessage is null) + { + return; + } + + // 空检查 + if (requestEventHandler is not null) + { + DelegateExtensions.TryInvoke(requestEventHandler.OnPostReceiveResponse, httpResponseMessage); + } + + httpRequestBuilder.OnPostReceiveResponse.TryInvoke(httpResponseMessage); + } + + /// + /// 处理发送 HTTP 请求发生异常 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static void HandleRequestFailed(HttpRequestBuilder httpRequestBuilder, + IHttpRequestEventHandler? requestEventHandler, Exception e, HttpResponseMessage? httpResponseMessage) + { + // 空检查 + if (requestEventHandler is not null) + { + DelegateExtensions.TryInvoke(requestEventHandler.OnRequestFailed, e, httpResponseMessage); + } + + httpRequestBuilder.OnRequestFailed.TryInvoke(e, httpResponseMessage); + } + + /// + /// 创建带有默认值的 实例 + /// + /// + /// + /// + /// + /// + /// + internal HttpClientPooling CreateHttpClientWithDefaults(HttpRequestBuilder httpRequestBuilder) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRequestBuilder); + + // 检查是否已经存在 HttpClientPooling 实例 + if (httpRequestBuilder.HttpClientPooling is not null) + { + return httpRequestBuilder.HttpClientPooling; + } + + // 使用锁确保线程安全 + lock (httpRequestBuilder) + { + return httpRequestBuilder.HttpClientPooling ?? CreateHttpClientPooling(httpRequestBuilder); + } + } + + /// + /// 创建 实例管理器 + /// + /// + /// + /// + /// + /// + /// + internal HttpClientPooling CreateHttpClientPooling(HttpRequestBuilder httpRequestBuilder) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRequestBuilder); + + Action? release = null; + HttpClient httpClient; + + // 检查是否设置了 HttpClient 实例提供器 + if (httpRequestBuilder.HttpClientProvider is null) + { + httpClient = string.IsNullOrWhiteSpace(httpRequestBuilder.HttpClientName) + ? _httpClientFactory.CreateClient() + : _httpClientFactory.CreateClient(httpRequestBuilder.HttpClientName); + } + else + { + // 调用 HttpClient 实例提供器 + var provider = httpRequestBuilder.HttpClientProvider(); + httpClient = provider.Instance; + release = provider.Release; + } + + // 空检查 + ArgumentNullException.ThrowIfNull(httpClient); + + // 添加默认的 User-Agent 标头 + AddDefaultUserAgentHeader(httpClient); + + // 存储 HttpClientPooling 实例并返回 + return httpRequestBuilder.HttpClientPooling = new HttpClientPooling(httpClient, release); + } + + /// + /// 向 添加默认的 User-Agent 标头 + /// + /// 解决某些服务器可能需要这个头部信息才能正确响应请求。 + /// + /// + /// + internal static void AddDefaultUserAgentHeader(HttpClient httpClient) + { + // 空检查 + if (httpClient.DefaultRequestHeaders.UserAgent.Count != 0) + { + return; + } + + // User-Agent 默认格式为:程序集名称/程序集版本号 + httpClient.DefaultRequestHeaders.UserAgent.Add(typeof(HttpRemoteService).Assembly.ConvertTo(ass => + new ProductInfoHeaderValue(ass.GetName().Name!, + ass.GetVersion()?.ToString() ?? Constants.UNKNOWN_USER_AGENT_VERSION))); + } + + /// + /// 检查 HTTP 响应内容长度是否在设定的最大缓冲区大小限制内 + /// + /// + /// + /// + /// + /// + /// + /// + internal static void CheckContentLengthWithinLimit(HttpRequestBuilder httpRequestBuilder, + HttpResponseMessage httpResponseMessage) + { + // 空检查 + if (httpRequestBuilder.MaxResponseContentBufferSize is null) + { + return; + } + + // 检查响应内容长度 + if (httpResponseMessage.Content.Headers.ContentLength is { } contentLength && + contentLength > httpRequestBuilder.MaxResponseContentBufferSize) + { + throw new HttpRequestException( + $"Cannot write more bytes to the buffer than the configured maximum buffer size: `{httpRequestBuilder.MaxResponseContentBufferSize}`."); + } + } + + /// + /// 调用状态码处理程序 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static void InvokeStatusCodeHandlers(HttpRequestBuilder httpRequestBuilder, + HttpResponseMessage httpResponseMessage, CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRequestBuilder); + ArgumentNullException.ThrowIfNull(httpResponseMessage); + + // 后台线程中启动异步任务 + Task.Run( + async () => await InvokeStatusCodeHandlersAsync(httpRequestBuilder, httpResponseMessage, cancellationToken).ConfigureAwait(false), + cancellationToken); + } + + /// + /// 调用状态码处理程序 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static async Task InvokeStatusCodeHandlersAsync(HttpRequestBuilder httpRequestBuilder, + HttpResponseMessage httpResponseMessage, CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(httpRequestBuilder); + ArgumentNullException.ThrowIfNull(httpResponseMessage); + + // 空检查 + if (httpRequestBuilder.StatusCodeHandlers is null || httpRequestBuilder.StatusCodeHandlers.Count == 0) + { + return; + } + + // 获取响应状态码 + var statusCode = (int)httpResponseMessage.StatusCode; + + // 查找响应状态码所有处理程序 + var statusCodeHandlers = httpRequestBuilder.StatusCodeHandlers + .Where(u => u.Key.Any(code => IsMatchedStatusCode(code, statusCode))) + .Select(u => u.Value).ToList(); + + // 空检查 + if (statusCodeHandlers.Count == 0) + { + return; + } + + // 并行执行所有的处理程序,并等待所有任务完成 + await Task.WhenAll(statusCodeHandlers.Select(handler => + handler.TryInvokeAsync(httpResponseMessage, cancellationToken))).ConfigureAwait(false); + } + + /// + /// 检查状态码代码是否匹配响应状态码 + /// + /// 状态码代码 + /// 响应状态码 + /// + /// + /// + internal static bool IsMatchedStatusCode(object code, int statusCode) + { + switch (code) + { + // 处理正整数类型 + case int intStatusCode when intStatusCode == statusCode: + return true; + // 处理 HttpStatusCode 枚举类型 + case HttpStatusCode httpStatusCode when (int)httpStatusCode == statusCode: + return true; + // 处理特殊字符串 + case "*" or '*': + return true; + // 处理字符串类型 + case string stringStatusCode when !stringStatusCode.Contains('+') && + int.TryParse(stringStatusCode, out var intStatusCodeResult) && + intStatusCodeResult == statusCode: + return true; + // 处理字符串区间类型,如 200-500 + case string stringStatusCode when StatusCodeRangeRegex().IsMatch(stringStatusCode): + // 根据 - 符号切割 + var parts = stringStatusCode.Split('-', StringSplitOptions.RemoveEmptyEntries); + + // 比较状态码区间 + if (parts.Length == 2 && int.TryParse(parts[0], out var start) && int.TryParse(parts[1], out var end)) + { + return statusCode >= start && statusCode <= end; + } + + break; + // 处理包含比较符号的类型:如:>=200, <=300, <100, =100, >100 + case string compareStatusCode when StatusCodeCompareRegex().IsMatch(compareStatusCode): + // 提取正则表达式内容并获取符号和数字部分 + var match = StatusCodeCompareRegex().Match(compareStatusCode); + var symbolPart = match.Groups[1].Value; + var numberPart = match.Groups[2].Value; + + // 获取状态码 + if (!int.TryParse(numberPart, out var number)) + { + return false; + } + + return symbolPart switch + { + ">=" => statusCode >= number, + "<=" => statusCode <= number, + ">" => statusCode > number, + "<" => statusCode < number, + "=" => statusCode == number, + _ => false + }; + default: + return false; + } + + return false; + } + + /// + /// 动态创建 实例 + /// + /// 类型 + /// + /// + /// + /// 泛型类型的实例 + /// 请求耗时(毫秒) + /// + /// + /// + /// + internal static object DynamicCreateHttpRemoteResult(Type httpRemoteResultType, + HttpResponseMessage httpResponseMessage, + object? result, long requestDuration) + { + // 检查类型是否是 HttpRemoteResult 类型 + if (!typeof(HttpRemoteResult<>).IsDefinitionEqual(httpRemoteResultType)) + { + throw new ArgumentException( + $"`{httpRemoteResultType}` type is not assignable from `{typeof(HttpRemoteResult<>)}`.", + nameof(httpRemoteResultType)); + } + + // 反射创建 HttpRemoteResult 实例 + var httpRemoteResult = Activator.CreateInstance(httpRemoteResultType, httpResponseMessage); + + // 空检查 + ArgumentNullException.ThrowIfNull(httpRemoteResult); + + // 初始化反射搜索成员方式 + const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + // 获取 Result 和 RequestDuration 属性设置器 + var setResultDelegate = + httpRemoteResultType.CreatePropertySetter(httpRemoteResultType.GetProperty( + nameof(HttpRemoteResult.Result), + bindingFlags)!); + var setRequestDurationDelegate = + httpRemoteResultType.CreatePropertySetter(httpRemoteResultType.GetProperty( + nameof(HttpRemoteResult.RequestDuration), + bindingFlags)!); + + // 设置 Result 和 RequestDuration 属性值 + setResultDelegate(httpRemoteResult, result); + setRequestDurationDelegate(httpRemoteResult, requestDuration); + + return httpRemoteResult; + } + + /// + /// 状态码区间正则表达式 + /// + /// + [GeneratedRegex(@"^\d+-\d+$")] + private static partial Regex StatusCodeRangeRegex(); + + /// + /// 状态码比较正则表达式 + /// + /// + [GeneratedRegex(@"^([<>]=?|=|>|<)(\d+)$")] + private static partial Regex StatusCodeCompareRegex(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/IHttpRemoteService.Extensions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/IHttpRemoteService.Extensions.cs new file mode 100644 index 000000000..0262d8a78 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/IHttpRemoteService.Extensions.cs @@ -0,0 +1,418 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求服务 +/// +public partial interface IHttpRemoteService +{ + /// + /// 下载文件 + /// + /// 请求地址 + /// 文件保存的目标路径 + /// 用于传输进度发生变化时执行的委托 + /// + /// + /// + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + void DownloadFile(string? requestUri, string? destinationPath, + Func? onProgressChanged = null, + FileExistsBehavior fileExistsBehavior = FileExistsBehavior.CreateNew, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default); + + /// + /// 下载文件 + /// + /// 请求地址 + /// 文件保存的目标路径 + /// 用于传输进度发生变化时执行的委托 + /// + /// + /// + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task DownloadFileAsync(string? requestUri, string? destinationPath, + Func? onProgressChanged = null, + FileExistsBehavior fileExistsBehavior = FileExistsBehavior.CreateNew, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default); + + /// + /// 下载文件 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + void Send(HttpFileDownloadBuilder httpFileDownloadBuilder, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 下载文件 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task SendAsync(HttpFileDownloadBuilder httpFileDownloadBuilder, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 上传文件 + /// + /// 请求地址 + /// 文件路径 + /// 表单名称;默认值为 file。 + /// 用于传输进度发生变化时执行的委托 + /// 文件的名称 + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage UploadFile(string? requestUri, string filePath, string name = "file", + Func? onProgressChanged = null, string? fileName = null, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default); + + /// + /// 上传文件 + /// + /// 请求地址 + /// 文件路径 + /// 表单名称;默认值为 file。 + /// 用于传输进度发生变化时执行的委托 + /// 文件的名称 + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task UploadFileAsync(string? requestUri, string filePath, string name = "file", + Func? onProgressChanged = null, string? fileName = null, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default); + + /// + /// 上传文件 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Send(HttpFileUploadBuilder httpFileUploadBuilder, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 上传文件 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task SendAsync(HttpFileUploadBuilder httpFileUploadBuilder, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 Server-Sent Events 请求 + /// + /// 请求地址 + /// 用于在从事件源接收到数据时的操作 + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + void ServerSentEvents(string? requestUri, Func onMessage, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 Server-Sent Events 请求 + /// + /// 请求地址 + /// 用于在从事件源接收到数据时的操作 + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task ServerSentEventsAsync(string? requestUri, Func onMessage, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 Server-Sent Events 请求 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + void Send(HttpServerSentEventsBuilder httpServerSentEventsBuilder, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 Server-Sent Events 请求 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task SendAsync(HttpServerSentEventsBuilder httpServerSentEventsBuilder, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 压力测试 + /// + /// 请求地址 + /// 并发请求数量,默认值为:100。 + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + StressTestHarnessResult StressTestHarness(string? requestUri, int numberOfRequests = 100, + Action? configure = null, Action? requestConfigure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default); + + /// + /// 压力测试 + /// + /// 请求地址 + /// 并发请求数量,默认值为:100。 + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task StressTestHarnessAsync(string? requestUri, int numberOfRequests = 100, + Action? configure = null, Action? requestConfigure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default); + + /// + /// 压力测试 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + StressTestHarnessResult Send(HttpStressTestHarnessBuilder httpStressTestHarnessBuilder, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default); + + /// + /// 压力测试 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task SendAsync(HttpStressTestHarnessBuilder httpStressTestHarnessBuilder, + Action? configure = null, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead, + CancellationToken cancellationToken = default); + + /// + /// 发送长轮询请求 + /// + /// 请求地址 + /// 用于接收服务器返回 200~299 状态码的数据的操作 + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + void LongPolling(string? requestUri, Func onDataReceived, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送长轮询请求 + /// + /// 请求地址 + /// 用于接收服务器返回 200~299 状态码的数据的操作 + /// 自定义配置委托 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task LongPollingAsync(string? requestUri, Func onDataReceived, + Action? configure = null, Action? requestConfigure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送长轮询请求 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + void Send(HttpLongPollingBuilder httpLongPollingBuilder, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送长轮询请求 + /// + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task SendAsync(HttpLongPollingBuilder httpLongPollingBuilder, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 声明式请求 + /// + /// 仅支持同步方法。 + /// 被调用方法 + /// 被调用方法的参数值数组 + /// + /// + /// + object? Declarative(MethodInfo method, object[] args); + + /// + /// 发送 HTTP 声明式请求 + /// + /// 仅支持异步方法。若无返回值则泛型传入 类型。 + /// 被调用方法 + /// 被调用方法的参数值数组 + /// 转换的目标类型 + /// + /// + /// + Task DeclarativeAsync(MethodInfo method, object[] args); + + /// + /// 发送 HTTP 声明式请求 + /// + /// 仅支持同步方法。 + /// + /// + /// + /// + /// + /// + object? SendAs(HttpDeclarativeBuilder httpDeclarativeBuilder); + + /// + /// 发送 HTTP 声明式请求 + /// + /// 仅支持异步方法。若无返回值则泛型传入 类型。 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task SendAsAsync(HttpDeclarativeBuilder httpDeclarativeBuilder); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/IHttpRemoteService.HttpMethods.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/IHttpRemoteService.HttpMethods.cs new file mode 100644 index 000000000..a5c507b83 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/IHttpRemoteService.HttpMethods.cs @@ -0,0 +1,3062 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求服务 +/// +public partial interface IHttpRemoteService +{ + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Get(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Get(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task GetAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task GetAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? GetAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? GetAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task GetAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task GetAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Get(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Get(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> GetAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> GetAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? GetAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? GetAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? GetAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? GetAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? GetAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? GetAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task GetAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task GetAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task GetAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task GetAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task GetAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP GET 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task GetAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Put(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Put(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task PutAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task PutAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? PutAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? PutAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task PutAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task PutAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Put(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Put(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> PutAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> PutAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? PutAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? PutAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? PutAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? PutAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? PutAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? PutAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PutAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PutAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task PutAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PutAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PutAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PUT 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task PutAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Post(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Post(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task PostAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task PostAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? PostAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? PostAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task PostAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task PostAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Post(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Post(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> PostAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> PostAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? PostAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? PostAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? PostAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? PostAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? PostAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? PostAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PostAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PostAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task PostAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PostAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PostAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP POST 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task PostAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Delete(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Delete(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task DeleteAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task DeleteAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? DeleteAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? DeleteAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task DeleteAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task DeleteAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Delete(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Delete(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> DeleteAsync(string? requestUri, + Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> DeleteAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? DeleteAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? DeleteAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? DeleteAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? DeleteAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? DeleteAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? DeleteAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task DeleteAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task DeleteAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task DeleteAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task DeleteAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task DeleteAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP DELETE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task DeleteAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Head(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Head(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task HeadAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task HeadAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? HeadAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? HeadAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task HeadAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task HeadAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Head(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Head(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> HeadAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> HeadAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? HeadAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? HeadAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? HeadAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? HeadAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? HeadAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? HeadAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task HeadAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task HeadAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task HeadAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task HeadAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task HeadAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP HEAD 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task HeadAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Options(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Options(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task OptionsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task OptionsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? OptionsAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? OptionsAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task OptionsAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task OptionsAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Options(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Options(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> OptionsAsync(string? requestUri, + Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> OptionsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? OptionsAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? OptionsAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? OptionsAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? OptionsAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? OptionsAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? OptionsAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task OptionsAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task OptionsAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task OptionsAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task OptionsAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task OptionsAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP OPTIONS 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task OptionsAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Trace(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Trace(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task TraceAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task TraceAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? TraceAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? TraceAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task TraceAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task TraceAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Trace(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Trace(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> TraceAsync(string? requestUri, + Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> TraceAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? TraceAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? TraceAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? TraceAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? TraceAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? TraceAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? TraceAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task TraceAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task TraceAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task TraceAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task TraceAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task TraceAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP TRACE 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task TraceAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Patch(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + HttpResponseMessage Patch(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task PatchAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + Task PatchAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? PatchAs(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? PatchAs(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task PatchAsAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task PatchAsAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Patch(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Patch(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> PatchAsync(string? requestUri, + Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> PatchAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? PatchAsString(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? PatchAsStream(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? PatchAsByteArray(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public string? PatchAsString(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Stream? PatchAsStream(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public byte[]? PatchAsByteArray(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PatchAsStringAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PatchAsStreamAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task PatchAsByteArrayAsync(string? requestUri, Action? configure = null, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PatchAsStringAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// + /// + public Task PatchAsStreamAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP PATCH 远程请求 + /// + /// 请求地址 + /// + /// + /// + /// 自定义配置委托 + /// + /// + /// + /// + /// byte[] + /// + public Task PatchAsByteArrayAsync(string? requestUri, HttpCompletionOption completionOption, + Action? configure = null, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/IHttpRemoteService.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/IHttpRemoteService.cs new file mode 100644 index 000000000..45ae5bf43 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Services/IHttpRemoteService.cs @@ -0,0 +1,492 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.HttpRemote; + +/// +/// HTTP 远程请求服务 +/// +public partial interface IHttpRemoteService +{ + /// + /// + /// + IServiceProvider ServiceProvider { get; } + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + HttpResponseMessage Send(HttpRequestBuilder httpRequestBuilder, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + HttpResponseMessage Send(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task SendAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task SendAsync(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? SendAs(HttpRequestBuilder httpRequestBuilder, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + TResult? SendAs(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + string? SendAsString(HttpRequestBuilder httpRequestBuilder, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + string? SendAsString(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + byte[]? SendAsByteArray(HttpRequestBuilder httpRequestBuilder, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + byte[]? SendAsByteArray(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Stream? SendAsStream(HttpRequestBuilder httpRequestBuilder, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Stream? SendAsStream(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task SendAsAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task SendAsAsync(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task SendAsStringAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task SendAsStringAsync(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + Task SendAsByteArrayAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// byte[] + /// + Task SendAsByteArrayAsync(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task SendAsStreamAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task SendAsStreamAsync(HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// 转换的目标类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + object? SendAs(Type resultType, HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// 转换的目标类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + object? SendAs(Type resultType, HttpRequestBuilder httpRequestBuilder, HttpCompletionOption completionOption, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// 转换的目标类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task SendAsAsync(Type resultType, HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// 转换的目标类型 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + Task SendAsAsync(Type resultType, HttpRequestBuilder httpRequestBuilder, + HttpCompletionOption completionOption, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Send(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + HttpRemoteResult Send(HttpRequestBuilder httpRequestBuilder, + HttpCompletionOption completionOption, CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> SendAsync(HttpRequestBuilder httpRequestBuilder, + CancellationToken cancellationToken = default); + + /// + /// 发送 HTTP 远程请求 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// 转换的目标类型 + /// + /// + /// + Task> SendAsync(HttpRequestBuilder httpRequestBuilder, + HttpCompletionOption completionOption, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Utilities/HttpRemoteUtility.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Utilities/HttpRemoteUtility.cs new file mode 100644 index 000000000..afe8397fc --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/Utilities/HttpRemoteUtility.cs @@ -0,0 +1,148 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net; +using System.Net.Security; +using System.Net.Sockets; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; + +namespace ThingsGateway.HttpRemote; + +/// +/// 提供 HTTP 远程请求实用方法 +/// +public static class HttpRemoteUtility +{ + /// + /// 获取所有支持的 SslProtocols + /// +#pragma warning disable SYSLIB0039 +#pragma warning disable CS0618 // 类型或成员已过时 +#pragma warning disable CA5397 +#pragma warning disable CA5398 // 避免硬编码的 SslProtocols 值 + public static SslProtocols AllSslProtocols => SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Ssl2 | + SslProtocols.Ssl3 | SslProtocols.Tls12 | SslProtocols.Tls13 | + SslProtocols.None; +#pragma warning restore CA5398 // 避免硬编码的 SslProtocols 值 +#pragma warning restore CA5397 +#pragma warning restore CS0618 // 类型或成员已过时 +#pragma warning restore SYSLIB0039 + + /// + /// 忽略 SSL 证书验证 + /// + public static Func IgnoreSslErrors => + (message, cert, chain, errors) => true; + + /// + /// 获取使用 IPv4 连接到服务器的回调 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static ValueTask IPv4ConnectCallback(SocketsHttpConnectionContext context, + CancellationToken cancellationToken) => + IPAddressConnectCallback(AddressFamily.InterNetwork, context, cancellationToken); + + /// + /// 获取使用 IPv6 连接到服务器的回调 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static ValueTask IPv6ConnectCallback(SocketsHttpConnectionContext context, + CancellationToken cancellationToken) => + IPAddressConnectCallback(AddressFamily.InterNetworkV6, context, cancellationToken); + + /// + /// 获取使用 IPv4 或 IPv6 连接到服务器的回调 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static ValueTask UnspecifiedConnectCallback(SocketsHttpConnectionContext context, + CancellationToken cancellationToken) => + IPAddressConnectCallback(AddressFamily.Unspecified, context, cancellationToken); + + /// + /// 获取使用指定 IP 地址类型连接到服务器的回调 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal static async ValueTask IPAddressConnectCallback(AddressFamily addressFamily, + SocketsHttpConnectionContext context, + CancellationToken cancellationToken) + { + // 参考文献: + // - https://www.meziantou.net/forcing-httpclient-to-use-ipv4-or-ipv6-addresses.htm + // - https://learn.microsoft.com/en-us/dotnet/core/runtime-config/#runtimeconfigjson + + // 使用 DNS 查找目标主机的 IP 地址: + // - IPv4: AddressFamily.InterNetwork + // - IPv6: AddressFamily.InterNetworkV6 + // - IPv4 或 IPv6: AddressFamily.Unspecified + // 注意:当主机没有 IP 地址时,此方法会抛出一个 SocketException 异常 + var entry = await Dns.GetHostEntryAsync(context.DnsEndPoint.Host, addressFamily, cancellationToken).ConfigureAwait(false); + + // 打开与目标主机/端口的连接 + var socket = new Socket(SocketType.Stream, ProtocolType.Tcp); + + // 关闭 Nagle 算法,因为这在大多数 HttpClient 场景中会降低性能。 + socket.NoDelay = true; + + try + { + await socket.ConnectAsync(entry.AddressList, context.DnsEndPoint.Port, cancellationToken).ConfigureAwait(false); + + // 如果你想选择特定的 IP 地址来连接服务器 + // await socket.ConnectAsync( + // entry.AddressList[Random.Shared.Next(0, entry.AddressList.Length)], + // context.DnsEndPoint.Port, cancellationToken); + + // 返回 NetworkStream 给调用者 + return new NetworkStream(socket, true); + } + catch + { + socket.Dispose(); + throw; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketBinaryReceiveResult.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketBinaryReceiveResult.cs new file mode 100644 index 000000000..918ded2b9 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketBinaryReceiveResult.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.WebSockets; + +namespace ThingsGateway.HttpRemote; + +/// +/// WebSocket 接收的二进制消息的结果类 +/// +public sealed class WebSocketBinaryReceiveResult : WebSocketReceiveResult +{ + /// + public WebSocketBinaryReceiveResult(int count, bool endOfMessage) + : base(count, WebSocketMessageType.Binary, endOfMessage) + { + } + + /// + public WebSocketBinaryReceiveResult(int count, bool endOfMessage, WebSocketCloseStatus? closeStatus, + string? closeStatusDescription) + : base(count, WebSocketMessageType.Binary, endOfMessage, closeStatus, closeStatusDescription) + { + } + + /// + /// 二进制消息 + /// + public byte[] Message { get; internal init; } = default!; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketClient.Events.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketClient.Events.cs new file mode 100644 index 000000000..0a2e0ee08 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketClient.Events.cs @@ -0,0 +1,128 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// WebSocket 客户端 +/// +public sealed partial class WebSocketClient +{ + /// + /// 开始连接时触发事件 + /// + public event EventHandler? Connecting; + + /// + /// 连接成功时触发事件 + /// + public event EventHandler? Connected; + + /// + /// 开始重新连接时触发事件 + /// + public event EventHandler? Reconnecting; + + /// + /// 重新连接成功时触发事件 + /// + public event EventHandler? Reconnected; + + /// + /// 开始关闭连接时触发事件 + /// + public event EventHandler? Closing; + + /// + /// 关闭连接成功时触发事件 + /// + public event EventHandler? Closed; + + /// + /// 开始接收消息时触发事件 + /// + public event EventHandler? ReceivingStarted; + + /// + /// 停止接收消息时触发事件 + /// + public event EventHandler? ReceivingStopped; + + /// + /// 接收文本消息事件 + /// + public event EventHandler? TextReceived; + + /// + /// 接收二进制消息事件 + /// + public event EventHandler? BinaryReceived; + + /// + /// 触发开始连接事件 + /// + internal void OnConnecting() => Connecting?.TryInvoke(this, EventArgs.Empty); + + /// + /// 触发连接成功事件 + /// + internal void OnConnected() => Connected?.TryInvoke(this, EventArgs.Empty); + + /// + /// 触发开始重新连接事件 + /// + internal void OnReconnecting() => Reconnecting?.TryInvoke(this, EventArgs.Empty); + + /// + /// 触发重新连接成功事件 + /// + internal void OnReconnected() => Reconnected?.TryInvoke(this, EventArgs.Empty); + + /// + /// 触发开始关闭连接事件 + /// + internal void OnClosing() => Closing?.TryInvoke(this, EventArgs.Empty); + + /// + /// 触发关闭连接成功事件 + /// + internal void OnClosed() => Closed?.TryInvoke(this, EventArgs.Empty); + + /// + /// 触发开始接收消息事件 + /// + internal void OnReceivingStarted() => ReceivingStarted?.TryInvoke(this, EventArgs.Empty); + + /// + /// 触发停止接收消息事件 + /// + internal void OnReceivingStopped() => ReceivingStopped?.TryInvoke(this, EventArgs.Empty); + + /// + /// 触发接收文本消息事件 + /// + /// + /// + /// + internal void OnTextReceived(WebSocketTextReceiveResult receiveResult) => + TextReceived?.TryInvoke(this, receiveResult); + + /// + /// 触发接收二进制消息事件 + /// + /// + /// + /// + internal void OnBinaryReceived(WebSocketBinaryReceiveResult receiveResult) => + BinaryReceived?.TryInvoke(this, receiveResult); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketClient.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketClient.cs new file mode 100644 index 000000000..3c2162200 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketClient.cs @@ -0,0 +1,474 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.WebSockets; +using System.Text; + +using ThingsGateway.Extensions; + +namespace ThingsGateway.HttpRemote; + +/// +/// WebSocket 客户端 +/// +public sealed partial class WebSocketClient : IDisposable +{ + /// + internal ClientWebSocket? _clientWebSocket; + + /// + /// 取消接收服务器消息标记 + /// + internal CancellationTokenSource? _messageCancellationTokenSource; + + /// + /// 接收服务器消息任务 + /// + internal Task? _receiveMessageTask; + + /// + /// + /// + /// 服务器地址 + public WebSocketClient(string serverUri) + : this(new WebSocketClientOptions(serverUri)) + { + } + + /// + /// + /// + /// 服务器地址 + public WebSocketClient(Uri serverUri) + : this(new WebSocketClientOptions(serverUri)) + { + } + + /// + /// + /// + /// + /// + /// + public WebSocketClient(WebSocketClientOptions options) + { + // 空检查 + ArgumentNullException.ThrowIfNull(options); + + Options = options; + } + + /// + public WebSocketState? State => _clientWebSocket?.State; + + /// + /// + /// + internal WebSocketClientOptions Options { get; } + + /// + /// 当前重连次数 + /// + internal int CurrentReconnectRetries { get; private set; } + + /// + public void Dispose() + { + // 释放 ClientWebSocket 实例 + _clientWebSocket?.Dispose(); + _clientWebSocket = null; + + // 等待接收服务器消息任务完成 + _messageCancellationTokenSource?.Cancel(); + _messageCancellationTokenSource?.Dispose(); + _messageCancellationTokenSource = null; + + _receiveMessageTask?.Wait(); + _receiveMessageTask = null; + } + + /// + /// 连接到服务器 + /// + /// + /// + /// + public async Task ConnectAsync(CancellationToken cancellationToken = default) + { + // 初始化 ClientWebSocket 实例 + _clientWebSocket ??= new ClientWebSocket(); + + // 调用用于配置 ConfigureClientWebSocketOptions 的操作 + Options.ConfigureClientWebSocketOptions?.Invoke(_clientWebSocket.Options); + + // 检查连接是否处于正在连接或打开状态,如果是则跳过 + if (State is WebSocketState.Connecting or WebSocketState.Open) + { + if (State == WebSocketState.Open) + { + // 重置当前重连次数 + CurrentReconnectRetries = 0; + } + + return; + } + + // 创建关联的连接超时 Token 标识 + using var connectTimeoutCancellationTokenSource = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 设置连接超时时间控制 + if (Options.Timeout is not null && Options.Timeout.Value != TimeSpan.Zero) + { + connectTimeoutCancellationTokenSource.CancelAfter(Options.Timeout.Value); + } + + // 触发开始连接事件 + var onConnecting = OnConnecting; + onConnecting.TryInvoke(); + + try + { + // 连接到服务器 + await _clientWebSocket.ConnectAsync(Options.ServerUri, connectTimeoutCancellationTokenSource.Token).ConfigureAwait(false); + + // 重置当前重连次数 + CurrentReconnectRetries = 0; + + // 触发连接成功事件 + var onConnected = OnConnected; + onConnected.TryInvoke(); + + // 开始监听服务器消息(非阻塞) + await ListenAsync(cancellationToken).ConfigureAwait(false); + } + // 任务被取消 + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + throw; + } + catch (Exception e) + { + // 释放 WebSocketClient 实例 + Dispose(); + + // 输出调试事件 + Debugging.Error(e.Message); + + // 检查是否达到了最大重连次数 + if (CurrentReconnectRetries < Options.MaxReconnectRetries) + { + // 触发开始重新连接事件 + var onReconnecting = OnReconnecting; + onReconnecting.TryInvoke(); + + // 重新连接到服务器 + await ReconnectAsync(cancellationToken).ConfigureAwait(false); + } + else + { + throw; + } + } + } + + /// + /// 重新连接到服务器 + /// + /// + /// + /// + internal async Task ReconnectAsync(CancellationToken cancellationToken = default) + { + // 递增当前重连次数 + CurrentReconnectRetries++; + + // 根据配置的重连的间隔时间延迟重新开始连接 + await Task.Delay(Options.ReconnectInterval, cancellationToken).ConfigureAwait(false); + + // 重新连接到服务器 + await ConnectAsync(cancellationToken).ConfigureAwait(false); + + // 触发重新连接成功事件 + var onReconnected = OnReconnected; + onReconnected.TryInvoke(); + } + + /// + /// 开始监听服务器消息(非阻塞) + /// + /// + /// + /// + /// + /// + /// + internal Task ListenAsync(CancellationToken cancellationToken = default) + { + // 检查连接是否处于打开状态 + if (State == WebSocketState.Open) + { + // 初始化接收服务器消息任务 + _receiveMessageTask ??= ReceiveAsync(cancellationToken); + } + + return Task.CompletedTask; + } + + /// + /// 等待接收服务器消息(阻塞) + /// + /// + /// + /// + internal async Task WaitAsync(CancellationToken cancellationToken = default) + { + // 检查连接是否处于打开状态 + if (State != WebSocketState.Open) + { + return; + } + + // 空检查 + if (_receiveMessageTask is not null) + { + await _receiveMessageTask.ConfigureAwait(false); + } + else + { + await ReceiveAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// 接收服务器消息 + /// + /// + /// + /// + internal async Task ReceiveAsync(CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(_clientWebSocket); + + // 创建关联的取消接收服务器消息 Token 标识 + _messageCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // 触发开始接收消息事件 + var onReceivingStarted = OnReceivingStarted; + onReceivingStarted.TryInvoke(); + + // 初始化缓冲区大小 + var buffer = new byte[Options.ReceiveBufferSize]; + + try + { + // 循环读取服务器消息直到取消请求或连接处于非打开状态 + while (!cancellationToken.IsCancellationRequested && State == WebSocketState.Open) + { + try + { + // 获取接收到的数据 + var receiveResult = + await _clientWebSocket.ReceiveAsync(new ArraySegment(buffer), cancellationToken).ConfigureAwait(false); + + // 如果接收到关闭帧,则退出循环 + if (receiveResult.MessageType == WebSocketMessageType.Close || receiveResult.CloseStatus.HasValue) + { + break; + } + + // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault + switch (receiveResult.MessageType) + { + case WebSocketMessageType.Text: + // 解码接收到的文本消息 + var message = Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); + + // 初始化 WebSocketTextReceiveResult 实例 + var textReceiveResult = new WebSocketTextReceiveResult(receiveResult.Count, + receiveResult.EndOfMessage, receiveResult.CloseStatus, + receiveResult.CloseStatusDescription) + { Message = message }; + + // 触发接收文本消息事件 + var onTextReceived = OnTextReceived; + onTextReceived.TryInvoke(textReceiveResult); + break; + case WebSocketMessageType.Binary: + // 将接收到的数据从原始缓冲区复制到新创建的字节数组中 + var bytes = new byte[receiveResult.Count]; + Buffer.BlockCopy(buffer, 0, bytes, 0, receiveResult.Count); + + // 初始化 WebSocketBinaryReceiveResult 实例 + var binaryReceiveResult = new WebSocketBinaryReceiveResult(receiveResult.Count, + receiveResult.EndOfMessage, receiveResult.CloseStatus, + receiveResult.CloseStatusDescription) + { Message = bytes }; + + // 触发接收二进制消息事件 + var onBinaryReceived = OnBinaryReceived; + onBinaryReceived.TryInvoke(binaryReceiveResult); + break; + } + + // 如果这是消息的最后一部分,则清空缓冲区 + if (receiveResult.EndOfMessage) + { + Array.Clear(buffer, 0, buffer.Length); + } + } + // 任务被取消 + catch (Exception e) when (cancellationToken.IsCancellationRequested || e is OperationCanceledException) + { + break; + } + } + } + finally + { + // 触发停止接收消息事件 + var onReceivingStopped = OnReceivingStopped; + onReceivingStopped.TryInvoke(); + } + } + + /// + /// 向服务器发送消息 + /// + /// 字符串消息 + /// 是否作为消息的最后一部分,默认值为 true。 + /// + /// + /// + public Task SendAsync(string message, bool endOfMessage = true, CancellationToken cancellationToken = default) => + SendAsync(message, WebSocketMessageType.Text, endOfMessage, cancellationToken); + + /// + /// 向服务器发送消息 + /// + /// 字符串消息 + /// + /// + /// + /// 是否作为消息的最后一部分,默认值为 true。 + /// + /// + /// + public async Task SendAsync(string message, WebSocketMessageType webSocketMessageType, bool endOfMessage = true, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(message); + + // 检查连接是否处于打开状态 + if (State != WebSocketState.Open) + { + return; + } + + // 空检查 + ArgumentNullException.ThrowIfNull(_clientWebSocket); + + // 将字符串编码为字节数组 + var buffer = Encoding.UTF8.GetBytes(message); + + // 初始化 ArraySegment 实例 + var arraySegment = new ArraySegment(buffer); + + // 向服务器发送消息 + await _clientWebSocket.SendAsync(arraySegment, webSocketMessageType, endOfMessage, cancellationToken).ConfigureAwait(false); + } + + /// + /// 向服务器发送消息 + /// + /// 二进制消息 + /// 是否作为消息的最后一部分,默认值为 true。 + /// + /// + /// + public async Task SendAsync(byte[] byteArray, bool endOfMessage = true, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(byteArray); + + // 检查连接是否处于打开状态 + if (State != WebSocketState.Open) + { + return; + } + + // 空检查 + ArgumentNullException.ThrowIfNull(_clientWebSocket); + + // 初始化 ArraySegment 实例 + var arraySegment = new ArraySegment(byteArray); + + // 向服务器发送二进制消息 + await _clientWebSocket.SendAsync(arraySegment, WebSocketMessageType.Binary, endOfMessage, cancellationToken).ConfigureAwait(false); + } + + /// + /// 关闭连接 + /// + /// + /// + /// + public Task CloseAsync(CancellationToken cancellationToken = default) => + CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", cancellationToken); + + /// + /// 关闭连接 + /// + /// + /// + /// + /// 关闭描述。默认值为:Closing。 + /// + /// + /// + public async Task CloseAsync(WebSocketCloseStatus closeStatus, string closeDescription, + CancellationToken cancellationToken = default) + { + // 检查连接是否处于关闭状态 + if (State is null or WebSocketState.CloseSent or WebSocketState.Closed) + { + return; + } + + // 空检查 + ArgumentNullException.ThrowIfNull(_clientWebSocket); + + // 触发开始关闭连接事件 + var onClosing = OnClosing; + onClosing.TryInvoke(); + + try + { + // 发送关闭帧并关闭连接 + await _clientWebSocket.CloseAsync(closeStatus, closeDescription, cancellationToken).ConfigureAwait(false); + } + finally + { + // 释放 WebSocketClient 实例 + Dispose(); + + // 重置当前重连次数 + CurrentReconnectRetries = 0; + + // 触发关闭连接成功事件 + var onClosed = OnClosed; + onClosed.TryInvoke(); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketClientOptions.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketClientOptions.cs new file mode 100644 index 000000000..2a2e54922 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketClientOptions.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.WebSockets; + +namespace ThingsGateway.HttpRemote; + +/// +/// WebSocket 客户端配置选项 +/// +public sealed class WebSocketClientOptions +{ + /// + /// + /// + /// 服务器地址 + public WebSocketClientOptions(string serverUri) + { + // 空检查 + ArgumentException.ThrowIfNullOrWhiteSpace(serverUri); + + ServerUri = new Uri(serverUri); + } + + /// + /// + /// + /// 服务器地址 + public WebSocketClientOptions(Uri serverUri) + { + // 空检查 + ArgumentNullException.ThrowIfNull(serverUri); + + ServerUri = serverUri; + } + + /// + /// 服务器地址 + /// + public Uri ServerUri { get; } + + /// + /// 重连的间隔时间(毫秒) + /// + /// 默认值为 2 秒。 + public TimeSpan ReconnectInterval { get; set; } = TimeSpan.FromSeconds(2); + + /// + /// 最大重连次数 + /// + /// 默认最大重连次数为 10。 + public int MaxReconnectRetries { get; set; } = 10; + + /// + /// 超时时间 + /// + public TimeSpan? Timeout { get; set; } + + /// + /// 接收服务器新消息缓冲区大小 + /// + /// 以字节为单位,默认值为 4 KB + public int ReceiveBufferSize { get; set; } = 1024 * 4; + + /// + /// 用于配置 的操作 + /// + public Action? ConfigureClientWebSocketOptions { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketTextReceiveResult.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketTextReceiveResult.cs new file mode 100644 index 000000000..d0d116b87 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/HttpRemote/WebSocket/WebSocketTextReceiveResult.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Net.WebSockets; + +namespace ThingsGateway.HttpRemote; + +/// +/// WebSocket 接收的文本消息的结果类 +/// +public sealed class WebSocketTextReceiveResult : WebSocketReceiveResult +{ + /// + public WebSocketTextReceiveResult(int count, bool endOfMessage) + : base(count, WebSocketMessageType.Text, endOfMessage) + { + } + + /// + public WebSocketTextReceiveResult(int count, bool endOfMessage, WebSocketCloseStatus? closeStatus, + string? closeStatusDescription) + : base(count, WebSocketMessageType.Text, endOfMessage, closeStatus, closeStatusDescription) + { + } + + /// + /// 文本消息 + /// + public string Message { get; internal init; } = default!; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/CompositePolicyContext.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/CompositePolicyContext.cs new file mode 100644 index 000000000..b2b3daddb --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/CompositePolicyContext.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 组合策略上下文 +/// +/// 操作返回值类型 +public sealed class CompositePolicyContext : PolicyContextBase +{ + /// + /// + /// + /// + /// + /// + internal CompositePolicyContext(PolicyBase policy) + { + // 空检查 + ArgumentNullException.ThrowIfNull(policy); + + Policy = policy; + } + + /// + public PolicyBase Policy { get; init; } + + /// + public System.Exception? Exception { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/FallbackPolicyContext.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/FallbackPolicyContext.cs new file mode 100644 index 000000000..c023a4863 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/FallbackPolicyContext.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 后备策略上下文 +/// +/// 操作返回值类型 +public sealed class FallbackPolicyContext : PolicyContextBase +{ + /// + /// + /// + internal FallbackPolicyContext() + { + } + + /// + public System.Exception? Exception { get; internal set; } + + /// + /// 操作返回值 + /// + public TResult? Result { get; internal set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/PolicyContextBase.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/PolicyContextBase.cs new file mode 100644 index 000000000..94d1525e2 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/PolicyContextBase.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 策略上下文抽象基类 +/// +public abstract class PolicyContextBase +{ + /// + /// 策略名称 + /// + public string? PolicyName { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/RetryPolicyContext.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/RetryPolicyContext.cs new file mode 100644 index 000000000..59916d4ac --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/RetryPolicyContext.cs @@ -0,0 +1,49 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 重试策略上下文 +/// +/// 操作返回值类型 +public sealed class RetryPolicyContext : PolicyContextBase +{ + /// + /// + /// + internal RetryPolicyContext() + { + } + + /// + public System.Exception? Exception { get; internal set; } + + /// + /// 操作返回值 + /// + public TResult? Result { get; internal set; } + + /// + /// 当前重试次数 + /// + public int RetryCount { get; internal set; } + + /// + /// 附加属性 + /// + public IDictionary? Properties { get; set; } + + /// + /// 递增上下文数据 + /// + internal void Increment() => RetryCount++; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/TimeoutPolicyContext.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/TimeoutPolicyContext.cs new file mode 100644 index 000000000..5d0cd5ccf --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Contexts/TimeoutPolicyContext.cs @@ -0,0 +1,26 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 超时策略上下文 +/// +/// 操作返回值类型 +public sealed class TimeoutPolicyContext : PolicyContextBase +{ + /// + /// + /// + internal TimeoutPolicyContext() + { + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Dependencies/IExceptionPolicy.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Dependencies/IExceptionPolicy.cs new file mode 100644 index 000000000..2a2ee2b17 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Dependencies/IExceptionPolicy.cs @@ -0,0 +1,115 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 异常策略服务 +/// +/// 操作返回值类型 +public interface IExceptionPolicy +{ + /// + /// 策略名称 + /// + string? PolicyName { get; set; } + + /// + /// 执行同步操作方法 + /// + /// 操作方法 + /// + /// + /// + void Execute(Action operation, CancellationToken cancellationToken = default); + + /// + /// 执行同步操作方法 + /// + /// 操作方法 + /// + /// + /// + void Execute(Action operation, CancellationToken cancellationToken = default); + + /// + /// 执行异步操作方法 + /// + /// 操作方法 + /// + /// + /// + /// + /// + /// + Task ExecuteAsync(Func operation, CancellationToken cancellationToken = default); + + /// + /// 执行异步操作方法 + /// + /// 操作方法 + /// + /// + /// + /// + /// + /// + Task ExecuteAsync(Func operation, CancellationToken cancellationToken = default); + + /// + /// 执行同步操作方法 + /// + /// 操作方法 + /// + /// + /// + /// + /// + /// + TResult? Execute(Func operation, CancellationToken cancellationToken = default); + + /// + /// 执行同步操作方法 + /// + /// 操作方法 + /// + /// + /// + /// + /// + /// + TResult? Execute(Func operation, CancellationToken cancellationToken = default); + + /// + /// 执行异步操作方法 + /// + /// 操作方法 + /// + /// + /// + /// + /// + /// + Task ExecuteAsync(Func> operation, CancellationToken cancellationToken = default); + + /// + /// 执行异步操作方法 + /// + /// 操作方法 + /// + /// + /// + /// + /// + /// + Task ExecuteAsync(Func> operation, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/CompositePolicy.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/CompositePolicy.cs new file mode 100644 index 000000000..d86111377 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/CompositePolicy.cs @@ -0,0 +1,230 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 组合策略 +/// +public sealed class CompositePolicy : CompositePolicy +{ + /// + /// + /// + public CompositePolicy() + { + } + + /// + /// + /// + /// 策略集合 + public CompositePolicy(params PolicyBase[] policies) + : base(policies) + { + } + + /// + /// + /// + /// 策略集合 + public CompositePolicy(params IEnumerable> policies) + : base(policies) + { + } +} + +/// +/// 组合策略 +/// +/// 操作返回值类型 +public class CompositePolicy : PolicyBase +{ + /// + /// + /// + public CompositePolicy() => Policies = []; + + /// + /// + /// + /// 策略集合 + public CompositePolicy(params PolicyBase[] policies) + : this() => + Join(policies); + + /// + /// + /// + /// 策略集合 + public CompositePolicy(params IEnumerable> policies) + : this() => + Join(policies); + + /// + /// 策略集合 + /// + public List> Policies { get; init; } + + /// + /// 执行失败时操作方法 + /// + public Action>? ExecutionFailureAction { get; set; } + + /// + /// 添加策略 + /// + /// 策略集合 + /// + /// + /// + public CompositePolicy Join(params PolicyBase[] policies) + { + // 检查策略集合合法性 + EnsureLegalData(policies); + + Policies.AddRange(policies); + + return this; + } + + /// + /// 添加策略 + /// + /// 策略集合 + /// + /// + /// + public CompositePolicy Join(params IEnumerable> policies) => Join(policies.ToArray()); + + /// + /// 添加执行失败时操作方法 + /// + /// 执行失败时操作方法 + /// + /// + /// + public CompositePolicy OnExecutionFailure(Action> executionFailureAction) + { + // 空检查 + ArgumentNullException.ThrowIfNull(executionFailureAction); + + ExecutionFailureAction = executionFailureAction; + + return this; + } + + /// + public override async Task ExecuteAsync(Func> operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 检查策略集合合法性 + EnsureLegalData(Policies); + + // 检查是否配置了策略集合 + if (Policies is { Count: 0 }) + { + return await operation(cancellationToken).ConfigureAwait(false); + } + + // 生成异步操作方法级联委托 + var cascadeExecuteAsync = Policies + .Select(p => + new Func>, CancellationToken, Task>(p.ExecuteAsync)) + .Aggregate(ExecutePolicyChain); + + // 调用异步操作方法级联委托 + return await cascadeExecuteAsync(operation, cancellationToken).ConfigureAwait(false); + } + + /// + /// 执行策略链 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + internal Func>, CancellationToken, Task> ExecutePolicyChain( + Func>, CancellationToken, Task> previous + , Func>, CancellationToken, Task> current) => + async (opt, token) => + { + object? policy = null; + try + { + // 执行前一个策略 + return await previous(async outerToken => + { + try + { + // 执行当前策略 + return await current(opt, outerToken).ConfigureAwait(false); + } + // 检查内部策略是否已被取消 + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + // 记录执行异常的策略 + policy ??= current.Target; + + throw; + } + }, token).ConfigureAwait(false); + } + // 检查内部策略是否已被取消 + catch (OperationCanceledException) + { + throw; + } + catch (System.Exception exception) + { + // 记录执行异常的策略 + policy ??= previous.Target; + + // 调用执行失败时操作方法 + ExecutionFailureAction?.Invoke(new CompositePolicyContext((PolicyBase)policy!) + { + PolicyName = PolicyName, + Exception = exception + }); + + throw; + } + }; + + /// + /// 检查策略集合合法性 + /// + /// 策略集合 + /// + internal static void EnsureLegalData(params IEnumerable?> policies) + { + // 空检查 + ArgumentNullException.ThrowIfNull(policies); + + // 子项空检查 + if (policies.Any(policy => policy is null)) + { + throw new ArgumentException("The policy collection contains a null value.", nameof(policies)); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/FallbackPolicy.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/FallbackPolicy.cs new file mode 100644 index 000000000..0a6cb2550 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/FallbackPolicy.cs @@ -0,0 +1,418 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 后备策略 +/// +public sealed class FallbackPolicy : FallbackPolicy +{ + /// + /// + /// + public FallbackPolicy() + { + } + + /// + /// + /// + public FallbackPolicy(Func, object?> fallbackAction) + : base(fallbackAction) + { + } + + /// + /// + /// + public FallbackPolicy(Action> fallbackAction) + : base(fallbackAction) + { + } +} + +/// +/// 后备策略 +/// +/// 操作返回值类型 +public class FallbackPolicy : PolicyBase +{ + /// + /// 后备输出信息 + /// + internal const string FALLBACK_MESSAGE = "Operation execution failed! The backup operation will be called shortly."; + + /// + /// + /// + public FallbackPolicy() + { + } + + /// + /// + /// + public FallbackPolicy(Func, TResult?> fallbackAction) => OnFallback(fallbackAction); + + /// + /// + /// + public FallbackPolicy(Action> fallbackAction) => OnFallback(fallbackAction); + + /// + /// 捕获的异常集合 + /// + public HashSet? HandleExceptions { get; set; } + + /// + /// 捕获的内部异常集合 + /// + public HashSet? HandleInnerExceptions { get; set; } + + /// + /// 操作结果条件集合 + /// + public List, bool>>? ResultConditions { get; set; } + + /// + /// 后备操作方法 + /// + public Func, TResult?>? FallbackAction { get; set; } + + /// + /// 添加捕获异常类型 + /// + /// + /// + /// + /// + /// + /// + public FallbackPolicy Handle() + where TException : System.Exception + { + HandleExceptions ??= []; + HandleExceptions.Add(typeof(TException)); + + return this; + } + + /// + /// 添加捕获异常类型 + /// + /// + /// + /// + /// 异常条件 + /// + /// + /// + public FallbackPolicy Handle(Func exceptionCondition) + where TException : System.Exception + { + // 空检查 + ArgumentNullException.ThrowIfNull(exceptionCondition); + + // 添加捕获异常类型和条件 + Handle(); + HandleResult(context => context.Exception is TException exception && exceptionCondition(exception)); + + return this; + } + + /// + /// 添加捕获异常类型 + /// + /// + /// + /// + /// + /// + /// + public FallbackPolicy Or() + where TException : System.Exception => + Handle(); + + /// + /// 添加捕获异常类型 + /// + /// + /// + /// + /// 异常条件 + /// + /// + /// + public FallbackPolicy Or(Func exceptionCondition) + where TException : System.Exception => + Handle(exceptionCondition); + + /// + /// 添加捕获内部异常类型 + /// + /// + /// + /// + /// + /// + /// + public FallbackPolicy HandleInner() + where TException : System.Exception + { + HandleInnerExceptions ??= []; + HandleInnerExceptions.Add(typeof(TException)); + + return this; + } + + /// + /// 添加捕获内部异常类型 + /// + /// + /// + /// + /// 异常条件 + /// + /// + /// + public FallbackPolicy HandleInner(Func exceptionCondition) + where TException : System.Exception + { + // 空检查 + ArgumentNullException.ThrowIfNull(exceptionCondition); + + // 添加捕获内部异常类型和条件 + HandleInner(); + HandleResult(context => + context.Exception?.InnerException is TException exception && exceptionCondition(exception)); + + return this; + } + + /// + /// 添加捕获内部异常类型 + /// + /// + /// + /// + /// + /// + /// + public FallbackPolicy OrInner() + where TException : System.Exception => + HandleInner(); + + /// + /// 添加捕获内部异常类型 + /// + /// + /// + /// + /// 异常条件 + /// + /// + /// + public FallbackPolicy OrInner(Func exceptionCondition) + where TException : System.Exception => + HandleInner(exceptionCondition); + + /// + /// 添加操作结果条件 + /// + /// 操作结果条件 + /// + /// + /// + public FallbackPolicy HandleResult(Func, bool> resultCondition) + { + // 空检查 + ArgumentNullException.ThrowIfNull(resultCondition); + + ResultConditions ??= []; + ResultConditions.Add(resultCondition); + + return this; + } + + /// + /// 添加操作结果条件 + /// + /// 操作结果条件 + /// + /// + /// + public FallbackPolicy OrResult(Func, bool> resultCondition) => + HandleResult(resultCondition); + + /// + /// 添加后备操作方法 + /// + /// 后备操作方法 + /// + /// + /// + public FallbackPolicy OnFallback(Func, TResult?> fallbackAction) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fallbackAction); + + FallbackAction = fallbackAction; + + return this; + } + + /// + /// 添加后备操作方法 + /// + /// 后备操作方法 + /// + /// + /// + public FallbackPolicy OnFallback(Action> fallbackAction) + { + // 空检查 + ArgumentNullException.ThrowIfNull(fallbackAction); + + return OnFallback(context => + { + fallbackAction(context); + + return default; + }); + } + + /// + /// 检查是否满足捕获异常的条件 + /// + /// + /// + /// + /// + /// + /// + internal bool ShouldHandle(FallbackPolicyContext context) + { + // 空检查 + ArgumentNullException.ThrowIfNull(context); + + // 检查是否满足捕获异常的条件 + if (CanHandleException(context, HandleExceptions, context.Exception) + || CanHandleException(context, HandleInnerExceptions, context.Exception?.InnerException)) + { + return true; + } + + // 检查是否满足操作结果条件 + return ResultConditions is { Count: > 0 } && ResultConditions.Any(condition => condition(context)); + } + + /// + public override async Task ExecuteAsync(Func> operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 初始化后备策略上下文 + var context = new FallbackPolicyContext { PolicyName = PolicyName }; + + try + { + // 获取操作方法执行结果 + context.Result = await operation(cancellationToken).ConfigureAwait(false); + } + catch (System.Exception exception) + { + // 设置策略上下文异常信息 + context.Exception = exception; + } + + // 检查是否存在取消请求 + cancellationToken.ThrowIfCancellationRequested(); + + // 检查是否满足捕获异常的条件 + if (!ShouldHandle(context)) + { + return ReturnOrThrowIfException(context); + } + + // 输出调试事件 + Debugging.Error(FALLBACK_MESSAGE); + + // 调用后备操作方法 + return FallbackAction is not null + ? FallbackAction(context) + : + // 返回结果或抛出异常 + ReturnOrThrowIfException(context); + } + + /// + /// 检查是否满足捕获异常的条件 + /// + /// + /// + /// + /// 捕获异常类型集合 + /// + /// + /// + /// + /// + /// + internal bool CanHandleException(FallbackPolicyContext context + , HashSet? exceptionTypes + , System.Exception? exception) + { + // 空检查 + ArgumentNullException.ThrowIfNull(context); + + // 空检查 + if (exception is null) + { + return false; + } + + // 检查是否满足捕获异常的条件 + if (exceptionTypes is not (null or { Count: 0 }) + && !exceptionTypes.Any(ex => ex.IsInstanceOfType(exception))) + { + return false; + } + + // 检查是否满足操作结果条件 + return ResultConditions is not { Count: > 0 } || ResultConditions.Any(condition => condition(context)); + } + + /// + /// 返回结果或抛出异常 + /// + /// + /// + /// + /// + /// + /// + internal static TResult? ReturnOrThrowIfException(FallbackPolicyContext context) + { + // 空检查 + ArgumentNullException.ThrowIfNull(context); + + // 空检查 + if (context.Exception is not null) + { + throw context.Exception; + } + + return context.Result; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/LockPolicy.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/LockPolicy.cs new file mode 100644 index 000000000..66a223f2f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/LockPolicy.cs @@ -0,0 +1,101 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 并发锁策略 +/// +public sealed class LockPolicy : LockPolicy +{ + /// + /// + /// + public LockPolicy() + { + } +} + +/// +/// 并发锁策略 +/// +/// 操作返回值类型 +public class LockPolicy : PolicyBase +{ + /// + /// 异步锁对象 + /// + internal readonly SemaphoreSlim _asyncLock = new(1); + + /// + /// 同步锁对象 + /// + internal readonly object _syncLock = new(); + + /// + /// + /// + public LockPolicy() + { + } + + /// + public override TResult? Execute(Func operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 对同步锁对象进行加锁,确保同一时间只有一个线程可以进入同步代码块 + lock (_syncLock) + { + // 执行操作方法并返回 + return operation(); + } + } + + /// + public override TResult? Execute(Func operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 对同步锁对象进行加锁,确保同一时间只有一个线程可以进入同步代码块 + lock (_syncLock) + { + // 执行操作方法并返回 + return operation(cancellationToken); + } + } + + /// + public override async Task ExecuteAsync(Func> operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 获取异步锁,确保同一时间只有一个异步操作可以进入异步代码块 + await _asyncLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + // 执行操作方法并返回 + return await operation(cancellationToken).ConfigureAwait(false); + } + finally + { + // 释放异步锁 + _asyncLock.Release(); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/PolicyBase.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/PolicyBase.cs new file mode 100644 index 000000000..09a72e11f --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/PolicyBase.cs @@ -0,0 +1,121 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 策略抽象基类 +/// +/// 操作返回值类型 +public abstract class PolicyBase : IExceptionPolicy +{ + /// + public string? PolicyName { get; set; } + + /// + public virtual void Execute(Action operation, CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 执行同步操作方法 + Execute(() => + { + operation(); + + return default; + }, cancellationToken); + } + + /// + public virtual void Execute(Action operation, CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 执行同步操作方法 + Execute(token => + { + operation(token); + + return default; + }, cancellationToken); + } + + /// + public virtual async Task ExecuteAsync(Func operation, CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 执行异步操作方法 + await ExecuteAsync(async () => + { + await operation().ConfigureAwait(false); + + return default; + }, cancellationToken).ConfigureAwait(false); + } + + /// + public virtual async Task ExecuteAsync(Func operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 执行异步操作方法 + await ExecuteAsync(async token => + { + await operation(token).ConfigureAwait(false); + + return default; + }, cancellationToken).ConfigureAwait(false); + } + + /// + public virtual TResult? Execute(Func operation, CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + return ExecuteAsync(() => Task.FromResult(operation()), cancellationToken) + .GetAwaiter() + .GetResult(); + } + + /// + public virtual TResult? Execute(Func operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + return ExecuteAsync(token => Task.FromResult(operation(token)), cancellationToken) + .GetAwaiter() + .GetResult(); + } + + /// + public virtual async Task ExecuteAsync(Func> operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 执行异步操作方法 + return await ExecuteAsync(async _ => await operation().ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + } + + /// + public abstract Task ExecuteAsync(Func> operation, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/RetryPolicy.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/RetryPolicy.cs new file mode 100644 index 000000000..3a1227eae --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/RetryPolicy.cs @@ -0,0 +1,497 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 重试策略 +/// +public sealed class RetryPolicy : RetryPolicy +{ + /// + /// + /// + public RetryPolicy() + { + } + + /// + /// + /// + /// 最大重试次数 + public RetryPolicy(int maxRetryCount) + : base(maxRetryCount) + { + } +} + +/// +/// 重试策略 +/// +/// 操作返回值类型 +public class RetryPolicy : PolicyBase +{ + /// + /// 等待重试输出信息 + /// + internal const string WAIT_RETRY_MESSAGE = "Retry after {0} seconds."; + + /// + /// 重试输出信息 + /// + internal const string RETRY_MESSAGE = "Retrying for the {0}nd time."; + + /// + /// + /// + public RetryPolicy() + { + } + + /// + /// + /// + /// 最大重试次数 + public RetryPolicy(int maxRetryCount) => MaxRetryCount = maxRetryCount; + + /// + /// 最大重试次数 + /// + public int MaxRetryCount { get; set; } + + /// + /// 重试等待时间集合 + /// + public TimeSpan[]? RetryIntervals { get; set; } + + /// + /// 捕获的异常集合 + /// + public HashSet? HandleExceptions { get; set; } + + /// + /// 捕获的内部异常集合 + /// + public HashSet? HandleInnerExceptions { get; set; } + + /// + /// 操作结果条件集合 + /// + public List, bool>>? ResultConditions { get; set; } + + /// + /// 等待重试时操作方法 + /// + public Action, TimeSpan>? WaitRetryAction { get; set; } + + /// + /// 重试时操作方法 + /// + public Action>? RetryingAction { get; set; } + + /// + /// 添加捕获异常类型 + /// + /// + /// + /// + /// + /// + /// + public RetryPolicy Handle() + where TException : System.Exception + { + HandleExceptions ??= []; + HandleExceptions.Add(typeof(TException)); + + return this; + } + + /// + /// 添加捕获异常类型 + /// + /// + /// + /// + /// 异常条件 + /// + /// + /// + public RetryPolicy Handle(Func exceptionCondition) + where TException : System.Exception + { + // 空检查 + ArgumentNullException.ThrowIfNull(exceptionCondition); + + // 添加捕获异常类型和条件 + Handle(); + HandleResult(context => context.Exception is TException exception && exceptionCondition(exception)); + + return this; + } + + /// + /// 添加捕获异常类型 + /// + /// + /// + /// + /// + /// + /// + public RetryPolicy Or() + where TException : System.Exception => + Handle(); + + /// + /// 添加捕获异常类型 + /// + /// + /// + /// + /// 异常条件 + /// + /// + /// + public RetryPolicy Or(Func exceptionCondition) + where TException : System.Exception => + Handle(exceptionCondition); + + /// + /// 添加捕获内部异常类型 + /// + /// + /// + /// + /// + /// + /// + public RetryPolicy HandleInner() + where TException : System.Exception + { + HandleInnerExceptions ??= []; + HandleInnerExceptions.Add(typeof(TException)); + + return this; + } + + /// + /// 添加捕获内部异常类型 + /// + /// + /// + /// + /// 异常条件 + /// + /// + /// + public RetryPolicy HandleInner(Func exceptionCondition) + where TException : System.Exception + { + // 空检查 + ArgumentNullException.ThrowIfNull(exceptionCondition); + + // 添加捕获内部异常类型和条件 + HandleInner(); + HandleResult(context => + context.Exception?.InnerException is TException exception && exceptionCondition(exception)); + + return this; + } + + /// + /// 添加捕获内部异常类型 + /// + /// + /// + /// + /// + /// + /// + public RetryPolicy OrInner() + where TException : System.Exception => + HandleInner(); + + /// + /// 添加捕获内部异常类型 + /// + /// + /// + /// + /// 异常条件 + /// + /// + /// + public RetryPolicy OrInner(Func exceptionCondition) + where TException : System.Exception => + HandleInner(exceptionCondition); + + /// + /// 添加操作结果条件 + /// + /// 操作结果条件 + /// + /// + /// + public RetryPolicy HandleResult(Func, bool> resultCondition) + { + // 空检查 + ArgumentNullException.ThrowIfNull(resultCondition); + + ResultConditions ??= []; + ResultConditions.Add(resultCondition); + + return this; + } + + /// + /// 添加操作结果条件 + /// + /// 操作结果条件 + /// + /// + /// + public RetryPolicy OrResult(Func, bool> resultCondition) => + HandleResult(resultCondition); + + /// + /// 添加重试等待时间 + /// + /// 重试等待时间 + /// + /// + /// + public RetryPolicy WaitAndRetry(params TimeSpan[] retryIntervals) + { + // 空检查 + ArgumentNullException.ThrowIfNull(retryIntervals); + + RetryIntervals = retryIntervals; + + return this; + } + + /// + /// 永久重试 + /// + /// + /// + /// + public RetryPolicy Forever() + { + MaxRetryCount = int.MaxValue; + + return this; + } + + /// + /// 永久重试并添加重试等待时间 + /// + /// 重试等待时间 + /// + /// + /// + public RetryPolicy WaitAndRetryForever(params TimeSpan[] retryIntervals) => + WaitAndRetry(retryIntervals) + .Forever(); + + /// + /// 添加等待重试时操作方法 + /// + /// 等待重试时操作方法 + /// + /// + /// + public RetryPolicy OnWaitRetry(Action, TimeSpan> waitRetryAction) + { + // 空检查 + ArgumentNullException.ThrowIfNull(waitRetryAction); + + WaitRetryAction = waitRetryAction; + + return this; + } + + /// + /// 添加重试时操作方法 + /// + /// 重试时操作方法 + /// + /// + /// + public RetryPolicy OnRetrying(Action> retryingAction) + { + // 空检查 + ArgumentNullException.ThrowIfNull(retryingAction); + + RetryingAction = retryingAction; + + return this; + } + + /// + /// 检查是否满足捕获异常的条件 + /// + /// + /// + /// + /// + /// + /// + internal bool ShouldHandle(RetryPolicyContext context) + { + // 空检查 + ArgumentNullException.ThrowIfNull(context); + + // 检查最大重试次数是否大于等于 0 + if (MaxRetryCount <= 0) + { + return false; + } + + // 检查重试次数是否大于最大重试次数减 + if (context.RetryCount > MaxRetryCount - 1) + { + return false; + } + + // 检查是否满足捕获异常的条件 + if (CanHandleException(context, HandleExceptions, context.Exception) + || CanHandleException(context, HandleInnerExceptions, context.Exception?.InnerException)) + { + return true; + } + + // 检查是否满足操作结果条件 + return ResultConditions is { Count: > 0 } && ResultConditions.Any(condition => condition(context)); + } + + /// + public override async Task ExecuteAsync(Func> operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 初始化重试策略上下文 + var context = new RetryPolicyContext { PolicyName = PolicyName }; + + // 无限循环直到满足条件退出 + while (true) + { + try + { + // 获取操作方法执行结果 + context.Result = await operation(cancellationToken).ConfigureAwait(false); + } + catch (System.Exception exception) + { + // 设置策略上下文异常信息 + context.Exception = exception; + } + + // 检查是否存在取消请求 + cancellationToken.ThrowIfCancellationRequested(); + + // 检查是否满足捕获异常的条件 + if (!ShouldHandle(context)) + { + // 返回结果或抛出异常 + return ReturnOrThrowIfException(context); + } + + // 递增上下文数据 + context.Increment(); + + // 检查是否配置了重试时间 + if (RetryIntervals is { Length: > 0 }) + { + // 解析延迟时间戳 + var delay = RetryIntervals[context.RetryCount % RetryIntervals.Length]; + + // 输出调试事件 + Debugging.Info(WAIT_RETRY_MESSAGE, delay.TotalSeconds); + + // 调用等待重试时操作方法 + WaitRetryAction?.Invoke(context, delay); + + // 延迟指定时间再操作 + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + + // 输出调试事件 + Debugging.Warn(RETRY_MESSAGE, context.RetryCount); + + // 调用重试时操作方法 + RetryingAction?.Invoke(context); + } + } + + /// + /// 检查是否满足捕获异常的条件 + /// + /// + /// + /// + /// 捕获异常类型集合 + /// + /// + /// + /// + /// + /// + internal bool CanHandleException(RetryPolicyContext context + , HashSet? exceptionTypes + , System.Exception? exception) + { + // 空检查 + ArgumentNullException.ThrowIfNull(context); + + // 空检查 + if (exception is null) + { + return false; + } + + // 检查是否满足捕获异常的条件 + if (exceptionTypes is not (null or { Count: 0 }) + && !exceptionTypes.Any(ex => ex.IsInstanceOfType(exception))) + { + return false; + } + + // 检查是否满足操作结果条件 + return ResultConditions is not { Count: > 0 } || ResultConditions.Any(condition => condition(context)); + } + + /// + /// 返回结果或抛出异常 + /// + /// + /// + /// + /// + /// + /// + internal static TResult? ReturnOrThrowIfException(RetryPolicyContext context) + { + // 空检查 + ArgumentNullException.ThrowIfNull(context); + + // 空检查 + if (context.Exception is not null) + { + throw context.Exception; + } + + return context.Result; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/TimeoutPolicy.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/TimeoutPolicy.cs new file mode 100644 index 000000000..f1592964a --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policies/TimeoutPolicy.cs @@ -0,0 +1,201 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; + +namespace ThingsGateway.RescuePolicy; + +/// +/// 超时策略 +/// +/// +/// 若需要测试同步阻塞,建议使用 Task.Delay(...).Wait() 替代 Thread.Sleep(...) +/// +public sealed class TimeoutPolicy : TimeoutPolicy +{ + /// + /// + /// + public TimeoutPolicy() + { + } + + /// + /// + /// + /// 超时时间(毫秒) + public TimeoutPolicy(double timeout) + : base(timeout) + { + } + + /// + /// + /// + /// 超时时间 + public TimeoutPolicy(TimeSpan timeout) + : base(timeout) + { + } +} + +/// +/// 超时策略 +/// +/// +/// 若需要测试同步阻塞,建议使用 Task.Delay(...).Wait() 替代 Thread.Sleep(...) +/// +/// 操作返回值类型 +public class TimeoutPolicy : PolicyBase +{ + /// + /// 超时输出信息 + /// + internal const string TIMEOUT_MESSAGE = "The operation has timed out."; + + /// + /// + /// + public TimeoutPolicy() + { + } + + /// + /// + /// + /// 超时时间(毫秒) + public TimeoutPolicy(double timeout) => Timeout = TimeSpan.FromMilliseconds(timeout); + + /// + /// + /// + /// 超时时间 + public TimeoutPolicy(TimeSpan timeout) => Timeout = timeout; + + /// + /// 超时时间 + /// + public TimeSpan Timeout { get; set; } + + /// + /// 超时时操作方法 + /// + public Action>? TimeoutAction { get; set; } + + /// + /// 添加超时时操作方法 + /// + /// 超时时操作方法 + /// + /// + /// + public TimeoutPolicy OnTimeout(Action> timeoutAction) + { + // 空检查 + ArgumentNullException.ThrowIfNull(timeoutAction); + + TimeoutAction = timeoutAction; + + return this; + } + + /// + public override TResult? Execute(Func operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + return ExecuteAsync(() => Task.Run(operation, cancellationToken), cancellationToken) + .GetAwaiter() + .GetResult(); + } + + /// + public override TResult? Execute(Func operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + return ExecuteAsync(() => Task.Run(() => operation(cancellationToken), cancellationToken), cancellationToken) + .GetAwaiter() + .GetResult(); + } + + /// + public override async Task ExecuteAsync(Func> operation, + CancellationToken cancellationToken = default) + { + // 空检查 + ArgumentNullException.ThrowIfNull(operation); + + // 检查是否配置了超时时间 + if (Timeout == TimeSpan.Zero) + { + return await operation(cancellationToken).ConfigureAwait(false); + } + + // 创建关联的取消标识 + using var cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var stoppingToken = cancellationTokenSource.Token; + + // 设置超时时间 + cancellationTokenSource.CancelAfter(Timeout); + + try + { + // 获取操作方法任务 + var operationTask = operation(stoppingToken); + + // 获取提前完成的任务 + var completedTask = await Task.WhenAny(operationTask, + Task.Delay(System.Threading.Timeout.InfiniteTimeSpan, stoppingToken)).ConfigureAwait(false); + + // 检查是否存在取消请求 + cancellationToken.ThrowIfCancellationRequested(); + + // 检查提前完成的任务是否是操作方法任务 + if (completedTask == operationTask) + { + // 返回操作方法结果 + return await operationTask.ConfigureAwait(false); + } + + // 抛出超时异常 + ThrowTimeoutException(); + } + catch (OperationCanceledException exception) when (exception.CancellationToken == stoppingToken) + { + // 抛出超时异常 + ThrowTimeoutException(); + } + + return default; + } + + /// + /// 抛出超时异常 + /// + /// + [DoesNotReturn] + internal void ThrowTimeoutException() + { + // 输出调试事件 + Debugging.Error(TIMEOUT_MESSAGE); + + // 调用重试时操作方法 + TimeoutAction?.Invoke(new TimeoutPolicyContext { PolicyName = PolicyName }); + + // 抛出超时异常 + throw new TimeoutException(TIMEOUT_MESSAGE); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policy.cs b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policy.cs new file mode 100644 index 000000000..b0feda6a6 --- /dev/null +++ b/src/Admin/ThingsGateway.Furion/V5_Experience/RescuePolicy/Policy.cs @@ -0,0 +1,310 @@ +// ------------------------------------------------------------------------ +// 版权信息 +// 版权归百小僧及百签科技(广东)有限公司所有。 +// 所有权利保留。 +// 官方网站:https://baiqian.com +// +// 许可证信息 +// 项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。 +// 许可证的完整文本可以在源代码树根目录中的 LICENSE-APACHE 和 LICENSE-MIT 文件中找到。 +// ------------------------------------------------------------------------ + +namespace ThingsGateway.RescuePolicy; + +/// +/// 异常策略静态类 +/// +public static class Policy +{ + /// + /// 添加自定义策略 + /// + /// + /// + /// + /// + /// + /// + public static TPolicy For() + where TPolicy : PolicyBase, new() => + new(); + + /// + /// 添加自定义策略 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static TPolicy For(TPolicy policy) + where TPolicy : PolicyBase => + policy; + + /// + /// 初始化重试策略(默认 3 次) + /// + /// + /// + /// + public static RetryPolicy Retry() => Retry(3); + + /// + /// 初始化重试策略 + /// + /// 最大重试次数 + /// + /// + /// + public static RetryPolicy Retry(int maxRetryCount) => new(maxRetryCount); + + /// + /// 初始化超时策略(默认 10 秒) + /// + /// + /// 若需要测试同步阻塞,建议使用 Task.Delay(...).Wait() 替代 Thread.Sleep(...) + /// + /// + /// + /// + public static TimeoutPolicy Timeout() => Timeout(TimeSpan.FromSeconds(10)); + + /// + /// 初始化超时策略 + /// + /// + /// 若需要测试同步阻塞,建议使用 Task.Delay(...).Wait() 替代 Thread.Sleep(...) + /// + /// 超时时间(毫秒) + /// + /// + /// + public static TimeoutPolicy Timeout(double timeout) => new(timeout); + + /// + /// 初始化超时策略 + /// + /// + /// 若需要测试同步阻塞,建议使用 Task.Delay(...).Wait() 替代 Thread.Sleep(...) + /// + /// 超时时间 + /// + /// + /// + public static TimeoutPolicy Timeout(TimeSpan timeout) => new(timeout); + + /// + /// 初始化后备策略 + /// + /// + /// + /// + public static FallbackPolicy Fallback() => new(); + + /// + /// 初始化后备策略 + /// + /// 后备操作方法 + /// + /// + /// + public static FallbackPolicy Fallback(Func, object?> fallbackAction) => + new(fallbackAction); + + /// + /// 初始化后备策略 + /// + /// 后备操作方法 + /// + /// + /// + public static FallbackPolicy Fallback(Action> fallbackAction) => new(fallbackAction); + + /// + /// 初始化组合策略 + /// + /// + /// + /// + public static CompositePolicy Composite() => new(); + + /// + /// 初始化组合策略 + /// + /// 策略集合 + /// + /// + /// + public static CompositePolicy Composite(params PolicyBase[] policies) => new(policies); + + /// + /// 初始化组合策略 + /// + /// 策略集合 + /// + /// + /// + public static CompositePolicy Composite(params IEnumerable> policies) => new(policies); + + /// + /// 并发锁策略 + /// + /// + /// + /// + public static LockPolicy Lock() => new(); +} + +/// +/// 异常策略静态类 +/// +/// 操作返回值类型 +public static class Policy +{ + /// + /// 添加自定义策略 + /// + /// + /// + /// + /// + /// + /// + public static TPolicy For() + where TPolicy : PolicyBase, new() => + new(); + + /// + /// 添加自定义策略 + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static TPolicy For(TPolicy policy) + where TPolicy : PolicyBase => + policy; + + /// + /// 初始化重试策略(默认 3 次) + /// + /// + /// + /// + public static RetryPolicy Retry() => Retry(3); + + /// + /// 初始化重试策略 + /// + /// 最大重试次数 + /// + /// + /// + public static RetryPolicy Retry(int maxRetryCount) => new(maxRetryCount); + + /// + /// 初始化超时策略(默认 10 秒) + /// + /// + /// 若需要测试同步阻塞,建议使用 Task.Delay(...).Wait() 替代 Thread.Sleep(...) + /// + /// + /// + /// + public static TimeoutPolicy Timeout() => Timeout(TimeSpan.FromSeconds(10)); + + /// + /// 初始化超时策略 + /// + /// + /// 若需要测试同步阻塞,建议使用 Task.Delay(...).Wait() 替代 Thread.Sleep(...) + /// + /// 超时时间(毫秒) + /// + /// + /// + public static TimeoutPolicy Timeout(double timeout) => new(timeout); + + /// + /// 初始化超时策略 + /// + /// + /// 若需要测试同步阻塞,建议使用 Task.Delay(...).Wait() 替代 Thread.Sleep(...) + /// + /// 超时时间 + /// + /// + /// + public static TimeoutPolicy Timeout(TimeSpan timeout) => new(timeout); + + /// + /// 初始化后备策略 + /// + /// + /// + /// + public static FallbackPolicy Fallback() => new(); + + /// + /// 初始化后备策略 + /// + /// 后备操作方法 + /// + /// + /// + public static FallbackPolicy Fallback(Func, TResult?> fallbackAction) => + new(fallbackAction); + + /// + /// 初始化后备策略 + /// + /// 后备操作方法 + /// + /// + /// + public static FallbackPolicy Fallback(Action> fallbackAction) => + new(fallbackAction); + + /// + /// 初始化组合策略 + /// + /// + /// + /// + public static CompositePolicy Composite() => new(); + + /// + /// 初始化组合策略 + /// + /// 策略集合 + /// + /// + /// + public static CompositePolicy Composite(params PolicyBase[] policies) => new(policies); + + /// + /// 初始化组合策略 + /// + /// 策略集合 + /// + /// + /// + public static CompositePolicy Composite(params IEnumerable> policies) => new(policies); + + /// + /// 初始化并发锁策略 + /// + /// + /// + /// + public static LockPolicy Lock() => new(); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Attributes/MinValueAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Attributes/MinValueAttribute.cs new file mode 100644 index 000000000..6040f62c7 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Attributes/MinValueAttribute.cs @@ -0,0 +1,46 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway; + +/// +/// 最小值校验 +/// +public sealed class MinValueAttribute : ValidationAttribute +{ + /// + /// 最小值 + /// + /// + public MinValueAttribute(UInt64 value) + { + MinValue = value; + } + + private UInt64 MinValue { get; set; } + + /// + /// 最小值校验 + /// + /// + /// + public override bool IsValid(object? value) + { + if (value is null) + { + return false; + } + + var input = Convert.ToUInt64(value); + return input >= MinValue; + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Buffers/BufferSegment.cs b/src/Admin/ThingsGateway.NewLife.X/Buffers/BufferSegment.cs new file mode 100644 index 000000000..8a28b5862 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Buffers/BufferSegment.cs @@ -0,0 +1,108 @@ +using System.Buffers; +using System.Runtime.CompilerServices; + +namespace ThingsGateway.NewLife.Buffers; + +internal sealed class BufferSegment : ReadOnlySequenceSegment +{ + private IMemoryOwner? _memoryOwner; + + private Byte[]? _array; + + private BufferSegment? _next; + + private Int32 _end; + + public Int32 End + { + get + { + return _end; + } + set + { + _end = value; + base.Memory = AvailableMemory[..value]; + } + } + + public BufferSegment? NextSegment + { + get + { + return _next; + } + set + { + base.Next = value; + _next = value; + } + } + + internal Object? MemoryOwner => ((Object)_memoryOwner) ?? ((Object)_array); + + public Memory AvailableMemory { get; private set; } + + public Int32 Length => End; + + public Int32 WritableBytes + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => AvailableMemory.Length - End; + } + + public void SetOwnedMemory(IMemoryOwner memoryOwner) + { + _memoryOwner = memoryOwner; + AvailableMemory = memoryOwner.Memory; + } + + public void SetOwnedMemory(Byte[] arrayPoolBuffer) + { + _array = arrayPoolBuffer; + AvailableMemory = arrayPoolBuffer; + } + + public void Reset() + { + ResetMemory(); + base.Next = null; + base.RunningIndex = 0L; + _next = null; + } + + public void ResetMemory() + { + var memoryOwner = _memoryOwner; + if (memoryOwner != null) + { + _memoryOwner = null; + memoryOwner.Dispose(); + } + else if (_array != null) + { + ArrayPool.Shared.Return(_array); + _array = null; + } + base.Memory = default; + _end = 0; + AvailableMemory = default; + } + + public void SetNext(BufferSegment segment) + { + NextSegment = segment; + segment = this; + while (segment.Next != null) + { + segment.NextSegment.RunningIndex = segment.RunningIndex + segment.Length; + segment = segment.NextSegment; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static Int64 GetLength(BufferSegment startSegment, Int32 startIndex, BufferSegment endSegment, Int32 endIndex) => endSegment.RunningIndex + (UInt32)endIndex - (startSegment.RunningIndex + (UInt32)startIndex); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static Int64 GetLength(Int64 startPosition, BufferSegment endSegment, Int32 endIndex) => endSegment.RunningIndex + (UInt32)endIndex - startPosition; +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Buffers/PooledByteBufferWriter.cs b/src/Admin/ThingsGateway.NewLife.X/Buffers/PooledByteBufferWriter.cs new file mode 100644 index 000000000..4dbac3980 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Buffers/PooledByteBufferWriter.cs @@ -0,0 +1,126 @@ +using System.Buffers; + +#if NETFRAMEWORK || NETSTANDARD2_0 +using ValueTask = System.Threading.Tasks.Task; +#endif + +namespace ThingsGateway.NewLife.Buffers; + +/// 池化缓冲区写入器 +public sealed class PooledByteBufferWriter : IBufferWriter, IDisposable +{ + #region 属性 + private Byte[] _rentedBuffer; + private Int32 _index; + + /// 已写入内存 + public ReadOnlyMemory WrittenMemory => _rentedBuffer.AsMemory(0, _index); + + /// 容量 + public Int32 Capacity => _rentedBuffer.Length; + #endregion + + #region 构造 + /// 指定初始容量并初始化写入器 + /// + public PooledByteBufferWriter(Int32 initialCapacity) + { + _rentedBuffer = ArrayPool.Shared.Rent(initialCapacity); + _index = 0; + } + + /// 销毁。释放内存并返回池里 + public void Dispose() + { + if (_rentedBuffer != null) + ClearAndReturnBuffers(); + } + #endregion + + #region 方法 + /// 初始化空实例 + /// + public void InitializeEmptyInstance(Int32 initialCapacity) + { + _rentedBuffer = ArrayPool.Shared.Rent(initialCapacity); + _index = 0; + } + + /// 清空写入器 + public void Clear() + { + _rentedBuffer.AsSpan(0, _index).Clear(); + _index = 0; + } + + /// 清空写入器并归还到对象池 + public void ClearAndReturnBuffers() + { + Clear(); + + var rentedBuffer = _rentedBuffer; + _rentedBuffer = null!; + ArrayPool.Shared.Return(rentedBuffer); + } + + /// 通知 IBufferWriter,已向输出写入 count 数据项。 + /// + public void Advance(Int32 count) => _index += count; + + /// 返回要向其中写入数据的 Memory,且大小至少是 sizeHint 指定的请求大小。 + /// + /// + public Memory GetMemory(Int32 sizeHint = 256) + { + CheckAndResizeBuffer(sizeHint); + return _rentedBuffer.AsMemory(_index); + } + + /// 返回要向其中写入数据的 Span,且大小至少是 sizeHint 指定的请求大小。 + /// + /// + public Span GetSpan(Int32 sizeHint = 256) + { + CheckAndResizeBuffer(sizeHint); + return _rentedBuffer.AsSpan(_index); + } + + /// 写入到数据流 + /// + /// + /// + public ValueTask WriteToStreamAsync(Stream destination, CancellationToken cancellationToken) => destination.WriteAsync(WrittenMemory, cancellationToken); + + /// 写入到数据流 + /// + public void WriteToStream(Stream destination) => destination.Write(_rentedBuffer, 0, _index); + + private void CheckAndResizeBuffer(Int32 sizeHint) + { + var num = _rentedBuffer.Length; + var num2 = num - _index; + if (_index >= 1073741795) + { + sizeHint = Math.Max(sizeHint, 2147483591 - num); + } + if (sizeHint <= num2) return; + + var num3 = Math.Max(sizeHint, num); + var num4 = num + num3; + if ((UInt32)num4 > 2147483591u) + { + num4 = num + sizeHint; + if ((UInt32)num4 > 2147483591u) + { + throw new OutOfMemoryException($"BufferMaximumSizeExceeded({num4})"); + } + } + var rentedBuffer = _rentedBuffer; + _rentedBuffer = ArrayPool.Shared.Rent(num4); + var span = rentedBuffer.AsSpan(0, _index); + span.CopyTo(_rentedBuffer); + span.Clear(); + ArrayPool.Shared.Return(rentedBuffer); + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Buffers/SpanHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Buffers/SpanHelper.cs new file mode 100644 index 000000000..0f3daf158 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Buffers/SpanHelper.cs @@ -0,0 +1,340 @@ +using System.Buffers; +using System.Runtime.InteropServices; +using System.Text; + +using ThingsGateway.NewLife.Collections; + +namespace ThingsGateway.NewLife.Extension; + +/// Span帮助类 +public static class SpanHelper +{ + #region 字符串扩展 + /// 转字符串 + /// + /// + /// + public static String ToStr(this ReadOnlySpan span, Encoding? encoding = null) => (encoding ?? Encoding.UTF8).GetString(span); + + /// 转字符串 + /// + /// + /// + public static String ToStr(this Span span, Encoding? encoding = null) => (encoding ?? Encoding.UTF8).GetString(span); + + /// 获取字符串的字节数组 + public static unsafe Int32 GetBytes(this Encoding encoding, ReadOnlySpan chars, Span bytes) + { + fixed (Char* chars2 = &MemoryMarshal.GetReference(chars)) + { + fixed (Byte* bytes2 = &MemoryMarshal.GetReference(bytes)) + { + return encoding.GetBytes(chars2, chars.Length, bytes2, bytes.Length); + } + } + } + + /// 获取字节数组的字符串 + public static unsafe String GetString(this Encoding encoding, ReadOnlySpan bytes) + { + if (bytes.IsEmpty) return String.Empty; + +#if NET45 + return encoding.GetString(bytes.ToArray()); +#else + fixed (Byte* bytes2 = &MemoryMarshal.GetReference(bytes)) + { + return encoding.GetString(bytes2, bytes.Length); + } +#endif + } + + /// 把字节数组编码为十六进制字符串 + /// 字节数组 + /// + public static String ToHex(this ReadOnlySpan data) + { + if (data.Length == 0) return String.Empty; + + Span cs = stackalloc Char[data.Length * 2]; + for (Int32 i = 0, j = 0; i < data.Length; i++, j += 2) + { + var b = data[i]; + cs[j] = GetHexValue(b >> 4); + cs[j + 1] = GetHexValue(b & 0x0F); + } + return cs.ToString(); + } + + /// 把字节数组编码为十六进制字符串 + /// 字节数组 + /// 最大长度 + /// + public static String ToHex(this ReadOnlySpan data, Int32 maxLength) + { + if (data.Length == 0) return String.Empty; + + if (maxLength > 0 && data.Length > maxLength) data = data[..maxLength]; + + return data.ToHex(); + } + + /// 把字节数组编码为十六进制字符串 + /// 字节数组 + /// + public static String ToHex(this Span data) => ToHex((ReadOnlySpan)data); + + private static Char GetHexValue(Int32 i) => i < 10 ? (Char)(i + '0') : (Char)(i - 10 + 'A'); + + /// 以十六进制编码表示 + /// + /// 分隔符 + /// 分组大小,为0时对每个字节应用分隔符,否则对每个分组使用 + /// 最大显示多少个字节。默认-1显示全部 + /// + public static String ToHex(this ReadOnlySpan data, String? separate, Int32 groupSize = 0, Int32 maxLength = -1) + { + if (data.Length == 0 || maxLength == 0) return String.Empty; + + if (maxLength > 0 && data.Length > maxLength) data = data[..maxLength]; + //return data.ToArray().ToHex(separate, groupSize); + + if (groupSize < 0) groupSize = 0; + + var count = data.Length; + if (groupSize == 0) + { + // 没有分隔符 + if (String.IsNullOrEmpty(separate)) return data.ToHex(); + + //// 特殊处理 + //if (separate == "-") return BitConverter.ToString(data, 0, count); + } + + var len = count * 2; + if (!separate.IsNullOrEmpty()) len += (count - 1) * separate.Length; + if (groupSize > 0) + { + // 计算分组个数 + var g = (count - 1) / groupSize; + len += g * 2; + // 扣除间隔 + if (!separate.IsNullOrEmpty()) _ = g * separate.Length; + } + + var sb = Pool.StringBuilder.Get(); + for (var i = 0; i < count; i++) + { + if (sb.Length > 0) + { + if (groupSize <= 0 || i % groupSize == 0) + sb.Append(separate); + } + + var b = data[i]; + sb.Append(GetHexValue(b >> 4)); + sb.Append(GetHexValue(b & 0x0F)); + } + + return sb.Return(true) ?? String.Empty; + } + + /// 以十六进制编码表示 + /// + /// 分隔符 + /// 分组大小,为0时对每个字节应用分隔符,否则对每个分组使用 + /// 最大显示多少个字节。默认-1显示全部 + /// + public static String ToHex(this Span span, String? separate, Int32 groupSize = 0, Int32 maxLength = -1) + { + if (span.Length == 0 || maxLength == 0) return String.Empty; + + return ToHex((ReadOnlySpan)span, separate, groupSize, maxLength); + } + + /// 通过指定开始与结束边界来截取数据源 + /// + /// + /// + /// + /// + public static ReadOnlySpan Substring(this ReadOnlySpan source, ReadOnlySpan start, ReadOnlySpan end) where T : IEquatable + { + var startIndex = source.IndexOf(start); + if (startIndex == -1) return []; + + startIndex += start.Length; + + var endIndex = source[startIndex..].IndexOf(end); + if (endIndex == -1) return []; + + return source.Slice(startIndex, endIndex); + } + + /// 通过指定开始与结束边界来截取数据源 + /// + /// + /// + /// + /// + public static Span Substring(this Span source, ReadOnlySpan start, ReadOnlySpan end) where T : IEquatable + { + var startIndex = source.IndexOf(start); + if (startIndex == -1) return []; + + startIndex += start.Length; + + var endIndex = source[startIndex..].IndexOf(end); + if (endIndex == -1) return []; + + return source.Slice(startIndex, endIndex); + } + + /// 在数据源中查找开始与结束边界 + /// + /// + /// + /// + /// + public static (Int32 offset, Int32 count) IndexOf(this ReadOnlySpan source, ReadOnlySpan start, ReadOnlySpan end) where T : IEquatable + { + var startIndex = source.IndexOf(start); + if (startIndex == -1) return (-1, -1); + + startIndex += start.Length; + + var endIndex = source[startIndex..].IndexOf(end); + if (endIndex == -1) return (startIndex, -1); + + return (startIndex, endIndex); + } + + /// 在数据源中查找开始与结束边界 + /// + /// + /// + /// + /// + public static (Int32 offset, Int32 count) IndexOf(this Span source, ReadOnlySpan start, ReadOnlySpan end) where T : IEquatable + { + var startIndex = source.IndexOf(start); + if (startIndex == -1) return (-1, -1); + + startIndex += start.Length; + + var endIndex = source[startIndex..].IndexOf(end); + if (endIndex == -1) return (startIndex, -1); + + return (startIndex, endIndex); + } + #endregion + + /// 写入Memory到数据流。从内存池借出缓冲区拷贝,仅作为兜底使用 + /// + /// + /// + public static void Write(this Stream stream, ReadOnlyMemory buffer) + { + if (MemoryMarshal.TryGetArray(buffer, out var segment)) + { + stream.Write(segment.Array!, segment.Offset, segment.Count); + + return; + } + + var array = ArrayPool.Shared.Rent(buffer.Length); + + try + { + buffer.Span.CopyTo(array); + + stream.Write(array, 0, buffer.Length); + } + finally + { + ArrayPool.Shared.Return(array); + } + } + + /// 写入Memory到数据流。从内存池借出缓冲区拷贝,仅作为兜底使用 + /// + /// + /// + /// + public static Task WriteAsync(this Stream stream, ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (MemoryMarshal.TryGetArray(buffer, out var segment)) + return stream.WriteAsync(segment.Array!, segment.Offset, segment.Count, cancellationToken); + + var array = ArrayPool.Shared.Rent(buffer.Length); + buffer.Span.CopyTo(array); + + var writeTask = stream.WriteAsync(array, 0, buffer.Length, cancellationToken); + return Task.Run(async () => + { + try + { + await writeTask.ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(array); + } + }, cancellationToken); + } + +#if NETFRAMEWORK || NETSTANDARD + /// 去掉前后字符 + /// + /// + /// + /// + public static ReadOnlySpan Trim(this ReadOnlySpan span, T trimElement) where T : IEquatable + { + var start = ClampStart(span, trimElement); + var length = ClampEnd(span, start, trimElement); + return span.Slice(start, length); + } + + /// 去掉前后字符 + /// + /// + /// + /// + public static Span Trim(this Span span, T trimElement) where T : IEquatable + { + var start = ClampStart(span, trimElement); + var length = ClampEnd(span, start, trimElement); + return span.Slice(start, length); + } + + private static Int32 ClampStart(ReadOnlySpan span, T trimElement) where T : IEquatable + { + var i = 0; + for (; i < span.Length; i++) + { + ref var reference = ref trimElement; + if (!reference.Equals(span[i])) + { + break; + } + } + return i; + } + + private static Int32 ClampEnd(ReadOnlySpan span, Int32 start, T trimElement) where T : IEquatable + { + var num = span.Length - 1; + while (num >= start) + { + ref var reference = ref trimElement; + if (!reference.Equals(span[num])) + { + break; + } + num--; + } + return num - start + 1; + } +#endif +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Buffers/SpanReader.cs b/src/Admin/ThingsGateway.NewLife.X/Buffers/SpanReader.cs new file mode 100644 index 000000000..1e56db88a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Buffers/SpanReader.cs @@ -0,0 +1,260 @@ +using System.Buffers.Binary; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; + +namespace ThingsGateway.NewLife.Buffers; + +/// Span读取器 +public ref struct SpanReader +{ + #region 属性 + private readonly ReadOnlySpan _span; + /// 数据片段 + public ReadOnlySpan Span => _span; + + private Int32 _index; + /// 已读取字节数 + public Int32 Position { get => _index; set => _index = value; } + + /// 总容量 + public Int32 Capacity => _span.Length; + + /// 空闲容量 + public Int32 FreeCapacity => _span.Length - _index; + + /// 是否小端字节序。默认true + public Boolean IsLittleEndian { get; set; } = true; + #endregion + + #region 构造 + /// 实例化。暂时兼容旧版,后面使用主构造函数 + /// + public SpanReader(ReadOnlySpan span) => _span = span; + + /// 实例化。暂时兼容旧版,后面删除 + /// + public SpanReader(Span span) => _span = span; + + #endregion + + #region 基础方法 + /// 告知有多少数据已从缓冲区读取 + /// + public void Advance(Int32 count) + { + if (count < 0) throw new ArgumentOutOfRangeException(nameof(count)); + if (_index + count > _span.Length) throw new ArgumentOutOfRangeException(nameof(count)); + + _index += count; + } + + /// 返回要写入到的Span,其大小按 sizeHint 参数指定至少为所请求的大小 + /// + /// + /// + public ReadOnlySpan GetSpan(Int32 sizeHint = 0) + { + if (sizeHint > FreeCapacity) throw new ArgumentOutOfRangeException(nameof(sizeHint)); + + return _span[_index..]; + } + #endregion + + #region 读取方法 + /// 确保缓冲区中有足够的空间。 + /// 需要的字节数。 + /// + private void EnsureSpace(Int32 size) + { + if (_index + size > _span.Length) + throw new InvalidOperationException("Not enough data to read."); + } + + /// 读取单个字节 + /// + public Byte ReadByte() + { + var size = sizeof(Byte); + EnsureSpace(size); + var result = _span[_index]; + _index += size; + return result; + } + + /// 读取Int16整数 + /// + public Int16 ReadInt16() + { + var size = sizeof(Int16); + EnsureSpace(size); + var result = IsLittleEndian ? + BinaryPrimitives.ReadInt16LittleEndian(_span.Slice(_index, size)) : + BinaryPrimitives.ReadInt16BigEndian(_span.Slice(_index, size)); + _index += size; + return result; + } + + /// 读取UInt16整数 + /// + public UInt16 ReadUInt16() + { + var size = sizeof(UInt16); + EnsureSpace(size); + var result = IsLittleEndian ? + BinaryPrimitives.ReadUInt16LittleEndian(_span.Slice(_index, size)) : + BinaryPrimitives.ReadUInt16BigEndian(_span.Slice(_index, size)); + _index += size; + return result; + } + + /// 读取Int32整数 + /// + public Int32 ReadInt32() + { + var size = sizeof(Int32); + EnsureSpace(size); + var result = IsLittleEndian ? + BinaryPrimitives.ReadInt32LittleEndian(_span.Slice(_index, size)) : + BinaryPrimitives.ReadInt32BigEndian(_span.Slice(_index, size)); + _index += size; + return result; + } + + /// 读取UInt32整数 + /// + public UInt32 ReadUInt32() + { + var size = sizeof(UInt32); + EnsureSpace(size); + var result = IsLittleEndian ? + BinaryPrimitives.ReadUInt32LittleEndian(_span.Slice(_index, size)) : + BinaryPrimitives.ReadUInt32BigEndian(_span.Slice(_index, size)); + _index += size; + return result; + } + + /// 读取Int64整数 + /// + public Int64 ReadInt64() + { + var size = sizeof(Int64); + EnsureSpace(size); + var result = IsLittleEndian ? + BinaryPrimitives.ReadInt64LittleEndian(_span.Slice(_index, size)) : + BinaryPrimitives.ReadInt64BigEndian(_span.Slice(_index, size)); + _index += size; + return result; + } + + /// 读取UInt64整数 + /// + public UInt64 ReadUInt64() + { + var size = sizeof(UInt64); + EnsureSpace(size); + var result = IsLittleEndian ? + BinaryPrimitives.ReadUInt64LittleEndian(_span.Slice(_index, size)) : + BinaryPrimitives.ReadUInt64BigEndian(_span.Slice(_index, size)); + _index += size; + return result; + } + + /// 读取单精度浮点数 + /// + public unsafe Single ReadSingle() + { +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP + return BitConverter.Int32BitsToSingle(ReadInt32()); +#else + var result = ReadInt32(); + return Unsafe.ReadUnaligned(ref Unsafe.As(ref result)); +#endif + } + + /// 读取双精度浮点数 + /// + public Double ReadDouble() + { +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP + return BitConverter.Int64BitsToDouble(ReadInt64()); +#else + var result = ReadInt64(); + return Unsafe.ReadUnaligned(ref Unsafe.As(ref result)); +#endif + } + + /// 读取字符串。支持定长、全部和长度前缀 + /// 需要读取的长度。-1表示读取全部,默认0表示读取7位压缩编码整数长度 + /// 字符串编码,默认UTF8 + /// + /// + public String ReadString(Int32 length = 0, Encoding? encoding = null) + { + if (length < 0) + length = _span.Length - _index; + else if (length == 0) + length = ReadEncodedInt(); + if (length == 0) return String.Empty; + + EnsureSpace(length); + + encoding ??= Encoding.UTF8; + + var result = encoding.GetString(_span.Slice(_index, length)); + _index += length; + return result; + } + + /// 读取字节数组 + /// + /// + /// + public ReadOnlySpan ReadBytes(Int32 length) + { + EnsureSpace(length); + + var result = _span.Slice(_index, length); + _index += length; + return result; + } + + /// 读取结构体 + /// + /// + public T Read() where T : struct + { + var size = Unsafe.SizeOf(); + EnsureSpace(size); + + var result = MemoryMarshal.Read(_span.Slice(_index)); + _index += size; + return result; + } + #endregion + + #region 扩展读取 + /// 以压缩格式读取32位整数 + /// + public Int32 ReadEncodedInt() + { + Byte b; + UInt32 rs = 0; + Byte n = 0; + while (true) + { + var bt = ReadByte(); + if (bt < 0) throw new Exception($"The data stream is out of range! The integer read is {rs: n0}"); + b = (Byte)bt; + + // 必须转为Int32,否则可能溢出 + rs |= (UInt32)((b & 0x7f) << n); + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 32) throw new FormatException("The number value is too large to read in compressed format!"); + } + return (Int32)rs; + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Caching/Cache.cs b/src/Admin/ThingsGateway.NewLife.X/Caching/Cache.cs new file mode 100644 index 000000000..3408d2c24 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Caching/Cache.cs @@ -0,0 +1,422 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ThingsGateway.NewLife.Caching; + +/// 缓存 +public abstract class Cache : DisposeBase, ICache +{ + #region 静态默认实现 + /// 默认缓存 + public static ICache Default { get; set; } = new MemoryCache(); + #endregion + + #region 属性 + /// 名称 + public String Name { get; set; } + + /// 默认过期时间。避免Set操作时没有设置过期时间,默认3600秒 + public Int32 Expire { get; set; } = 3600; + + /// 获取和设置缓存,使用默认过期时间 + /// + /// + public virtual Object? this[String key] { get => Get(key); set => Set(key, value); } + + /// 缓存个数 + public abstract Int32 Count { get; } + + /// 所有键 + public abstract ICollection Keys { get; } + #endregion + + #region 构造 + /// 构造函数 + protected Cache() => Name = GetType().Name.TrimEnd("Cache"); + + /// 销毁。释放资源 + /// + protected override void Dispose(Boolean disposing) + { + base.Dispose(disposing); + } + #endregion + + #region 基础操作 + /// 使用连接字符串初始化配置 + /// + public virtual void Init(String config) { } + + /// 是否包含缓存项 + /// + /// + public abstract Boolean ContainsKey(String key); + + /// 设置缓存项 + /// 键 + /// 值 + /// 过期时间,秒 + /// + public abstract Boolean Set(String key, T value, Int32 expire = -1); + + /// 设置缓存项 + /// 键 + /// 值 + /// 过期时间 + /// + public virtual Boolean Set(String key, T value, TimeSpan expire) => Set(key, value, (Int32)expire.TotalSeconds); + + /// 获取缓存项 + /// 键 + /// + [return: MaybeNull] + public abstract T Get(String key); + + /// 移除缓存项 + /// 键 + /// + public abstract Int32 Remove(String key); + + /// 批量移除缓存项 + /// 键集合 + /// + public abstract Int32 Remove(params String[] keys); + + /// 清空所有缓存项 + public virtual void Clear() => throw new NotSupportedException(); + + /// 设置缓存项有效期 + /// 键 + /// 过期时间,秒 + public abstract Boolean SetExpire(String key, TimeSpan expire); + + /// 获取缓存项有效期 + /// 键 + /// + public abstract TimeSpan GetExpire(String key); + #endregion + + #region 集合操作 + /// 批量获取缓存项 + /// + /// + /// + public virtual IDictionary GetAll(IEnumerable keys) + { + var dic = new Dictionary(); + foreach (var key in keys) + { + dic[key] = Get(key); + } + + return dic; + } + + /// 批量设置缓存项 + /// + /// + /// 过期时间,秒 + public virtual void SetAll(IDictionary values, Int32 expire = -1) + { + foreach (var item in values) + { + Set(item.Key, item.Value, expire); + } + } + + /// 获取列表 + /// 元素类型 + /// 键 + /// + public virtual IList GetList(String key) => throw new NotSupportedException(); + + /// 获取哈希 + /// 元素类型 + /// 键 + /// + public virtual IDictionary GetDictionary(String key) => throw new NotSupportedException(); + + /// 获取队列 + /// 元素类型 + /// 键 + /// + public virtual IProducerConsumer GetQueue(String key) => throw new NotSupportedException(); + + /// 获取栈 + /// 元素类型 + /// 键 + /// + public virtual IProducerConsumer GetStack(String key) => throw new NotSupportedException(); + + /// 获取Set + /// + /// + /// + public virtual ICollection GetSet(String key) => throw new NotSupportedException(); + #endregion + + #region 高级操作 + /// 添加,已存在时不更新 + /// 值类型 + /// 键 + /// 值 + /// 过期时间,秒 + /// + public virtual Boolean Add(String key, T value, Int32 expire = -1) + { + if (ContainsKey(key)) return false; + + return Set(key, value, expire); + } + + /// 设置新值并获取旧值,原子操作 + /// 值类型 + /// 键 + /// 值 + /// + [return: MaybeNull] + public virtual T Replace(String key, T value) + { + var rs = Get(key); + Set(key, value); + return rs; + } + + /// 尝试获取指定键,返回是否包含值。有可能缓存项刚好是默认值,或者只是反序列化失败 + /// 值类型 + /// 键 + /// 值。即使有值也不一定能够返回,可能缓存项刚好是默认值,或者只是反序列化失败 + /// 返回是否包含值,即使反序列化失败 + public virtual Boolean TryGetValue(String key, [MaybeNullWhen(false)] out T value) + { + value = Get(key); + if (!Equals(value, default)) return true; + + return ContainsKey(key); + } + + /// 获取 或 添加 缓存数据,在数据不存在时执行委托请求数据 + /// + /// + /// + /// 过期时间,秒。小于0时采用默认缓存时间 + /// + [return: MaybeNull] + public virtual T GetOrAdd(String key, Func callback, Int32 expire = -1) + { + var value = Get(key); + if (!Equals(value, default)) return value; + + if (ContainsKey(key)) return value; + + value = callback(key); + + if (expire < 0) expire = Expire; + if (Add(key, value, expire)) return value; + + return Get(key); + } + + /// 累加,原子操作 + /// 键 + /// 变化量 + /// + public virtual Int64 Increment(String key, Int64 value) + { + lock (this) + { + var v = Get(key); + v += value; + Set(key, v); + + return v; + } + } + + /// 累加,原子操作 + /// 键 + /// 变化量 + /// + public virtual Double Increment(String key, Double value) + { + lock (this) + { + var v = Get(key); + v += value; + Set(key, v); + + return v; + } + } + + /// 递减,原子操作 + /// 键 + /// 变化量 + /// + public virtual Int64 Decrement(String key, Int64 value) + { + lock (this) + { + var v = Get(key); + v -= value; + Set(key, v); + + return v; + } + } + + /// 递减,原子操作 + /// 键 + /// 变化量 + /// + public virtual Double Decrement(String key, Double value) + { + lock (this) + { + var v = Get(key); + v -= value; + Set(key, v); + + return v; + } + } + #endregion + + #region 事务 + /// 提交变更。部分提供者需要刷盘 + /// + public virtual Int32 Commit() => 0; + + /// 申请分布式锁 + /// 要锁定的key + /// 锁等待时间,单位毫秒 + /// + public virtual IDisposable? AcquireLock(String key, Int32 msTimeout) + { + var rlock = new CacheLock(this, key); + if (!rlock.Acquire(msTimeout, msTimeout)) throw new InvalidOperationException($"Lock [{key}] failed! msTimeout={msTimeout}"); + + return rlock; + } + + /// 申请分布式锁 + /// 要锁定的key + /// 锁等待时间,申请加锁时如果遇到冲突则等待的最大时间,单位毫秒 + /// 锁过期时间,超过该时间如果没有主动释放则自动释放锁,必须整数秒,单位毫秒 + /// 失败时是否抛出异常,如果不抛出异常,可通过返回null得知申请锁失败 + /// + public virtual IDisposable? AcquireLock(String key, Int32 msTimeout, Int32 msExpire, Boolean throwOnFailure) + { + var rlock = new CacheLock(this, key); + if (!rlock.Acquire(msTimeout, msExpire)) + { + if (throwOnFailure) throw new InvalidOperationException($"Lock [{key}] failed! msTimeout={msTimeout}"); + rlock.Dispose(); + return null; + } + + return rlock; + } + #endregion + + #region 辅助 + /// 已重载。 + /// + public override String ToString() => Name; + #endregion +#if NET6_0_OR_GREATER + #region 集合 + /// + public virtual void HashAdd(string key, string hashKey, T value) + { + lock (this) + { + //获取字典 + var exist = GetDictionary(key); + if (exist.ContainsKey(hashKey))//如果包含Key + exist[hashKey] = value;//重新赋值 + else exist.TryAdd(hashKey, value);//加上新的值 + Set(key, exist); + } + } + + /// + public virtual bool HashSet(string key, Dictionary dic) + { + lock (this) + { + //获取字典 + var exist = GetDictionary(key); + foreach (var it in dic) + { + if (exist.ContainsKey(it.Key))//如果包含Key + exist[it.Key] = it.Value;//重新赋值 + else exist.Add(it.Key, it.Value);//加上新的值 + } + return true; + } + } + + /// + public virtual int HashDel(string key, params string[] fields) + { + var result = 0; + //获取字典 + var exist = GetDictionary(key); + foreach (var field in fields) + { + if (field != null && exist.ContainsKey(field))//如果包含Key + { + exist.Remove(field);//删除 + result++; + } + } + return result; + } + + /// + public virtual List HashGet(string key, params string[] fields) + { + var list = new List(); + //获取字典 + var exist = GetDictionary(key); + foreach (var field in fields) + { + if (exist.TryGetValue(field, out var data))//如果包含Key + { + list.Add(data); + } + else { list.Add(default); } + } + return list; + } + + /// + public virtual T HashGetOne(string key, string field) + { + //获取字典 + var exist = GetDictionary(key); + exist.TryGetValue(field, out var result); + return result; + } + + /// + public virtual IDictionary HashGetAll(string key) + { + var data = GetDictionary(key); + return data; + } + /// + public void DelByPattern(string pattern) + { + var keys = Keys;//获取所有key + foreach (var item in keys.ToList()) + { + if (item.StartsWith(pattern))//如果匹配 + Remove(item); + } + } + #endregion + +#endif + +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Caching/CacheLock.cs b/src/Admin/ThingsGateway.NewLife.X/Caching/CacheLock.cs new file mode 100644 index 000000000..76bc6811c --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Caching/CacheLock.cs @@ -0,0 +1,86 @@ +namespace ThingsGateway.NewLife.Caching; + +/// 分布式锁 +public class CacheLock : DisposeBase +{ + private ICache Client { get; set; } + + /// + /// 是否持有锁 + /// + private Boolean _hasLock; + + /// + public String Key { get; set; } + + /// 实例化 + /// + /// + public CacheLock(ICache client, String key) + { + if (client == null) throw new ArgumentNullException(nameof(client)); + if (key.IsNullOrEmpty()) throw new ArgumentNullException(nameof(key)); + + Client = client; + Key = key; + } + + /// 申请锁 + /// 锁等待时间,申请加锁时如果遇到冲突则等待的最大时间,单位毫秒 + /// 锁过期时间,超过该时间如果没有主动释放则自动释放锁,必须整数秒,单位毫秒 + /// + public Boolean Acquire(Int32 msTimeout, Int32 msExpire) + { + var ch = Client; + var now = Runtime.TickCount64; + + // 循环等待 + var end = now + msTimeout; + while (now < end) + { + // 申请加锁。没有冲突时可以直接返回 + var rs = ch.Add(Key, now + msExpire, msExpire / 1000); + if (rs) return _hasLock = true; + + // 死锁超期检测 + var dt = ch.Get(Key); + if (dt <= now) + { + // 开抢死锁。所有竞争者都会修改该锁的时间戳,但是只有一个能拿到旧的超时的值 + var old = ch.Replace(Key, now + msExpire); + // 如果拿到超时值,说明抢到了锁。其它线程会抢到一个为超时的值 + if (old <= dt) + { + ch.SetExpire(Key, TimeSpan.FromMilliseconds(msExpire)); + return _hasLock = true; + } + } + + // 没抢到,继续 + Thread.Sleep(200); + + now = Runtime.TickCount64; + } + + return false; + } + + /// 销毁 + /// + protected override void Dispose(Boolean disposing) + { + base.Dispose(disposing); + + // 如果客户端已释放,则不删除 + if (Client is DisposeBase db && db.Disposed) + { + } + else + { + if (_hasLock) + { + Client.Remove(Key); + } + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Caching/CacheProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Caching/CacheProvider.cs new file mode 100644 index 000000000..1ac7a8354 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Caching/CacheProvider.cs @@ -0,0 +1,44 @@ +namespace ThingsGateway.NewLife.Caching; + +/// 分布式缓存架构服务。提供基础缓存及队列服务 +public class CacheProvider : ICacheProvider +{ + #region 属性 + /// 全局缓存。各功能模块跨进程共享数据,分布式部署时可用Redis,需要考虑序列化成本。默认单机使用内存缓存 + public ICache Cache { get; set; } + + /// 应用内本地缓存。默认内存缓存,无需考虑对象序列化成本,缺点是不支持跨进程共享数据 + public ICache InnerCache { get; set; } + #endregion + + #region 构造 + /// 使用默认缓存实例化 + public CacheProvider() + { + var cache = Caching.Cache.Default ?? new MemoryCache(); + Cache = cache; + InnerCache = cache; + } + #endregion + + #region 方法 + /// 获取队列。各功能模块跨进程共用的队列 + /// 消息类型 + /// 主题 + /// 消费组 + /// + public virtual IProducerConsumer GetQueue(String topic, String? group = null) => Cache.GetQueue(topic); + + /// 获取内部队列。默认内存队列 + /// 消息类型 + /// 主题 + /// + public virtual IProducerConsumer GetInnerQueue(String topic) => InnerCache.GetQueue(topic); + + /// 申请分布式锁 + /// 要锁定的键值。建议加上应用模块等前缀以避免冲突 + /// 遇到冲突时等待的最大时间 + /// + public virtual IDisposable? AcquireLock(String lockKey, Int32 msTimeout) => Cache.AcquireLock(lockKey, msTimeout); + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Caching/ICache.cs b/src/Admin/ThingsGateway.NewLife.X/Caching/ICache.cs new file mode 100644 index 000000000..26e31958c --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Caching/ICache.cs @@ -0,0 +1,234 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ThingsGateway.NewLife.Caching; + +/// 缓存接口 +/// +/// 文档 https://newlifex.com/core/icache +/// +public interface ICache +{ + #region 属性 + /// 名称 + String Name { get; } + + /// 默认缓存时间。默认0秒表示不过期 + Int32 Expire { get; set; } + + /// 获取和设置缓存,永不过期 + /// + /// + Object? this[String key] { get; set; } + + /// 缓存个数 + Int32 Count { get; } + + /// 所有键 + ICollection Keys { get; } + #endregion + + #region 基础操作 + /// 是否包含缓存项 + /// + /// + Boolean ContainsKey(String key); + + /// 设置缓存项 + /// 键 + /// 值 + /// 过期时间,秒。小于0时采用默认缓存时间 + /// + Boolean Set(String key, T value, Int32 expire = -1); + + /// 设置缓存项 + /// 键 + /// 值 + /// 过期时间 + /// + Boolean Set(String key, T value, TimeSpan expire); + + /// 获取缓存项 + /// 键 + /// + [return: MaybeNull] + T Get(String key); + + /// 移除缓存项 + /// 键 + /// + Int32 Remove(String key); + + /// 批量移除缓存项 + /// 键集合 + /// + Int32 Remove(params String[] keys); + + /// 清空所有缓存项 + void Clear(); + + /// 设置缓存项有效期 + /// 键 + /// 过期时间 + Boolean SetExpire(String key, TimeSpan expire); + + /// 获取缓存项有效期 + /// 键 + /// + TimeSpan GetExpire(String key); + #endregion + + #region 集合操作 + /// 批量获取缓存项 + /// + /// + /// + IDictionary GetAll(IEnumerable keys); + + /// 批量设置缓存项 + /// + /// + /// 过期时间,秒。小于0时采用默认缓存时间 + void SetAll(IDictionary values, Int32 expire = -1); + + /// 获取列表 + /// 元素类型 + /// 键 + /// + IList GetList(String key); + + /// 获取哈希 + /// 元素类型 + /// 键 + /// + IDictionary GetDictionary(String key); + + /// 获取队列 + /// 元素类型 + /// 键 + /// + IProducerConsumer GetQueue(String key); + + /// 获取栈 + /// 元素类型 + /// 键 + /// + IProducerConsumer GetStack(String key); + + /// 获取Set + /// + /// + /// + ICollection GetSet(String key); + #endregion + + #region 高级操作 + /// 添加,已存在时不更新 + /// 值类型 + /// 键 + /// 值 + /// 过期时间,秒。小于0时采用默认缓存时间 + /// + Boolean Add(String key, T value, Int32 expire = -1); + + /// 设置新值并获取旧值,原子操作 + /// + /// 常常配合Increment使用,用于累加到一定数后重置归零,又避免多线程冲突。 + /// + /// 值类型 + /// 键 + /// 值 + /// + [return: MaybeNull] + T Replace(String key, T value); + + /// 尝试获取指定键,返回是否包含值。有可能缓存项刚好是默认值,或者只是反序列化失败,解决缓存穿透问题 + /// 值类型 + /// 键 + /// 值。即使有值也不一定能够返回,可能缓存项刚好是默认值,或者只是反序列化失败 + /// 返回是否包含值,即使反序列化失败 + Boolean TryGetValue(String key, [MaybeNullWhen(false)] out T value); + + /// 获取 或 添加 缓存数据,在数据不存在时执行委托请求数据 + /// + /// + /// + /// 过期时间,秒。小于0时采用默认缓存时间 + /// + [return: MaybeNull] + T GetOrAdd(String key, Func callback, Int32 expire = -1); + + /// 累加,原子操作 + /// 键 + /// 变化量 + /// + Int64 Increment(String key, Int64 value); + + /// 累加,原子操作 + /// 键 + /// 变化量 + /// + Double Increment(String key, Double value); + + /// 递减,原子操作 + /// 键 + /// 变化量 + /// + Int64 Decrement(String key, Int64 value); + + /// 递减,原子操作 + /// 键 + /// 变化量 + /// + Double Decrement(String key, Double value); + #endregion + + #region 事务 + /// 提交变更。部分提供者需要刷盘 + /// + Int32 Commit(); + + /// 申请分布式锁 + /// 要锁定的key + /// 锁等待时间,单位毫秒 + /// + IDisposable? AcquireLock(String key, Int32 msTimeout); + + /// 申请分布式锁 + /// 要锁定的key + /// 锁等待时间,申请加锁时如果遇到冲突则等待的最大时间,单位毫秒 + /// 锁过期时间,超过该时间如果没有主动释放则自动释放锁,必须整数秒,单位毫秒 + /// 失败时是否抛出异常,如果不抛出异常,可通过返回null得知申请锁失败 + /// + IDisposable? AcquireLock(String key, Int32 msTimeout, Int32 msExpire, Boolean throwOnFailure); + #endregion + +#if NET6_0_OR_GREATER + #region 集合 + /// + public void HashAdd(string key, string hashKey, T value); + + /// + public bool HashSet(string key, Dictionary dic); + + /// + public int HashDel(string key, params string[] fields); + + /// + public List HashGet(string key, params string[] fields); + + /// + public T HashGetOne(string key, string field); + + /// + public IDictionary HashGetAll(string key); + + /// + /// 按前缀删除 + /// + /// + void DelByPattern(string v); + + #endregion + +#endif +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Caching/ICacheProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Caching/ICacheProvider.cs new file mode 100644 index 000000000..8dd14841e --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Caching/ICacheProvider.cs @@ -0,0 +1,52 @@ +namespace ThingsGateway.NewLife.Caching; + +/// 分布式缓存架构服务。提供基础缓存及队列服务 +/// +/// 文档 https://newlifex.com/core/icacheprovider +/// 根据实际开发经验,即使在分布式系统中,也有大量的数据是不需要跨进程共享的,因此本接口提供了两级缓存。 +/// 进程内缓存使用,可以规避对象序列化成本,跨进程缓存使用。 +/// 借助该缓存架构,可以实现各功能模块跨进程共享数据,分布式部署时可用Redis,需要考虑序列化成本。 +/// +/// 使用队列时,可根据是否设置消费组来决定使用简单队列还是完整队列。 +/// 简单队列(如RedisQueue)可用作命令队列,Topic很多,但几乎没有消息。 +/// 完整队列(如RedisStream)可用作消息队列,Topic很少,但消息很多,并且支持多消费组。 +/// +public interface ICacheProvider +{ + /// 全局缓存。各功能模块跨进程共享数据,分布式部署时可用Redis,需要考虑序列化成本。默认单机使用内存缓存 + ICache Cache { get; set; } + + /// 应用内本地缓存。默认内存缓存,无需考虑对象序列化成本,缺点是不支持跨进程共享数据 + ICache InnerCache { get; set; } + + /// 获取队列。各功能模块跨进程共用的队列 + /// + /// 使用队列时,可根据是否设置消费组来决定使用简单队列还是完整队列。 + /// 简单队列(如RedisQueue)可用作命令队列,Topic很多,但几乎没有消息。 + /// 完整队列(如RedisStream)可用作消息队列,Topic很少,但消息很多,并且支持多消费组。 + /// + /// 消息类型。用于消息生产者时,可指定为Object + /// 主题 + /// 消费组。未指定消费组时使用简单队列(如RedisQueue),指定消费组时使用完整队列(如RedisStream) + /// + IProducerConsumer GetQueue(String topic, String? group = null); + + /// 获取内部队列。默认内存队列 + /// 消息类型 + /// 主题 + /// + IProducerConsumer GetInnerQueue(String topic); + + /// 申请分布式锁 + /// + /// 一般实现为Redis分布式锁,申请锁的具体表现为锁定某个key,锁维持时间为msTimeout,遇到冲突时等待msTimeout时间。 + /// 如果在等待时间内获得锁,则返回一个IDisposable对象,离开using代码块时自动释放锁。 + /// 如果在等待时间内没有获得锁,则抛出异常,需要自己处理锁冲突的情况。 + /// + /// 如果希望指定不同的维持时间和等待时间,可以使用接口的方法。 + /// + /// 要锁定的键值。建议加上应用模块等前缀以避免冲突 + /// 遇到冲突时等待的最大时间,同时也是锁维持的时间 + /// + IDisposable? AcquireLock(String lockKey, Int32 msTimeout); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Caching/IProducerConsumer.cs b/src/Admin/ThingsGateway.NewLife.X/Caching/IProducerConsumer.cs new file mode 100644 index 000000000..75d6bc4b7 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Caching/IProducerConsumer.cs @@ -0,0 +1,46 @@ +namespace ThingsGateway.NewLife.Caching; + +/// 轻量级生产者消费者接口 +/// +/// 不一定支持Ack机制;也不支持消息体与消息键分离 +/// +/// +public interface IProducerConsumer +{ + /// 元素个数 + Int32 Count { get; } + + /// 集合是否为空 + Boolean IsEmpty { get; } + + /// 生产添加 + /// + /// + Int32 Add(params T[] values); + + /// 消费获取一批 + /// + /// + IEnumerable Take(Int32 count = 1); + + /// 消费获取一个 + /// 超时。默认0秒,永久等待 + /// + T? TakeOne(Int32 timeout = 0); + + /// 异步消费获取一个 + /// 超时。单位秒,0秒表示永久等待 + /// + Task TakeOneAsync(Int32 timeout = 0); + + /// 异步消费获取一个 + /// 超时。单位秒,0秒表示永久等待 + /// 取消通知 + /// + Task TakeOneAsync(Int32 timeout, CancellationToken cancellationToken); + + /// 确认消费 + /// + /// + Int32 Acknowledge(params String[] keys); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Caching/MemoryCache.cs b/src/Admin/ThingsGateway.NewLife.X/Caching/MemoryCache.cs new file mode 100644 index 000000000..81076244d --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Caching/MemoryCache.cs @@ -0,0 +1,884 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; + +using ThingsGateway.NewLife.Log; +using ThingsGateway.NewLife.Reflection; +using ThingsGateway.NewLife.Threading; + +namespace ThingsGateway.NewLife.Caching; + +/// 缓存键事件参数 +public class KeyEventArgs : CancelEventArgs +{ + /// 缓存键 + public String Key { get; set; } = null!; +} + +/// 内存缓存。并行字典实现,峰值性能10亿ops +public class MemoryCache : Cache +{ + #region 属性 + /// 缓存核心 + protected ConcurrentDictionary _cache = new(); + + /// 容量。容量超标时,采用LRU机制删除,默认100_000 + public Int32 Capacity { get; set; } = 100_000; + + /// 定时清理时间,默认60秒 + public Int32 Period { get; set; } = 60; + + /// 缓存键过期 + public event EventHandler? KeyExpired; + #endregion + + #region 静态默认实现 + /// 默认缓存 + public static ICache Instance { get; set; } = new MemoryCache(); + #endregion + + #region 构造 + /// 实例化一个内存字典缓存 + public MemoryCache() + { + Name = GetType().Name.TrimEnd("Cache"); + + Init(null); + } + + /// 销毁 + /// + protected override void Dispose(Boolean disposing) + { + base.Dispose(disposing); + + _clearTimer.TryDispose(); + _clearTimer = null; + } + #endregion + + #region 缓存属性 + private Int32 _count; + /// 缓存项。原子计数 + public override Int32 Count => _count; + + /// 所有键。实际返回只读列表新实例,数据量较大时注意性能 + public override ICollection Keys => _cache.Keys; + #endregion + + #region 方法 + + +#if !NET452 + + /// 返回全部 + public IReadOnlyDictionary GetAll() => _cache; +#endif + /// 初始化配置 + /// + public override void Init(String? config) + { + if (_clearTimer == null) + { + var period = Period; + _clearTimer = new TimerX(RemoveNotAlive, null, 10 * 1000, period * 1000) { Async = true }; + } + } + + /// 获取或添加缓存项 + /// 值类型 + /// 键 + /// 值 + /// 过期时间,秒 + /// + public virtual T? GetOrAdd(String key, T value, Int32 expire = -1) + { + if (expire < 0) expire = Expire; + + CacheItem? item = null; + do + { + if (_cache.TryGetValue(key, out item) && item != null) + { + if (!item.Expired) return item.Visit(); + + item.Set(value, expire); + + return value; + } + + item ??= new CacheItem(value, expire); + } while (!_cache.TryAdd(key, item)); + + Interlocked.Increment(ref _count); + + return item.Visit(); + } + #endregion + + #region 基本操作 + /// 是否包含缓存项 + /// + /// + public override Boolean ContainsKey(String key) => _cache.TryGetValue(key, out var item) && item != null && !item.Expired; + + /// 添加缓存项,已存在时更新 + /// 值类型 + /// 键 + /// 值 + /// 过期时间,秒 + /// + public override Boolean Set(String key, T value, Int32 expire = -1) + { + if (expire < 0) expire = Expire; + + //_cache.AddOrUpdate(key, + // k => new CacheItem(value, expire), + // (k, item) => + // { + // item.Value = value; + // item.ExpiredTime = DateTime.Now.AddSeconds(expire); + + // return item; + // }); + + // 不用AddOrUpdate,避免匿名委托带来的GC损耗 + CacheItem? item = null; + do + { + if (_cache.TryGetValue(key, out item) && item != null) + { + item.Set(value, expire); + return true; + } + + item ??= new CacheItem(value, expire); + } while (!_cache.TryAdd(key, item)); + + Interlocked.Increment(ref _count); + + return true; + } + + /// 获取缓存项,不存在时返回默认值 + /// 键 + /// + [return: MaybeNull] + public override T Get(String key) + { + if (!_cache.TryGetValue(key, out var item) || item == null || item.Expired) return default; + + return item.Visit(); + } + + /// 移除缓存项 + /// 键 + /// 实际移除个数 + public override Int32 Remove(String key) + { + var count = 0; + + if (_cache.TryRemove(key, out _)) + { + count++; + + Interlocked.Decrement(ref _count); + } + + return count; + } + + /// 批量移除缓存项 + /// 键集合 + /// 实际移除个数 + public override Int32 Remove(params String[] keys) + { + var count = 0; + foreach (var k in keys) + { + if (_cache.TryRemove(k, out _)) + { + count++; + + Interlocked.Decrement(ref _count); + } + } + return count; + } + + /// 清空所有缓存项 + public override void Clear() + { + _cache.Clear(); + _count = 0; + } + + /// 设置缓存项有效期。已过期但未移除的键会重新激活 + /// 键 + /// 过期时间 + /// 设置是否成功 + public override Boolean SetExpire(String key, TimeSpan expire) + { + if (!_cache.TryGetValue(key, out var item) || item == null) return false; + + item.SetExpire(expire); + + return true; + } + + /// 获取缓存项有效期,不存在时返回Zero + /// 键 + /// + public override TimeSpan GetExpire(String key) + { + if (!_cache.TryGetValue(key, out var item) || item == null) return TimeSpan.Zero; + + return TimeSpan.FromMilliseconds(item.ExpiredTime - Runtime.TickCount64); + } + #endregion + + #region 高级操作 + /// 添加,已存在时不更新,常用于锁争夺 + /// 值类型 + /// 键 + /// 值 + /// 过期时间,秒 + /// + public override Boolean Add(String key, T value, Int32 expire = -1) + { + if (expire < 0) expire = Expire; + + CacheItem? item = null; + do + { + if (_cache.TryGetValue(key, out item) && item != null) + { + if (!item.Expired) return false; + + item.Set(value, expire); + + return true; + } + + item ??= new CacheItem(value, expire); + } while (!_cache.TryAdd(key, item)); + + Interlocked.Increment(ref _count); + + return true; + } + + /// 设置新值并获取旧值,原子操作 + /// 值类型 + /// 键 + /// 值 + /// + [return: MaybeNull] + public override T Replace(String key, T value) + { + var expire = Expire; + + CacheItem? item = null; + do + { + if (_cache.TryGetValue(key, out item) && item != null) + { + var rs = item.Visit(); + // 如果已经过期,不要返回旧值 + if (item.Expired) rs = default(T); + + item.Set(value, expire); + + return (T?)rs; + } + + item ??= new CacheItem(value, expire); + } while (!_cache.TryAdd(key, item)); + + Interlocked.Increment(ref _count); + + return default; + } + + /// 尝试获取指定键,返回是否包含值。有可能缓存项刚好是默认值,或者只是反序列化失败 + /// + /// 在 MemoryCache 中,如果某个key过期,在清理之前仍然可以通过TryGet访问,并且更新访问时间,避免被清理。 + /// + /// 值类型 + /// 键 + /// 值。即使有值也不一定能够返回,可能缓存项刚好是默认值,或者只是反序列化失败 + /// 返回是否包含值,即使反序列化失败 + public override Boolean TryGetValue(String key, [MaybeNullWhen(false)] out T value) + { + value = default; + + // 没有值,直接结束 + if (!_cache.TryGetValue(key, out var item) || item == null) return false; + + // 得到已有值 + value = item.Visit(); + + // 是否未过期的有效值 + return !item.Expired; + } + + /// 获取 或 添加 缓存数据,在数据不存在时执行委托请求数据 + /// + /// + /// + /// 过期时间,秒。小于0时采用默认缓存时间 + /// + [return: MaybeNull] + public override T GetOrAdd(String key, Func callback, Int32 expire = -1) + { + if (expire < 0) expire = Expire; + + CacheItem? item = null; + do + { + if (_cache.TryGetValue(key, out item) && item != null) return item.Visit(); + + item ??= new CacheItem(callback(key), expire); + } while (!_cache.TryAdd(key, item)); + + Interlocked.Increment(ref _count); + + return item.Visit(); + } + + /// 累加,原子操作 + /// 键 + /// 变化量 + /// + public override Int64 Increment(String key, Int64 value) + { + var item = GetOrAddItem(key, k => 0L); + return item.Inc(value); + } + + /// 累加,原子操作 + /// 键 + /// 变化量 + /// + public override Double Increment(String key, Double value) + { + var item = GetOrAddItem(key, k => 0d); + return item.Inc(value); + } + + /// 递减,原子操作 + /// 键 + /// 变化量 + /// + public override Int64 Decrement(String key, Int64 value) + { + var item = GetOrAddItem(key, k => 0L); + return item.Dec(value); + } + + /// 递减,原子操作 + /// 键 + /// 变化量 + /// + public override Double Decrement(String key, Double value) + { + var item = GetOrAddItem(key, k => 0d); + return item.Dec(value); + } + #endregion + + #region 集合操作 + /// 获取列表 + /// + /// + /// + public override IList GetList(String key) + { + var item = GetOrAddItem(key, k => new List()); + return item.Visit>() ?? + throw new InvalidCastException($"Unable to convert the value of [{key}] from {item.TypeCode} to {typeof(IList)}"); + } + + /// 获取哈希 + /// + /// + /// + public override IDictionary GetDictionary(String key) + { + var item = GetOrAddItem(key, k => new ConcurrentDictionary()); + return item.Visit>() ?? + throw new InvalidCastException($"Unable to convert the value of [{key}] from {item.TypeCode} to {typeof(IDictionary)}"); + } + + /// 获取队列 + /// + /// + /// + public override IProducerConsumer GetQueue(String key) + { + var item = GetOrAddItem(key, k => new MemoryQueue()); + return item.Visit>() ?? + throw new InvalidCastException($"Unable to convert the value of [{key}] from {item.TypeCode} to {typeof(IProducerConsumer)}"); + } + + /// 获取栈 + /// + /// + /// + public override IProducerConsumer GetStack(String key) + { + var item = GetOrAddItem(key, k => new MemoryQueue(new ConcurrentStack())); + return item.Visit>() ?? + throw new InvalidCastException($"Unable to convert the value of [{key}] from {item.TypeCode} to {typeof(IProducerConsumer)}"); + } + + /// 获取Set + /// 基于HashSet,非线程安全 + /// + /// + /// + public override ICollection GetSet(String key) + { + var item = GetOrAddItem(key, k => new HashSet()); + return item.Visit>() ?? + throw new InvalidCastException($"Unable to convert the value of [{key}] from {item.TypeCode} to {typeof(ICollection)}"); + } + + /// 获取 或 添加 缓存项 + /// + /// + /// + protected CacheItem GetOrAddItem(String key, Func valueFactory) + { + var expire = Expire; + + CacheItem? item = null; + do + { + if (_cache.TryGetValue(key, out item) && item != null) + { + if (!item.Expired) return item; + + item.Set(valueFactory(key), expire); + + return item; + } + + item ??= new CacheItem(valueFactory(key), expire); + } while (!_cache.TryAdd(key, item)); + + Interlocked.Increment(ref _count); + + return item; + } + #endregion + + #region 缓存项 + /// 缓存项 + public class CacheItem + { + /// 数值类型 + public TypeCode TypeCode { get; set; } + + private Int64 _valueLong; + private Object? _value; + /// 数值 + public Object? Value { get => IsInt() ? _valueLong : _value; } + + /// 过期时间。系统启动以来的毫秒数 + public Int64 ExpiredTime { get; set; } + + /// 是否过期 + public Boolean Expired => ExpiredTime <= Runtime.TickCount64; + + /// 访问时间 + public Int64 VisitTime { get; private set; } + + /// 构造缓存项 + /// + /// + public CacheItem(Object? value, Int32 expire) => Set(value, expire); + + /// 设置数值和过期时间 + /// + /// 过期时间,秒 + public void Set(T value, Int32 expire) + { + var type = typeof(T); + TypeCode = type.GetTypeCode(); + + if (IsInt()) + _valueLong = value.ToLong(); + else + _value = value; + + var now = VisitTime = Runtime.TickCount64; + if (expire <= 0) + ExpiredTime = Int64.MaxValue; + else + ExpiredTime = now + expire * 1000; + } + + /// 设置数值和过期时间 + /// + /// 过期时间,秒 + public void Set(T value, TimeSpan expire) + { + var type = typeof(T); + TypeCode = type.GetTypeCode(); + + if (IsInt()) + _valueLong = value.ToLong(); + else + _value = value; + + SetExpire(expire); + } + + /// 设置过期时间 + /// + public void SetExpire(TimeSpan expire) + { + var now = VisitTime = Runtime.TickCount64; + if (expire == TimeSpan.Zero) + ExpiredTime = Int64.MaxValue; + else + ExpiredTime = now + (Int64)expire.TotalMilliseconds; + } + + private Boolean IsInt() => TypeCode >= TypeCode.SByte && TypeCode <= TypeCode.UInt64; + //private Boolean IsDouble() => TypeCode is TypeCode.Single or TypeCode.Double or TypeCode.Decimal; + + /// 更新访问时间并返回数值 + /// + public T? Visit() + { + VisitTime = Runtime.TickCount64; + + if (IsInt()) + { + // 存入取出相同,大多数时候走这里 + if (_valueLong is T n) return n; + + return _valueLong.ChangeType(); + } + else + { + var rs = _value; + if (rs == null) return default; + + // 存入取出相同,大多数时候走这里 + if (rs is T t) return t; + + // 复杂类型返回空值,避免ChangeType失败抛出异常 + if (typeof(T).GetTypeCode() == TypeCode.Object) return default; + + return rs.ChangeType(); + } + } + + /// 递增 + /// + /// + public Int64 Inc(Int64 value) + { + // 如果不是整数,先转为整数 + if (!IsInt()) + { + _valueLong = _value.ToLong(); + TypeCode = TypeCode.Int64; + } + + // 原子操作 + var newValue = Interlocked.Add(ref _valueLong, value); + + VisitTime = Runtime.TickCount64; + + return newValue; + } + + /// 递增 + /// + /// + public Double Inc(Double value) + { + // 原子操作 + Double newValue; + Object? oldValue; + do + { + oldValue = _value; + newValue = (oldValue is Double n ? n : oldValue.ToDouble()) + value; + } while (Interlocked.CompareExchange(ref _value, newValue, oldValue) != oldValue); + + VisitTime = Runtime.TickCount64; + + return newValue; + } + + /// 递减 + /// + /// + public Int64 Dec(Int64 value) + { + // 如果不是整数,先转为整数 + if (!IsInt()) + { + _valueLong = _value.ToLong(); + TypeCode = TypeCode.Int64; + } + + // 原子操作 + var newValue = Interlocked.Add(ref _valueLong, -value); + + VisitTime = Runtime.TickCount64; + + return newValue; + } + + /// 递减 + /// + /// + public Double Dec(Double value) + { + // 原子操作 + Double newValue; + Object? oldValue; + do + { + oldValue = _value; + newValue = (oldValue is Double n ? n : oldValue.ToDouble()) - value; + } while (Interlocked.CompareExchange(ref _value, newValue, oldValue) != oldValue); + + VisitTime = Runtime.TickCount64; + + return newValue; + } + } + #endregion + + #region 清理过期缓存 + /// 清理会话计时器 + private TimerX? _clearTimer; + + /// 移除过期的缓存项 + private void RemoveNotAlive(Object? state) + { + var tx = _clearTimer; + if (tx != null /*&& tx.Period == 60_000*/) tx.Period = Period * 1000; + + var dic = _cache; + if (_count == 0 && !dic.IsEmpty) return; + + // 过期时间升序,用于缓存满以后删除 + var slist = new SortedList>(); + // 超出个数 + var exceed = true; + if (Capacity <= 0 || _count <= Capacity) exceed = false; + + // 60分钟之内过期的数据,进入LRU淘汰 + var now = Runtime.TickCount64; + var exp = now + 3600_000; + var k = 0; + + // 这里先计算,性能很重要 + var toDels = new List(); + foreach (var item in dic) + { + // 已过期,准备删除 + var ci = item.Value; + if (ci.ExpiredTime <= now) + toDels.Add(item.Key); + else + { + k++; + + // 超出个数,且1小时内过期的数据,进入LRU淘汰 + if (exceed && ci.ExpiredTime < exp) + { + if (!slist.TryGetValue(ci.VisitTime, out var ss)) + slist.Add(ci.VisitTime, ss = []); + + ss.Add(item.Key); + } + } + } + + // 如果满了,删除前面 + if (exceed && slist.Count > 0 && _count - toDels.Count > Capacity) + { + // 从lru列表中删除最先将要过期的数据 + var over = _count - toDels.Count - Capacity; + for (var i = 0; i < slist.Count && over > 0; i++) + { + var ss = slist.Values[i]; + if (ss != null && ss.Count > 0) + { + foreach (var item in ss) + { + if (over <= 0) break; + + toDels.Add(item); + over--; + k--; + } + } + } + + XTrace.WriteLine("[{0}]满,{1:n0}>{2:n0},删除[{3:n0}]个", Name, _count, Capacity, toDels.Count); + } + + // 确认删除 + foreach (var item in toDels) + { + if (OnExpire(item)) + _cache.Remove(item); + } + + // 修正 + _count = k; + } + + /// 缓存过期 + /// + protected virtual Boolean OnExpire(String key) + { + var e = new KeyEventArgs { Key = key, Cancel = false }; + KeyExpired?.Invoke(this, e); + + return !e.Cancel; + } + #endregion + +} + +/// 生产者消费者 +/// +public class MemoryQueue : DisposeBase, IProducerConsumer +{ + private readonly IProducerConsumerCollection _collection; + private readonly SemaphoreSlim _occupiedNodes; + + /// 实例化内存队列 + public MemoryQueue() + { + _collection = new ConcurrentQueue(); + _occupiedNodes = new SemaphoreSlim(0); + } + + /// 实例化内存队列 + /// + public MemoryQueue(IProducerConsumerCollection collection) + { + _collection = collection; + _occupiedNodes = new SemaphoreSlim(collection.Count); + } + + /// 元素个数 + public Int32 Count => _collection.Count; + + /// 集合是否为空 + public Boolean IsEmpty + { + get + { + if (_collection is ConcurrentQueue queue) return queue.IsEmpty; + if (_collection is ConcurrentStack stack) return stack.IsEmpty; + + //throw new NotSupportedException(); + return _collection.Count == 0; + } + } + + /// 销毁 + /// + protected override void Dispose(Boolean disposing) + { + base.Dispose(disposing); + + _occupiedNodes.TryDispose(); + } + + /// 生产添加 + /// + /// + public Int32 Add(params T[] values) + { + var count = 0; + foreach (var item in values) + { + if (_collection.TryAdd(item)) + { + count++; + _occupiedNodes.Release(); + } + } + + return count; + } + + /// 消费获取 + /// + /// + public IEnumerable Take(Int32 count = 1) + { + if (count <= 0) yield break; + + for (var i = 0; i < count; i++) + { + if (!_occupiedNodes.Wait(0)) break; + if (!_collection.TryTake(out var item)) break; + + yield return item; + } + } + + /// 消费一个 + /// 超时。默认0秒,永久等待 + /// + public T? TakeOne(Int32 timeout = 0) + { + if (!_occupiedNodes.Wait(0)) + { + if (timeout <= 0 || !_occupiedNodes.Wait(timeout * 1000)) return default; + } + + return _collection.TryTake(out var item) ? item : default; + } + + /// 消费获取,异步阻塞 + /// 超时。单位秒,0秒表示永久等待 + /// + public async Task TakeOneAsync(Int32 timeout = 0) + { + if (!_occupiedNodes.Wait(0)) + { + if (timeout <= 0) return default; + + if (!await _occupiedNodes.WaitAsync(timeout * 1000).ConfigureAwait(false)) return default; + } + + return _collection.TryTake(out var item) ? item : default; + } + + /// 消费获取,异步阻塞 + /// 超时。单位秒,0秒表示永久等待 + /// 取消令牌 + /// + public async Task TakeOneAsync(Int32 timeout, CancellationToken cancellationToken) + { + if (!_occupiedNodes.Wait(0, cancellationToken)) + { + if (timeout <= 0) return default; + + if (!await _occupiedNodes.WaitAsync(timeout * 1000, cancellationToken).ConfigureAwait(false)) return default; + } + + return _collection.TryTake(out var item) ? item : default; + } + + /// 确认消费 + /// + /// + public Int32 Acknowledge(params String[] keys) => 0; +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Collections/ConcurrentHashSet.cs b/src/Admin/ThingsGateway.NewLife.X/Collections/ConcurrentHashSet.cs new file mode 100644 index 000000000..75871bb5e --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Collections/ConcurrentHashSet.cs @@ -0,0 +1,42 @@ +using System.Collections; +using System.Collections.Concurrent; + +namespace ThingsGateway.NewLife.Collections; + +/// 并行哈希集合 +/// +/// 主要用于频繁添加删除而又要遍历的场合 +/// +public class ConcurrentHashSet : IEnumerable where T : notnull +{ + private readonly ConcurrentDictionary _dic = new(); + + /// 是否空集合 + public Boolean IsEmpty => _dic.IsEmpty; + + /// 元素个数 + public Int32 Count => _dic.Count; + + /// 是否包含元素 + /// + /// + public Boolean Contain(T item) => _dic.ContainsKey(item); + + /// 尝试添加 + /// + /// + public Boolean TryAdd(T item) => _dic.TryAdd(item, 0); + + /// 尝试删除 + /// + /// + public Boolean TryRemove(T item) => _dic.TryRemove(item, out _); + + #region IEnumerable 成员 + IEnumerator IEnumerable.GetEnumerator() => _dic.Keys.GetEnumerator(); + #endregion + + #region IEnumerable 成员 + IEnumerator IEnumerable.GetEnumerator() => _dic.Keys.GetEnumerator(); + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Collections/IPool.cs b/src/Admin/ThingsGateway.NewLife.X/Collections/IPool.cs new file mode 100644 index 000000000..c29d66858 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Collections/IPool.cs @@ -0,0 +1,143 @@ +using System.Buffers; +using System.Text; + +namespace ThingsGateway.NewLife.Collections; + +/// 对象池接口 +/// +/// 文档 https://newlifex.com/core/object_pool +/// +/// +public interface IPool +{ + /// 对象池大小 + Int32 Max { get; set; } + + /// 获取 + /// + T Get(); + + /// 归还 + /// + Boolean Return(T value); + + /// 清空 + Int32 Clear(); +} + +/// 对象池扩展 +/// +/// 文档 https://newlifex.com/core/object_pool +/// +public static class Pool +{ + #region StringBuilder + /// 字符串构建器池 + public static IPool StringBuilder { get; set; } = new StringBuilderPool(); + + + /// 归还一个字符串构建器到对象池 + /// + /// 是否需要返回结果 + /// + public static String Return(this StringBuilder sb, Boolean returnResult = true) + { + //if (sb == null) return null; + + var str = returnResult ? sb.ToString() : String.Empty; + + Pool.StringBuilder.Return(sb); + + return str; + } + + /// 字符串构建器池 + public class StringBuilderPool : Pool + { + /// 初始容量。默认100个 + public Int32 InitialCapacity { get; set; } = 100; + + /// 最大容量。超过该大小时不进入池内,默认4k + public Int32 MaximumCapacity { get; set; } = 4 * 1024; + + /// 实例化字符串池。GC2时回收 + public StringBuilderPool() : base(0, true) { } + + /// 创建 + /// + protected override StringBuilder OnCreate() => new(InitialCapacity); + + /// 归还 + /// + /// + public override Boolean Return(StringBuilder value) + { + if (value.Capacity > MaximumCapacity) return false; + + value.Clear(); + + return base.Return(value); + } + } + #endregion + + #region MemoryStream + /// 内存流池 + public static IPool MemoryStream { get; set; } = new MemoryStreamPool(); + + + + /// 归还一个内存流到对象池 + /// + /// 是否需要返回结果 + /// + public static Byte[] Return(this MemoryStream ms, Boolean returnResult = true) + { + //if (ms == null) return null; + + var buf = returnResult ? ms.ToArray() : Empty; + + Pool.MemoryStream.Return(ms); + + return buf; + } + + /// 内存流池 + public class MemoryStreamPool : Pool + { + /// 初始容量。默认1024个 + public Int32 InitialCapacity { get; set; } = 1024; + + /// 最大容量。超过该大小时不进入池内,默认64k + public Int32 MaximumCapacity { get; set; } = 64 * 1024; + + /// 实例化字符串池。GC2时回收 + public MemoryStreamPool() : base(0, true) { } + + /// 创建 + /// + protected override MemoryStream OnCreate() => new(InitialCapacity); + + /// 归还 + /// + /// + public override Boolean Return(MemoryStream value) + { + if (value.Capacity > MaximumCapacity) return false; + + value.Position = 0; + value.SetLength(0); + + return base.Return(value); + } + } + #endregion + + #region ByteArray + /// 字节数组共享存储 + public static ArrayPool Shared { get; set; } = ArrayPool.Shared; + + /// 空数组 + public static Byte[] Empty { get; } = []; + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Collections/NullableDictionary.cs b/src/Admin/ThingsGateway.NewLife.X/Collections/NullableDictionary.cs new file mode 100644 index 000000000..71dc7262a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Collections/NullableDictionary.cs @@ -0,0 +1,40 @@ +namespace ThingsGateway.NewLife.Collections; + +/// 可空字典。获取数据时如果指定键不存在可返回空而不是抛出异常 +/// +/// +public class NullableDictionary : Dictionary, IDictionary where TKey : notnull +{ + /// 实例化一个可空字典 + public NullableDictionary() { } + + /// 指定比较器实例化一个可空字典 + /// + public NullableDictionary(IEqualityComparer comparer) : base(comparer) { } + + /// 实例化一个可空字典 + /// + public NullableDictionary(IDictionary dic) : base(dic) { } + + /// 实例化一个可空字典 + /// + /// + public NullableDictionary(IDictionary dic, IEqualityComparer comparer) : base(dic, comparer) { } + + /// 获取 或 设置 数据 + /// + /// + public new TValue this[TKey item] + { + get + { + if (TryGetValue(item, out var v)) return v; + + return default!; + } + set + { + base[item] = value; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Collections/ObjectPool.cs b/src/Admin/ThingsGateway.NewLife.X/Collections/ObjectPool.cs new file mode 100644 index 000000000..2ab3420b9 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Collections/ObjectPool.cs @@ -0,0 +1,435 @@ +using System.Collections.Concurrent; +using System.Diagnostics; + +using ThingsGateway.NewLife.Log; +using ThingsGateway.NewLife.Reflection; +using ThingsGateway.NewLife.Threading; + +namespace ThingsGateway.NewLife.Collections; + +/// 资源池。支持空闲释放,主要用于数据库连接池和网络连接池 +/// +/// 文档 https://newlifex.com/core/object_pool +/// +/// +public class ObjectPool : DisposeBase, IPool where T : notnull +{ + #region 属性 + /// 名称 + public String Name { get; set; } + + private Int32 _FreeCount; + /// 空闲个数 + public Int32 FreeCount => _FreeCount; + + private Int32 _BusyCount; + /// 繁忙个数 + public Int32 BusyCount => _BusyCount; + + /// 最大个数。默认100,0表示无上限 + public Int32 Max { get; set; } = 100; + + /// 最小个数。默认1 + public Int32 Min { get; set; } = 1; + + /// 空闲清理时间。最小个数之上的资源超过空闲时间时被清理,默认10s + public Int32 IdleTime { get; set; } = 10; + + /// 完全空闲清理时间。最小个数之下的资源超过空闲时间时被清理,默认0s永不清理 + public Int32 AllIdleTime { get; set; } = 0; + + /// 基础空闲集合。只保存最小个数,最热部分 + private readonly ConcurrentStack _free = new(); + + /// 扩展空闲集合。保存最小个数以外部分 + private readonly ConcurrentQueue _free2 = new(); + + /// 借出去的放在这 + private readonly ConcurrentDictionary _busy = new(); + + //private readonly Object SyncRoot = new(); + #endregion + + #region 构造 + /// 实例化一个资源池 + public ObjectPool() + { + var str = GetType().Name; + if (str.Contains('`')) str = str.Substring(null, "`"); + if (str != "Pool") + Name = str; + else + Name = $"Pool<{typeof(T).Name}>"; + } + + /// 销毁 + /// + protected override void Dispose(Boolean disposing) + { + base.Dispose(disposing); + + _timer.TryDispose(); + + WriteLog($"Dispose {typeof(T).FullName} FreeCount={FreeCount:n0} BusyCount={BusyCount:n0} Total={Total:n0}"); + + Clear(); + } + + private volatile Boolean _inited; + private void Init() + { + if (_inited) return; + + lock (this) + { + if (_inited) return; + _inited = true; + + WriteLog($"Init {typeof(T).FullName} Min={Min} Max={Max} IdleTime={IdleTime}s AllIdleTime={AllIdleTime}s"); + } + } + #endregion + + #region 内嵌 + private sealed class Item + { + /// 数值 + public T? Value { get; set; } + + /// 过期时间 + public DateTime LastTime { get; set; } + } + #endregion + + #region 主方法 + /// 借出 + /// + public virtual T Get() + { + var sw = Log == null || Log == Logger.Null ? null : Stopwatch.StartNew(); + Interlocked.Increment(ref _Total); + + var success = false; + Item? pi = null; + do + { + // 从空闲集合借一个 + if (_free.TryPop(out pi) || _free2.TryDequeue(out pi)) + { + Interlocked.Decrement(ref _FreeCount); + + success = true; + } + else + { + // 超出最大值后,抛出异常 + var count = BusyCount; + if (Max > 0 && count >= Max) + { + var msg = $"申请失败,已有 {count:n0} 达到或超过最大值 {Max:n0}"; + + WriteLog("Acquire Max " + msg); + + throw new Exception(Name + " " + msg); + } + + // 借不到,增加 + pi = new Item + { + Value = OnCreate(), + }; + + if (count == 0) Init(); +#if DEBUG + WriteLog("Acquire Create Free={0} Busy={1}", FreeCount, count + 1); +#endif + + Interlocked.Increment(ref _NewCount); + success = false; + } + + // 借出时如果不可用,再次借取 + } while (pi.Value == null || !OnGet(pi.Value)); + + // 最后时间 + pi.LastTime = TimerX.Now; + + // 加入繁忙集合 + _busy.TryAdd(pi.Value, pi); + + Interlocked.Increment(ref _BusyCount); + if (success) Interlocked.Increment(ref _Success); + if (sw != null) + { + sw.Stop(); + var ms = sw.Elapsed.TotalMilliseconds; + + if (Cost < 0.001) + Cost = ms; + else + Cost = (Cost * 3 + ms) / 4; + } + + return pi.Value; + } + + /// 借出时是否可用 + /// + /// + protected virtual Boolean OnGet(T value) => true; + + /// 申请资源包装项,Dispose时自动归还到池中 + /// + public PoolItem GetItem() => new(this, Get()); + + + /// 归还 + /// + public virtual Boolean Return(T value) + { + if (value == null) return false; + + // 从繁忙队列找到并移除缓存项 + if (!_busy.TryRemove(value, out var pi)) + { +#if DEBUG + WriteLog("Return Error"); +#endif + Interlocked.Increment(ref _ReleaseCount); + + return false; + } + + Interlocked.Decrement(ref _BusyCount); + + // 是否可用 + if (!OnReturn(value)) + { + Interlocked.Increment(ref _ReleaseCount); + return false; + } + + if (value is DisposeBase db && db.Disposed) + { + Interlocked.Increment(ref _ReleaseCount); + return false; + } + + var min = Min; + + // 如果空闲数不足最小值,则返回到基础空闲集合 + if (_FreeCount < min /*|| _free.Count < min*/) + _free.Push(pi); + else + _free2.Enqueue(pi); + + // 最后时间 + pi.LastTime = TimerX.Now; + + Interlocked.Increment(ref _FreeCount); + + // 启动定期清理的定时器 + StartTimer(); + + return true; + } + + /// 归还时是否可用 + /// + /// + protected virtual Boolean OnReturn(T value) => true; + + /// 清空已有对象 + public virtual Int32 Clear() + { + var count = _FreeCount + _BusyCount; + + //_busy.Clear(); + //_BusyCount = 0; + + //_free.Clear(); + //while (_free2.TryDequeue(out var rs)) ; + //_FreeCount = 0; + + while (_free.TryPop(out var pi)) OnDispose(pi.Value); + while (_free2.TryDequeue(out var pi)) OnDispose(pi.Value); + _FreeCount = 0; + + foreach (var item in _busy) + { + OnDispose(item.Key); + } + _busy.Clear(); + _BusyCount = 0; + + return count; + } + + /// 销毁 + /// + protected virtual void OnDispose(T? value) => value.TryDispose(); + #endregion + + #region 重载 + /// 创建实例 + /// + protected virtual T? OnCreate() => (T?)typeof(T).CreateInstance(); + #endregion + + #region 定期清理 + private TimerX? _timer; + + private void StartTimer() + { + if (_timer != null) return; + lock (this) + { + if (_timer != null) return; + + _timer = new TimerX(Work, null, 5000, 5000) { Async = true }; + } + } + + private void Work(Object? state) + { + //// 总数小于等于最小个数时不处理 + //if (FreeCount + BusyCount <= Min) return; + + // 遍历并干掉过期项 + var count = 0; + + // 清理过期不还。避免有借没还 + if (!_busy.IsEmpty) + { + var exp = TimerX.Now.AddSeconds(-AllIdleTime); + foreach (var item in _busy) + { + if (item.Value.LastTime < exp) + { + if (_busy.TryRemove(item.Key, out _)) + { + // 业务层可能故意有借没还 + //v.TryDispose(); + + Interlocked.Decrement(ref _BusyCount); + } + } + } + } + + // 总数小于等于最小个数时不处理 + if (IdleTime > 0 && !_free2.IsEmpty && FreeCount + BusyCount > Min) + { + var exp = TimerX.Now.AddSeconds(-IdleTime); + // 移除扩展空闲集合里面的超时项 + while (_free2.TryPeek(out var pi) && pi.LastTime < exp) + { + // 取出来销毁 + if (_free2.TryDequeue(out pi)) + { + pi.Value.TryDispose(); + + count++; + Interlocked.Decrement(ref _FreeCount); + } + } + } + + if (AllIdleTime > 0 && !_free.IsEmpty) + { + var exp = TimerX.Now.AddSeconds(-AllIdleTime); + // 移除基础空闲集合里面的超时项 + while (_free.TryPeek(out var pi) && pi.LastTime < exp) + { + // 取出来销毁 + if (_free.TryPop(out pi)) + { + pi.Value.TryDispose(); + + count++; + Interlocked.Decrement(ref _FreeCount); + } + } + } + + var ncount = _NewCount; + var fcount = _ReleaseCount; + if (count > 0 || ncount > 0 || fcount > 0) + { + Interlocked.Add(ref _NewCount, -ncount); + Interlocked.Add(ref _ReleaseCount, -fcount); + + var p = Total == 0 ? 0 : (Double)Success / Total; + + WriteLog("Release New={6:n0} Release={7:n0} Free={0} Busy={1} 清除过期资源 {2:n0} 项。总请求 {3:n0} 次,命中 {4:p2},平均 {5:n2}us", FreeCount, BusyCount, count, Total, p, Cost * 1000, ncount, fcount); + } + } + #endregion + + #region 统计 + private Int32 _Total; + /// 总请求数 + public Int32 Total => _Total; + + private Int32 _Success; + /// 成功数 + public Int32 Success => _Success; + + /// 新创建数 + private Int32 _NewCount; + + /// 释放数 + private Int32 _ReleaseCount; + + /// 平均耗时。单位ms + private Double Cost; + #endregion + + #region 日志 + /// 日志 + public ILog Log { get; set; } = Logger.Null; + + /// 写日志 + /// + /// + public void WriteLog(String format, params Object?[] args) + { + if (Log == null || !Log.Enable) return; + + Log.Info(Name + "." + format, args); + } + #endregion +} + +/// 资源池包装项,自动归还资源到池中 +/// +public class PoolItem : DisposeBase +{ + #region 属性 + /// 数值 + public T Value { get; } + + /// + public IPool Pool { get; } + #endregion + + #region 构造 + /// 包装项 + /// + /// + public PoolItem(IPool pool, T value) + { + Pool = pool; + Value = value; + } + + /// 销毁 + /// + protected override void Dispose(Boolean disposing) + { + base.Dispose(disposing); + + Pool.Return(Value); + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Collections/Pool.cs b/src/Admin/ThingsGateway.NewLife.X/Collections/Pool.cs new file mode 100644 index 000000000..87005d21a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Collections/Pool.cs @@ -0,0 +1,154 @@ +using System.Diagnostics.CodeAnalysis; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Collections; + +/// 轻量级对象池。数组无锁实现,高性能 +/// +/// 文档 https://newlifex.com/core/object_pool +/// 内部 1+N 的存储结果,保留最热的一个对象在外层,便于快速存取。 +/// 数组具有极快的查找速度,结构体确保没有GC操作。 +/// +/// +public class Pool : IPool where T : class +{ + #region 属性 + /// 对象池大小。默认CPU*2,初始化后改变无效 + public Int32 Max { get; set; } + + private Item[]? _items; + private T? _current; + + private struct Item + { + public T? Value; + } + #endregion + + #region 构造 + /// 实例化对象池。默认大小CPU*2 + /// 最大对象数。默认大小CPU*2 + public Pool(Int32 max = 0) + { + if (max <= 0) max = Environment.ProcessorCount * 2; + + Max = max; + } + + /// 实例化对象池。GC2时回收 + /// 最大对象数。默认大小CPU*2 + /// 是否在二代GC时回收池里对象 + protected Pool(Int32 max, Boolean useGcClear) : this(max) + { + if (useGcClear) Gen2GcCallback.Register(s => (s as Pool)!.OnGen2(), this); + } + + private Int64 _next; + private Boolean OnGen2() + { + var now = Runtime.TickCount64; + if (_next <= 0) + _next = now + 60000; + else if (_next < now) + { + Clear(); + _next = now + 60000; + } + + return true; + } + + [MemberNotNull(nameof(_items))] + private void Init() + { + if (_items != null) return; + lock (this) + { + if (_items != null) return; + + _items = new Item[Max - 1]; + } + } + #endregion + + #region 方法 + /// 获取 + /// + public virtual T Get() + { + // 最热的一个对象在外层,便于快速存取 + var val = _current; + if (val != null && Interlocked.CompareExchange(ref _current, null, val) == val) return val; + + Init(); + + var items = _items; + for (var i = 0; i < items.Length; i++) + { + val = items[i].Value; + if (val != null && Interlocked.CompareExchange(ref items[i].Value, null, val) == val) return val; + } + + var rs = OnCreate(); + if (rs == null) throw new InvalidOperationException($"Unable to create an instance of [{typeof(T).FullName}]"); + + return rs; + } + + + + /// 归还 + /// + /// + public virtual Boolean Return(T value) + { + // 最热的一个对象在外层,便于快速存取 + if (_current == null && Interlocked.CompareExchange(ref _current, value, null) == null) return true; + + Init(); + + var items = _items; + for (var i = 0; i < items.Length; ++i) + { + if (Interlocked.CompareExchange(ref items[i].Value, value, null) == null) return true; + } + + return false; + } + + /// 清空 + /// + public virtual Int32 Clear() + { + var count = 0; + + if (_current != null) + { + _current = null; + count++; + } + + var items = _items; + if (items == null) return count; + + for (var i = 0; i < items.Length; ++i) + { + if (items[i].Value != null) + { + items[i].Value = null; + count++; + } + } + _items = null; + + return count; + } + #endregion + + #region 重载 + /// 创建实例 + /// + protected virtual T? OnCreate() => typeof(T).CreateInstance() as T; + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Collections/QueueService.cs b/src/Admin/ThingsGateway.NewLife.X/Collections/QueueService.cs new file mode 100644 index 000000000..a8e9767c2 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Collections/QueueService.cs @@ -0,0 +1,113 @@ +using System.Collections.Concurrent; + +using ThingsGateway.NewLife.Caching; + +namespace ThingsGateway.NewLife.Collections +{ + /// 主动式消息服务 + /// 数据类型 + public interface IQueueService + { + /// 发布消息 + /// 主题 + /// 消息 + /// + Int32 Public(String topic, T value); + + /// 订阅 + /// 客户标识 + /// 主题 + Boolean Subscribe(String clientId, String topic); + + /// 取消订阅 + /// 客户标识 + /// 主题 + Boolean UnSubscribe(String clientId, String topic); + + /// 消费消息 + /// 客户标识 + /// 主题 + /// 要拉取的消息数 + /// + T[] Consume(String clientId, String topic, Int32 count); + } + + /// 轻量级主动式消息服务 + /// 数据类型 + public class QueueService : IQueueService + { + #region 属性 + /// 数据存储 + public ICache Cache { get; set; } = MemoryCache.Instance; + + /// 每个主题的所有订阅者 + private readonly ConcurrentDictionary>> _topics = new(); + #endregion + + #region 方法 + /// 发布消息 + /// 主题 + /// 消息 + /// + public Int32 Public(String topic, T value) + { + var rs = 0; + if (_topics.TryGetValue(topic, out var clients)) + { + // 向每个订阅者推送 + foreach (var item in clients) + { + var queue = item.Value; + rs += queue.Add(new[] { value }); + } + } + + return rs; + } + + /// 订阅 + /// 客户标识 + /// 主题 + public Boolean Subscribe(String clientId, String topic) + { + var dic = _topics.GetOrAdd(topic, k => new ConcurrentDictionary>()); + if (dic.ContainsKey(clientId)) return false; + + // 创建队列 + var queue = Cache.GetQueue($"{topic}_{clientId}"); + return dic.TryAdd(clientId, queue); + } + + /// 取消订阅 + /// 客户标识 + /// 主题 + public Boolean UnSubscribe(String clientId, String topic) + { + if (_topics.TryGetValue(topic, out var clients)) + { + return clients.TryRemove(clientId, out _); + } + + return false; + } + + /// 消费消息 + /// 客户标识 + /// 主题 + /// + /// + public T[] Consume(String clientId, String topic, Int32 count) + { + if (_topics.TryGetValue(topic, out var clients)) + { + if (clients.TryGetValue(clientId, out var queue)) + { + return queue.Take(count).ToArray(); + } + } + + return []; + } + #endregion + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/ConvertUtility.cs b/src/Admin/ThingsGateway.NewLife.X/Common/ConvertUtility.cs new file mode 100644 index 000000000..ac28d96cc --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/ConvertUtility.cs @@ -0,0 +1,954 @@ +using System.ComponentModel; +using System.Globalization; +using System.Reflection; + +using ThingsGateway.NewLife.Collections; + +namespace ThingsGateway.NewLife.Extension; + +/// 工具类 +/// +/// 文档 https://newlifex.com/core/utility +/// +/// 采用静态架构,允许外部重载工具类的各种实现。 +/// 所有类型转换均支持默认值,默认值为该default(T),在转换失败时返回默认值。 +/// +public static class ConvertUtility +{ + #region 类型转换 + /// 类型转换提供者 + /// 重载默认提供者并赋值给可改变所有类型转换的行为 + public static DefaultConvert Convert { get; set; } = new DefaultConvert(); + + /// 转为整数,转换失败时返回默认值。支持字符串、全角、字节数组(小端)、时间(Unix秒不转UTC) + /// Int16/UInt32/Int64等,可以先转为最常用的Int32后再二次处理 + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public static Int32 ToInt(this Object? value, Int32 defaultValue = 0) => Convert.ToInt(value, defaultValue); + + /// 转为长整数,转换失败时返回默认值。支持字符串、全角、字节数组(小端)、时间(Unix毫秒不转UTC) + /// + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public static Int64 ToLong(this Object? value, Int64 defaultValue = 0) => Convert.ToLong(value, defaultValue); + + /// 转为浮点数,转换失败时返回默认值。支持字符串、全角、字节数组(小端) + /// Single可以先转为最常用的Double后再二次处理 + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public static Double ToDouble(this Object? value, Double defaultValue = 0) => Convert.ToDouble(value, defaultValue); + + /// 转为高精度浮点数,转换失败时返回默认值。支持字符串、全角、字节数组(小端) + /// Single可以先转为最常用的Double后再二次处理 + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public static Decimal ToDecimal(this Object? value, Decimal defaultValue = 0) => Convert.ToDecimal(value, defaultValue); + + /// 转为布尔型,转换失败时返回默认值。支持大小写True/False、0和非零 + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public static Boolean ToBoolean(this Object? value, Boolean defaultValue = false) => Convert.ToBoolean(value, defaultValue); + + /// 转为时间日期,转换失败时返回最小时间。支持字符串、整数(Unix秒不考虑UTC转本地) + /// + /// 整数转时间日期时,取1970-01-01加上指定秒数,不考虑UTC时间和本地时间。 + /// 长整数转时间日期时,取1970-01-01加上指定毫秒数,不考虑UTC时间和本地时间。 + /// 在网络中传输时间日期时,特别是物联网设备到云平台的通信,一般取客户端本地UTC时间,转为长整型传输,服务端再转为本地时间。 + /// 因为设备和服务端可能不在同一时区,甚至多个设备也没有处于同一个时区。 + /// + /// 待转换对象 + /// + public static DateTime ToDateTime(this Object? value) => Convert.ToDateTime(value, DateTime.MinValue); + + /// 转为时间日期,转换失败时返回默认值。支持字符串、整数(Unix秒不考虑UTC转本地) + /// + /// 整数转时间日期时,取1970-01-01加上指定秒数,不考虑UTC时间和本地时间。 + /// 长整数转时间日期时,取1970-01-01加上指定毫秒数,不考虑UTC时间和本地时间。 + /// 在网络中传输时间日期时,特别是物联网设备到云平台的通信,一般取客户端本地UTC时间,转为长整型传输,服务端再转为本地时间。 + /// 因为设备和服务端可能不在同一时区,甚至多个设备也没有处于同一个时区。 + /// + /// 不是常量无法做默认值。 + /// + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public static DateTime ToDateTime(this Object? value, DateTime defaultValue) => Convert.ToDateTime(value, defaultValue); + + /// 转为时间日期,转换失败时返回最小时间。支持字符串、整数(Unix秒) + /// 待转换对象 + /// + public static DateTimeOffset ToDateTimeOffset(this Object? value) => Convert.ToDateTimeOffset(value, DateTimeOffset.MinValue); + + /// 转为时间日期,转换失败时返回默认值 + /// 不是常量无法做默认值 + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public static DateTimeOffset ToDateTimeOffset(this Object? value, DateTimeOffset defaultValue) => Convert.ToDateTimeOffset(value, defaultValue); + + /// 去掉时间日期指定位置后面部分,可指定毫秒ms、秒s、分m、小时h、纳秒ns + /// 时间日期 + /// 格式字符串,默认s格式化到秒,ms格式化到毫秒 + /// + public static DateTime Trim(this DateTime value, String format = "s") => Convert.Trim(value, format); + + /// 去掉时间日期指定位置后面部分,可指定毫秒ms、秒s、分m、小时h、纳秒ns + /// 时间日期 + /// 格式字符串,默认s格式化到秒,ms格式化到毫秒 + /// + public static DateTimeOffset Trim(this DateTimeOffset value, String format = "s") => new(Convert.Trim(value.DateTime, format), value.Offset); + + /// 时间日期转为yyyy-MM-dd HH:mm:ss完整字符串,对UTC时间加后缀 + /// 最常用的时间日期格式,可以无视各平台以及系统自定义的时间格式 + /// 待转换对象 + /// + public static String ToFullString(this DateTime value) => Convert.ToFullString(value, false); + + /// 时间日期转为yyyy-MM-dd HH:mm:ss完整字符串,支持指定最小时间的字符串 + /// 最常用的时间日期格式,可以无视各平台以及系统自定义的时间格式 + /// 待转换对象 + /// 字符串空值时(DateTime.MinValue)显示的字符串,null表示原样显示最小时间,String.Empty表示不显示 + /// + public static String ToFullString(this DateTime value, String? emptyValue = null) => Convert.ToFullString(value, false, emptyValue); + + /// 时间日期转为yyyy-MM-dd HH:mm:ss.fff完整字符串,支持指定最小时间的字符串 + /// 最常用的时间日期格式,可以无视各平台以及系统自定义的时间格式 + /// 待转换对象 + /// 是否使用毫秒 + /// 字符串空值时(DateTime.MinValue)显示的字符串,null表示原样显示最小时间,String.Empty表示不显示 + /// + public static String ToFullString(this DateTime value, Boolean useMillisecond, String? emptyValue = null) => Convert.ToFullString(value, useMillisecond, emptyValue); + + /// 时间日期转为yyyy-MM-dd HH:mm:ss +08:00完整字符串,支持指定最小时间的字符串 + /// 最常用的时间日期格式,可以无视各平台以及系统自定义的时间格式 + /// 待转换对象 + /// 字符串空值时(DateTimeOffset.MinValue)显示的字符串,null表示原样显示最小时间,String.Empty表示不显示 + /// + public static String ToFullString(this DateTimeOffset value, String? emptyValue = null) => Convert.ToFullString(value, false, emptyValue); + + /// 时间日期转为yyyy-MM-dd HH:mm:ss.fff +08:00完整字符串,支持指定最小时间的字符串 + /// 最常用的时间日期格式,可以无视各平台以及系统自定义的时间格式 + /// 待转换对象 + /// 是否使用毫秒 + /// 字符串空值时(DateTimeOffset.MinValue)显示的字符串,null表示原样显示最小时间,String.Empty表示不显示 + /// + public static String ToFullString(this DateTimeOffset value, Boolean useMillisecond, String? emptyValue = null) => Convert.ToFullString(value, useMillisecond, emptyValue); + + /// 时间日期转为指定格式字符串 + /// 待转换对象 + /// 格式化字符串 + /// 字符串空值时显示的字符串,null表示原样显示最小时间,String.Empty表示不显示 + /// + public static String ToString(this DateTime value, String format, String emptyValue) => Convert.ToString(value, format, emptyValue); + + /// 字节单位字符串 + /// 数值 + /// 格式化字符串 + /// + public static String ToGMK(this UInt64 value, String? format = null) => Convert.ToGMK(value, format); + + /// 字节单位字符串 + /// 数值 + /// 格式化字符串 + /// + public static String ToGMK(this Int64 value, String? format = null) => value < 0 ? value + "" : Convert.ToGMK((UInt64)value, format); + #endregion + + #region 异常处理 + /// 获取内部真实异常 + /// + /// + public static Exception GetTrue(this Exception ex) => Convert.GetTrue(ex); + + /// 获取异常消息 + /// 异常 + /// + public static String GetMessage(this Exception ex) => Convert.GetMessage(ex); + #endregion +} + +/// 默认转换 +[EditorBrowsable(EditorBrowsableState.Advanced)] +public class DefaultConvert +{ + private static readonly DateTime _dt1970 = new(1970, 1, 1); + private static readonly DateTimeOffset _dto1970 = new(new DateTime(1970, 1, 1)); + private static readonly Int64 _maxSeconds = (Int64)(DateTime.MaxValue - DateTime.MinValue).TotalSeconds; + private static readonly Int64 _maxMilliseconds = (Int64)(DateTime.MaxValue - DateTime.MinValue).TotalMilliseconds; + + /// 转为整数,转换失败时返回默认值。支持字符串、全角、字节数组(小端)、时间(Unix秒) + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public virtual Int32 ToInt(Object? value, Int32 defaultValue) + { + if (value is Int32 num) return num; + if (value == null || value == DBNull.Value) return defaultValue; + + // 支持表单提交的StringValues + if (value is IList list) + { + if (list.Count == 0) return defaultValue; + value = list.FirstOrDefault(e => !e.IsNullOrEmpty()); + if (value == null) return defaultValue; + } + + // 特殊处理字符串,也是最常见的 + if (value is String str) + { + // 拷贝而来的逗号分隔整数 + Span tmp = stackalloc Char[str.Length]; + var rs = TrimNumber(str.AsSpan(), tmp); + if (rs == 0) return defaultValue; + +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + return Int32.TryParse(tmp[..rs], out var n) ? n : defaultValue; +#else + return Int32.TryParse(tmp[..rs].ToString(), out var n) ? n : defaultValue; +#endif + } + + // 特殊处理时间,转Unix秒 +#if NET6_0_OR_GREATER + if (value is DateOnly date) value = date.ToDateTime(TimeOnly.MinValue); +#endif + if (value is DateTime dt) + { + if (dt == DateTime.MinValue) return 0; + if (dt == DateTime.MaxValue) return -1; + + //// 先转UTC时间再相减,以得到绝对时间差 + //return (Int32)(dt.ToUniversalTime() - _dt1970).TotalSeconds; + // 保存时间日期由Int32改为UInt32,原截止2038年的范围扩大到2106年 + var n = (dt - _dt1970).TotalSeconds; + return n >= Int32.MaxValue ? throw new InvalidDataException("Time too long, value exceeds Int32.MaxValue") : (Int32)n; + } + if (value is DateTimeOffset dto) + { + if (dto == DateTimeOffset.MinValue) return 0; + + //return (Int32)(dto - _dto1970).TotalSeconds; + var n = (dto - _dto1970).TotalSeconds; + return n >= Int32.MaxValue ? throw new InvalidDataException("Time too long, value exceeds Int32.MaxValue") : (Int32)n; + } + + if (value is Byte[] buf) + { + if (buf == null || buf.Length <= 0) return defaultValue; + + switch (buf.Length) + { + case 1: + return buf[0]; + case 2: + return BitConverter.ToInt16(buf, 0); + case 3: + return BitConverter.ToInt32([buf[0], buf[1], buf[2], 0], 0); + case 4: + return BitConverter.ToInt32(buf, 0); + default: + break; + } + } + + try + { + // 转换接口 + if (value is IConvertible conv) return conv.ToInt32(null); + + //return Convert.ToInt32(value); + } + catch { } + + // 转字符串再转整数,作为兜底方案 + var str2 = value.ToString(); + return !str2.IsNullOrEmpty() && Int32.TryParse(str2.Trim(), out var n2) ? n2 : defaultValue; + } + + /// 转为长整数。支持字符串、全角、字节数组(小端)、时间(Unix毫秒) + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public virtual Int64 ToLong(Object? value, Int64 defaultValue) + { + if (value is Int64 num) return num; + if (value == null || value == DBNull.Value) return defaultValue; + + // 支持表单提交的StringValues + if (value is IList list) + { + if (list.Count == 0) return defaultValue; + value = list.FirstOrDefault(e => !e.IsNullOrEmpty()); + if (value == null) return defaultValue; + } + + // 特殊处理字符串,也是最常见的 + if (value is String str) + { + // 拷贝而来的逗号分隔整数 + Span tmp = stackalloc Char[str.Length]; + var rs = TrimNumber(str.AsSpan(), tmp); + if (rs == 0) return defaultValue; + +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + return Int64.TryParse(tmp[..rs], out var n) ? n : defaultValue; +#else + return Int64.TryParse(new String(tmp[..rs].ToArray()), out var n) ? n : defaultValue; +#endif + } + + // 特殊处理时间,转Unix毫秒 +#if NET6_0_OR_GREATER + if (value is DateOnly date) value = date.ToDateTime(TimeOnly.MinValue); +#endif + if (value is DateTime dt) + { + if (dt == DateTime.MinValue) return 0; + + //// 先转UTC时间再相减,以得到绝对时间差 + //return (Int32)(dt.ToUniversalTime() - _dt1970).TotalSeconds; + return (Int64)(dt - _dt1970).TotalMilliseconds; + } + if (value is DateTimeOffset dto) + { + return dto == DateTimeOffset.MinValue ? 0 : (Int64)(dto - _dto1970).TotalMilliseconds; + } + + if (value is Byte[] buf) + { + if (buf == null || buf.Length <= 0) return defaultValue; + + switch (buf.Length) + { + case 1: + return buf[0]; + case 2: + return BitConverter.ToInt16(buf, 0); + case 3: + return BitConverter.ToInt32([buf[0], buf[1], buf[2], 0], 0); + case 4: + return BitConverter.ToInt32(buf, 0); + case 8: + return BitConverter.ToInt64(buf, 0); + default: + break; + } + } + + //暂时不做处理 先处理异常转换 + try + { + // 转换接口 + if (value is IConvertible conv) return conv.ToInt64(null); + + //return Convert.ToInt64(value); + } + catch { } + + // 转字符串再转整数,作为兜底方案 + var str2 = value.ToString(); + return !str2.IsNullOrEmpty() && Int64.TryParse(str2.Trim(), out var n2) ? n2 : defaultValue; + } + + /// 转为浮点数 + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public virtual Double ToDouble(Object? value, Double defaultValue) + { + if (value is Double num) return Double.IsNaN(num) ? defaultValue : num; + if (value == null || value == DBNull.Value) return defaultValue; + + // 支持表单提交的StringValues + if (value is IList list) + { + if (list.Count == 0) return defaultValue; + value = list.FirstOrDefault(e => !e.IsNullOrEmpty()); + if (value == null) return defaultValue; + } + + // 特殊处理字符串,也是最常见的 + if (value is String str) + { + Span tmp = stackalloc Char[str.Length]; + var rs = TrimNumber(str.AsSpan(), tmp); + if (rs == 0) return defaultValue; + +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + return Double.TryParse(tmp[..rs], out var n) ? n : defaultValue; +#else + return Double.TryParse(new String(tmp[..rs].ToArray()), out var n) ? n : defaultValue; +#endif + } + + if (value is Byte[] buf && buf.Length <= 8) + return BitConverter.ToDouble(buf, 0); + + try + { + // 转换接口 + if (value is IConvertible conv) return conv.ToDouble(null); + + //return Convert.ToDouble(value); + } + catch { } + + // 转字符串再转整数,作为兜底方案 + var str2 = value.ToString(); + return !str2.IsNullOrEmpty() && Double.TryParse(str2.Trim(), out var n2) ? n2 : defaultValue; + } + + /// 转为高精度浮点数 + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public virtual Decimal ToDecimal(Object? value, Decimal defaultValue) + { + if (value is Decimal num) return num; + if (value == null || value == DBNull.Value) return defaultValue; + + // 支持表单提交的StringValues + if (value is IList list) + { + if (list.Count == 0) return defaultValue; + value = list.FirstOrDefault(e => !e.IsNullOrEmpty()); + if (value == null) return defaultValue; + } + + // 特殊处理字符串,也是最常见的 + if (value is String str) + { + Span tmp = stackalloc Char[str.Length]; + var rs = TrimNumber(str.AsSpan(), tmp); + if (rs == 0) return defaultValue; + +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + return Decimal.TryParse(tmp[..rs], out var n) ? n : defaultValue; +#else + return Decimal.TryParse(new String(tmp[..rs].ToArray()), out var n) ? n : defaultValue; +#endif + } + + if (value is Byte[] buf) + { + if (buf == null || buf.Length <= 0) return defaultValue; + + switch (buf.Length) + { + case 1: + return buf[0]; + case 2: + return BitConverter.ToInt16(buf, 0); + case 3: + return BitConverter.ToInt32([buf[0], buf[1], buf[2], 0], 0); + case 4: + return BitConverter.ToInt32(buf, 0); + default: + // 凑够8字节 + if (buf.Length < 8) + { + var bts = Pool.Shared.Rent(8); + Buffer.BlockCopy(buf, 0, bts, 0, buf.Length); + + var dec = BitConverter.ToDouble(bts, 0).ToDecimal(); + + Pool.Shared.Return(bts); + + return dec; + } + return BitConverter.ToDouble(buf, 0).ToDecimal(); + } + } + + if (value is Double d) + { + return Double.IsNaN(d) ? defaultValue : (Decimal)d; + } + + try + { + // 转换接口 + if (value is IConvertible conv) return conv.ToDecimal(null); + + //return Convert.ToDecimal(value); + } + catch { } + + // 转字符串再转整数,作为兜底方案 + var str2 = value.ToString(); + return !str2.IsNullOrEmpty() && Decimal.TryParse(str2.Trim(), out var n2) ? n2 : defaultValue; + } + + /// 转为布尔型。支持大小写True/False、0和非零 + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public virtual Boolean ToBoolean(Object? value, Boolean defaultValue) + { + if (value is Boolean num) return num; + if (value == null || value == DBNull.Value) return defaultValue; + + // 支持表单提交的StringValues + if (value is IList list) + { + if (list.Count == 0) return defaultValue; + value = list.FirstOrDefault(e => !e.IsNullOrEmpty()); + if (value == null) return defaultValue; + } + + // 特殊处理字符串,也是最常见的 + var str = value.ToString().Trim(); + if (str.IsNullOrEmpty()) return defaultValue; + + if (Boolean.TryParse(str, out var b)) return b; + + if (String.Equals(str, Boolean.TrueString, StringComparison.OrdinalIgnoreCase)) return true; + if (String.Equals(str, Boolean.FalseString, StringComparison.OrdinalIgnoreCase)) return false; + + if (Int32.TryParse(str, out var n)) return n != 0; + + try + { + // 转换接口 + if (value is IConvertible conv) return conv.ToBoolean(null); + + //return Convert.ToBoolean(value); + } + catch { } + + // 转字符串再转整数,作为兜底方案 + var str2 = value.ToString(); + return !str2.IsNullOrEmpty() && Boolean.TryParse(str2.Trim(), out var n2) ? n2 : defaultValue; + } + + /// 转为时间日期,转换失败时返回最小时间。支持字符串、整数(Unix秒) + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + /// + /// 整数(Unix秒)转换后不包含时区信息,需要调用.ToLocalTime()来转换为当前时区时间 + /// + public virtual DateTime ToDateTime(Object? value, DateTime defaultValue) + { + if (value is DateTime num) return num; + if (value == null || value == DBNull.Value) return defaultValue; + + // 支持表单提交的StringValues + if (value is IList list) + { + if (list.Count == 0) return defaultValue; + value = list.FirstOrDefault(e => !e.IsNullOrEmpty()); + if (value == null) return defaultValue; + } + + // 特殊处理字符串,也是最常见的 + if (value is String str) + { + str = str.Trim(); + if (str.IsNullOrEmpty()) return defaultValue; + + // 处理UTC + var utc = false; + if (str.EndsWithIgnoreCase(" UTC") || str.EndsWith('Z') && str.Contains('T')) + { + utc = true; + str = str[0..^4]; + } + + if (!DateTime.TryParse(str, out var dt) && + !(str.Contains('-') && DateTime.TryParseExact(str, "yyyy-M-d", null, DateTimeStyles.None, out dt)) && + !(str.Contains('/') && DateTime.TryParseExact(str, "yyyy/M/d", null, DateTimeStyles.None, out dt)) && + !DateTime.TryParseExact(str, "yyyyMMddHHmmss", null, DateTimeStyles.None, out dt) && + !DateTime.TryParseExact(str, "yyyyMMdd", null, DateTimeStyles.None, out dt) && + !DateTime.TryParse(str, out dt)) + { + dt = defaultValue; + } + + // 处理UTC + if (utc) dt = new DateTime(dt.Ticks, DateTimeKind.Utc); + + return dt; + } + + // 特殊处理整数,Unix秒,绝对时间差,不考虑UTC时间和本地时间。 + if (value is Int32 k) + { + return k >= _maxSeconds || k <= -_maxSeconds ? defaultValue : _dt1970.AddSeconds(k); + } + if (value is Int64 m) + { + return m >= _maxMilliseconds || m <= -_maxMilliseconds + ? defaultValue + : m > 100 * 365 * 24 * 3600L ? _dt1970.AddMilliseconds(m) : _dt1970.AddSeconds(m); + } + + try + { + // 转换接口 + if (value is IConvertible conv) return conv.ToDateTime(null); + + //return Convert.ToDateTime(value); + } + catch { } + + // 转字符串再转整数,作为兜底方案 + return ToDateTime(value.ToString(), defaultValue); + } + + /// 转为时间日期,转换失败时返回最小时间。支持字符串、整数(Unix秒) + /// 待转换对象 + /// 默认值。待转换对象无效时使用 + /// + public virtual DateTimeOffset ToDateTimeOffset(Object? value, DateTimeOffset defaultValue) + { + if (value is DateTimeOffset num) return num; + if (value == null || value == DBNull.Value) return defaultValue; + +#if NET6_0_OR_GREATER + if (value is DateOnly date) value = date.ToDateTime(TimeOnly.MinValue); +#endif + if (value is DateTime dateTime) return dateTime; + + // 支持表单提交的StringValues + if (value is IList list) + { + if (list.Count == 0) return defaultValue; + value = list.FirstOrDefault(e => !e.IsNullOrEmpty()); + if (value == null) return defaultValue; + } + + // 特殊处理字符串,也是最常见的 + if (value is String str) + { + str = str.Trim(); + if (str.IsNullOrEmpty()) return defaultValue; + + if (DateTimeOffset.TryParse(str, out var dt)) return dt; + return str.Contains('-') && DateTimeOffset.TryParseExact(str, "yyyy-M-d", null, DateTimeStyles.None, out dt) + ? dt + : str.Contains('/') && DateTimeOffset.TryParseExact(str, "yyyy/M/d", null, DateTimeStyles.None, out dt) + ? dt + : DateTimeOffset.TryParseExact(str, "yyyyMMddHHmmss", null, DateTimeStyles.None, out dt) + ? dt + : DateTimeOffset.TryParseExact(str, "yyyyMMdd", null, DateTimeStyles.None, out dt) ? dt : defaultValue; + } + + // 特殊处理整数,Unix秒,绝对时间差,不考虑UTC时间和本地时间。 + if (value is Int32 k) + { + return k >= _maxSeconds || k <= -_maxSeconds ? defaultValue : _dto1970.AddSeconds(k); + } + if (value is Int64 m) + { + return m >= _maxMilliseconds || m <= -_maxMilliseconds + ? defaultValue + : m > 100 * 365 * 24 * 3600L ? _dto1970.AddMilliseconds(m) : _dto1970.AddSeconds(m); + } + + try + { + return Convert.ToDateTime(value); + } + catch + { + return defaultValue; + } + } + + /// 清理整数字符串,去掉常见分隔符,替换全角数字为半角数字 + /// + /// + /// + private static Int32 TrimNumber(ReadOnlySpan input, Span output) + { + var idx = 0; + + for (var i = 0; i < input.Length; i++) + { + // 去掉逗号分隔符 + var ch = input[i]; + if (ch == ',' || ch == '_' || ch == ' ') continue; + // 支持前缀正号。Redis响应中就会返回带正号的整数 + if (ch == '+') + { + if (idx == 0) continue; + return 0; + } + // 支持负数 + if (ch == '-' && idx > 0) return 0; + + // 全角空格 + if (ch == 0x3000) + ch = (Char)0x20; + else if (ch is > (Char)0xFF00 and < (Char)0xFF5F) + ch = (Char)(input[i] - 0xFEE0); + + // 数字和小数点 以外字符,认为非数字 + if (ch is not '.' and not '-' and (< '0' or > '9')) return 0; + + output[idx++] = ch; + } + + return idx; + } + + /// 去掉时间日期指定位置后面部分,可指定毫秒ms、秒s、分m、小时h + /// 时间日期 + /// 格式字符串,默认s格式化到秒,ms格式化到毫秒 + /// + public virtual DateTime Trim(DateTime value, String format) + { + return format switch + { +#if NET8_0_OR_GREATER + "us" => new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, value.Millisecond, value.Microsecond, value.Kind), +#endif + "ms" => new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, value.Millisecond, value.Kind), + "s" => new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, value.Kind), + "m" => new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, 0, value.Kind), + "h" => new DateTime(value.Year, value.Month, value.Day, value.Hour, 0, 0, value.Kind), + _ => value, + }; + } + + /// 时间日期转为yyyy-MM-dd HH:mm:ss完整字符串 + /// 待转换对象 + /// 是否使用毫秒 + /// 字符串空值时显示的字符串,null表示原样显示最小时间,String.Empty表示不显示 + /// + public virtual String ToFullString(DateTime value, Boolean useMillisecond, String? emptyValue = null) + { + if (emptyValue != null && value <= DateTime.MinValue) return emptyValue; + + //return value.ToString("yyyy-MM-dd HH:mm:ss"); + + //var dt = value; + //var sb = new StringBuilder(); + //sb.Append(dt.Year.ToString().PadLeft(4, '0')); + //sb.Append("-"); + //sb.Append(dt.Month.ToString().PadLeft(2, '0')); + //sb.Append("-"); + //sb.Append(dt.Day.ToString().PadLeft(2, '0')); + //sb.Append(" "); + + //sb.Append(dt.Hour.ToString().PadLeft(2, '0')); + //sb.Append(":"); + //sb.Append(dt.Minute.ToString().PadLeft(2, '0')); + //sb.Append(":"); + //sb.Append(dt.Second.ToString().PadLeft(2, '0')); + + //return sb.ToString(); + + var cs = useMillisecond ? + "yyyy-MM-dd HH:mm:ss.fff".ToCharArray() : + "yyyy-MM-dd HH:mm:ss".ToCharArray(); + + var k = 0; + var y = value.Year; + cs[k++] = (Char)('0' + (y / 1000)); + y %= 1000; + cs[k++] = (Char)('0' + (y / 100)); + y %= 100; + cs[k++] = (Char)('0' + (y / 10)); + y %= 10; + cs[k++] = (Char)('0' + y); + k++; + + var m = value.Month; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + + m = value.Day; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + + m = value.Hour; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + + m = value.Minute; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + + m = value.Second; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + + if (useMillisecond) + { + k++; + m = value.Millisecond; + cs[k++] = (Char)('0' + (m / 100)); + cs[k++] = (Char)('0' + (m % 100 / 10)); + cs[k++] = (Char)('0' + (m % 10)); + } + + var str = new String(cs); + + // 此格式不受其它工具识别只存不包含时区的格式 + // 取出后,业务上存的是utc取出来再当utc即可 + //if (value.Kind == DateTimeKind.Utc) str += " UTC"; + + return str; + } + + /// 时间日期转为yyyy-MM-dd HH:mm:ss完整字符串 + /// 待转换对象 + /// 是否使用毫秒 + /// 字符串空值时显示的字符串,null表示原样显示最小时间,String.Empty表示不显示 + /// + public virtual String ToFullString(DateTimeOffset value, Boolean useMillisecond, String? emptyValue = null) + { + if (emptyValue != null && value <= DateTimeOffset.MinValue) return emptyValue; + + //var cs = "yyyy-MM-dd HH:mm:ss +08:00".ToCharArray(); + var cs = useMillisecond ? + "yyyy-MM-dd HH:mm:ss.fff +08:00".ToCharArray() : + "yyyy-MM-dd HH:mm:ss +08:00".ToCharArray(); + + var k = 0; + var y = value.Year; + cs[k++] = (Char)('0' + (y / 1000)); + y %= 1000; + cs[k++] = (Char)('0' + (y / 100)); + y %= 100; + cs[k++] = (Char)('0' + (y / 10)); + y %= 10; + cs[k++] = (Char)('0' + y); + k++; + + var m = value.Month; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + + m = value.Day; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + + m = value.Hour; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + + m = value.Minute; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + + m = value.Second; + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + + if (useMillisecond) + { + m = value.Millisecond; + cs[k++] = (Char)('0' + (m / 100)); + cs[k++] = (Char)('0' + (m % 100 / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + } + + // 时区 + var offset = value.Offset; + cs[k++] = offset.TotalSeconds >= 0 ? '+' : '-'; + m = Math.Abs(offset.Hours); + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + k++; + m = Math.Abs(offset.Minutes); + cs[k++] = (Char)('0' + (m / 10)); + cs[k++] = (Char)('0' + (m % 10)); + + return new String(cs); + } + + /// 时间日期转为指定格式字符串 + /// 待转换对象 + /// 格式化字符串 + /// 字符串空值时显示的字符串,null表示原样显示最小时间,String.Empty表示不显示 + /// + public virtual String ToString(DateTime value, String format, String emptyValue) + { + if (emptyValue != null && value <= DateTime.MinValue) return emptyValue; + + //return value.ToString(format ?? "yyyy-MM-dd HH:mm:ss"); + + return format.IsNullOrEmpty() || format == "yyyy-MM-dd HH:mm:ss" ? ToFullString(value, false, emptyValue) : value.ToString(format); + } + + /// 获取内部真实异常 + /// + /// + public virtual Exception GetTrue(Exception ex) + { + return ex is AggregateException agg && agg.InnerException != null + ? GetTrue(agg.InnerException) + : ex is TargetInvocationException tie && tie.InnerException != null + ? GetTrue(tie.InnerException) + : ex is TypeInitializationException te && te.InnerException != null + ? GetTrue(te.InnerException) + : ex.GetBaseException() + ?? ex; + } + + /// 获取异常消息 + /// 异常 + /// + public virtual String GetMessage(Exception ex) + { + var msg = ex + ""; + if (msg.IsNullOrEmpty()) return ex.Message; + + var ss = msg.Split(Environment.NewLine); + var ns = ss.Where(e => + !e.StartsWith("---") && + !e.Contains("System.Runtime.ExceptionServices") && + !e.Contains("System.Runtime.CompilerServices")); + + msg = ns.Join(Environment.NewLine); + + return msg; + } + + /// 字节单位字符串 + /// 数值 + /// 格式化字符串 + /// + public virtual String ToGMK(UInt64 value, String? format = null) + { + if (value < 1024) return $"{value:n0}"; + + if (format.IsNullOrEmpty()) format = "n2"; + + var val = value / 1024d; + if (val < 1024) return val.ToString(format) + "K"; + + val /= 1024; + if (val < 1024) return val.ToString(format) + "M"; + + val /= 1024; + if (val < 1024) return val.ToString(format) + "G"; + + val /= 1024; + if (val < 1024) return val.ToString(format) + "T"; + + val /= 1024; + if (val < 1024) return val.ToString(format) + "P"; + + val /= 1024; + return val.ToString(format) + "E"; + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/DisposeBase.cs b/src/Admin/ThingsGateway.NewLife.X/Common/DisposeBase.cs new file mode 100644 index 000000000..addf50bf1 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/DisposeBase.cs @@ -0,0 +1,159 @@ +using System.Collections; +using System.ComponentModel; +using System.Runtime.Serialization; +using System.Xml.Serialization; + +#nullable enable +namespace ThingsGateway.NewLife; + +/// 具有是否已释放和释放后事件的接口 +public interface IDisposable2 : IDisposable +{ + /// 是否已经释放 + [XmlIgnore, IgnoreDataMember] + Boolean Disposed { get; } + + /// 被销毁时触发事件 + event EventHandler OnDisposed; +} + +/// 具有销毁资源处理的抽象基类 +/// +/// 文档 https://newlifex.com/core/disposebase +/// +/// +/// +/// /// <summary>子类重载实现资源释放逻辑时必须首先调用基类方法</summary> +/// /// <param name="disposing">从Dispose调用(释放所有资源)还是析构函数调用(释放非托管资源)。 +/// /// 因为该方法只会被调用一次,所以该参数的意义不太大。</param> +/// protected override void Dispose(bool disposing) +/// { +/// base.OnDispose(disposing); +/// +/// if (disposing) +/// { +/// // 如果是析构函数进来,不执行这里的代码 +/// } +/// } +/// +/// +public abstract class DisposeBase : IDisposable2 +{ + #region 释放资源 + /// 释放资源 + public void Dispose() + { + Dispose(true); + + // 告诉GC,不要调用析构函数 + GC.SuppressFinalize(this); + } + + [NonSerialized] + private Int32 _disposed = 0; + /// 是否已经释放 + [XmlIgnore, IgnoreDataMember] + public Boolean Disposed => _disposed > 0; + + /// 被销毁时触发事件 + [field: NonSerialized] + public event EventHandler? OnDisposed; + + /// 释放资源,参数表示是否由Dispose调用。重载时先调用基类方法 + /// + protected virtual void Dispose(Boolean disposing) + { + if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) return; + + if (disposing) + { + // 释放托管资源 + //OnDispose(disposing); + + //// 告诉GC,不要调用析构函数 + //GC.SuppressFinalize(this); + } + + // 释放非托管资源 + + OnDisposed?.Invoke(this, EventArgs.Empty); + } + + ///// 释放资源,参数表示是否由Dispose调用。该方法保证OnDispose只被调用一次! + ///// + //[Obsolete("=>Dispose")] + //protected virtual void OnDispose(Boolean disposing) { } + + /// 析构函数 + /// + /// 如果忘记调用Dispose,这里会释放非托管资源。 + /// 如果曾经调用过Dispose,因为GC.SuppressFinalize(this),不会再调用该析构函数。 + /// 在 .NET 中,析构函数(Finalizer)不应该抛出未捕获的异常。如果析构函数引发未捕获的异常,它将导致应用程序崩溃或进程退出。 + /// + ~DisposeBase() + { + // 在 .NET 中,析构函数(Finalizer)不应该抛出未捕获的异常。如果析构函数引发未捕获的异常,它将导致应用程序崩溃或进程退出。 + try + { + Dispose(false); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + } + #endregion +} + +/// 销毁助手。扩展方法专用 +[EditorBrowsable(EditorBrowsableState.Advanced)] +public static class DisposeHelper +{ + /// 尝试销毁对象,如果有则调用 + /// + /// + public static Object? TryDispose(this Object? obj) + { + if (obj == null) return obj; + + // 列表元素销毁 + if (obj is IEnumerable ems) + { + // 对于枚举成员,先考虑添加到列表,再逐个销毁,避免销毁过程中集合改变 + if (obj is not IList list) + { + list = new List(); + foreach (var item in ems) + { + if (item is IDisposable) list.Add(item); + } + } + foreach (var item in list) + { + if (item is IDisposable disp) + { + try + { + //(item as IDisposable).TryDispose(); + // 只需要释放一层,不需要递归 + // 因为一般每一个对象负责自己内部成员的释放 + disp.Dispose(); + } + catch { } + } + } + } + // 对象销毁 + if (obj is IDisposable disp2) + { + try + { + disp2.Dispose(); + } + catch { } + } + + return obj; + } +} +#nullable restore \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/FileUtil.cs b/src/Admin/ThingsGateway.NewLife.X/Common/FileUtil.cs new file mode 100644 index 000000000..742e85502 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/FileUtil.cs @@ -0,0 +1,46 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Text; + +namespace ThingsGateway.NewLife; + +/// +/// FileUtil +/// +public class FileUtil +{ + /// + /// 读取文件 + /// + public static string ReadFile(string Path, Encoding? encoding = default) + { + encoding ??= Encoding.UTF8; + if (!File.Exists(Path)) + { + return null; + } + + StreamReader streamReader = new StreamReader(Path, encoding); + string result = streamReader.ReadToEnd(); + streamReader.Close(); + streamReader.Dispose(); + return result; + } + + public static void DeleteFile(string file) + { + if (File.Exists(file)) + { + File.SetAttributes(file, FileAttributes.Normal); + File.Delete(file); + } + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/Gen2GcCallback.cs b/src/Admin/ThingsGateway.NewLife.X/Common/Gen2GcCallback.cs new file mode 100644 index 000000000..1015bbc5f --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/Gen2GcCallback.cs @@ -0,0 +1,72 @@ +using System.ComponentModel; +using System.Runtime.ConstrainedExecution; +using System.Runtime.InteropServices; + +namespace ThingsGateway.NewLife; + +/// Gen2垃圾回收回调 +[EditorBrowsable(EditorBrowsableState.Never)] +public class Gen2GcCallback : CriticalFinalizerObject +{ + private readonly Func? _callback0; + + private readonly Func? _callback1; + + private GCHandle _weakTargetObj; + + private Gen2GcCallback(Func callback) => _callback0 = callback; + + private Gen2GcCallback(Func callback, Object targetObj) + { + _callback1 = callback; + _weakTargetObj = GCHandle.Alloc(targetObj, GCHandleType.Weak); + } + + /// + /// Registers a callback to be invoked during Gen2 garbage collection. + /// + /// The callback function to be invoked. + public static void Register(Func callback) => _ = new Gen2GcCallback(callback); + + /// + /// Registers a callback to be invoked during Gen2 garbage collection with a target object. + /// + /// The callback function to be invoked. + /// The target object associated with the callback. + public static void Register(Func callback, Object targetObj) => _ = new Gen2GcCallback(callback, targetObj); + + /// 析构 + ~Gen2GcCallback() + { + if (_weakTargetObj.IsAllocated) + { + var target = _weakTargetObj.Target; + if (target == null) + { + _weakTargetObj.Free(); + return; + } + try + { + if (_callback1 != null && !_callback1(target)) + { + _weakTargetObj.Free(); + return; + } + } + catch { } + } + else + { + try + { + if (_callback0 != null && !_callback0()) + { + return; + } + } + catch { } + } + GC.ReRegisterForFinalize(this); + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/Index.cs b/src/Admin/ThingsGateway.NewLife.X/Common/Index.cs new file mode 100644 index 000000000..ec35f745d --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/Index.cs @@ -0,0 +1,111 @@ +#if NETFRAMEWORK || NETSTANDARD2_0 +using System.Runtime.CompilerServices; + +namespace System; + +/// +public readonly struct Index : IEquatable +{ + /// + private readonly Int32 _value; + + /// + public static Index Start => new(0); + + /// + public static Index End => new(-1); + + /// + public Int32 Value => _value < 0 ? ~_value : _value; + + /// + public Boolean IsFromEnd => _value < 0; + + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Index(Int32 value, Boolean fromEnd = false) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException("value", "value must be non-negative"); + } + _value = fromEnd ? ~value : value; + } + + private Index(Int32 value) => _value = value; + + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Index FromStart(Int32 value) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException("value", "value must be non-negative"); + } + return new Index(value); + } + + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Index FromEnd(Int32 value) + { + if (value < 0) + { + throw new ArgumentOutOfRangeException("value", "value must be non-negative"); + } + return new Index(~value); + } + + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Int32 GetOffset(Int32 length) + { + var offset = _value; + if (IsFromEnd) + { + offset += length + 1; + } + return offset; + } + + /// + /// + /// + public override Boolean Equals(Object value) => value is Index index && _value == index._value; + + /// + /// + /// + public Boolean Equals(Index other) => _value == other._value; + + /// + /// + public override Int32 GetHashCode() => _value; + + /// + /// + public static implicit operator Index(Int32 value) => FromStart(value); + + /// + /// + public override String ToString() + { + if (IsFromEnd) + { + return "^" + (UInt32)Value; + } + return ((UInt32)Value).ToString(); + } +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/MachineInfo.cs b/src/Admin/ThingsGateway.NewLife.X/Common/MachineInfo.cs new file mode 100644 index 000000000..b1c47572b --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/MachineInfo.cs @@ -0,0 +1,1193 @@ +using Newtonsoft.Json.Linq; + +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Net.NetworkInformation; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security; + +using ThingsGateway.NewLife.Collections; +using ThingsGateway.NewLife.Json.Extension; +using ThingsGateway.NewLife.Log; +using ThingsGateway.NewLife.Reflection; +using ThingsGateway.NewLife.Windows; + +#if NETFRAMEWORK +using System.Management; + +using Microsoft.VisualBasic.Devices; +#endif +#if NETFRAMEWORK || NET6_0_OR_GREATER +using Microsoft.Win32; +#endif + +namespace ThingsGateway.NewLife; + +/// 机器信息接口 +/// 用于扩展MachineInfo功能,具体应用自定义各字段获取方式 +public interface IMachineInfo +{ + /// 初始化静态数据 + void Init(MachineInfo info); + + /// 刷新动态数据 + void Refresh(MachineInfo info); +} + +/// 机器信息 +/// +/// 文档 https://newlifex.com/core/machine_info +/// +/// 刷新信息成本较高,建议采用单例模式 +/// +public class MachineInfo +{ + #region 属性 + /// 系统名称 + [DisplayName("系统名称")] + public String? OSName { get; set; } + + /// 系统版本 + [DisplayName("系统版本")] + public String? OSVersion { get; set; } + + /// 产品名称 + [DisplayName("产品名称")] + public String? Product { get; set; } + + /// 制造商 + [DisplayName("制造商")] + public String? Vendor { get; set; } + + /// 处理器型号 + [DisplayName("处理器型号")] + public String? Processor { get; set; } + + ///// 处理器序列号。PC处理器序列号绝大部分重复,实际存储处理器的其它信息 + //public String CpuID { get; set; } + + /// 硬件唯一标识。取主板编码,部分品牌存在重复 + [DisplayName("硬件唯一标识")] + public String? UUID { get; set; } + + /// 软件唯一标识。系统标识,操作系统重装后更新,Linux系统的machine_id,Android的android_id,Ghost系统存在重复 + [DisplayName("软件唯一标识")] + public String? Guid { get; set; } + + /// 计算机序列号。适用于品牌机,跟笔记本标签显示一致 + [DisplayName("计算机序列号")] + public String? Serial { get; set; } + + /// 主板。序列号或家族信息 + [DisplayName("主板")] + public String? Board { get; set; } + + /// 磁盘序列号 + [DisplayName("磁盘序列号")] + public String? DiskID { get; set; } + + /// 内存总量。单位Byte + [DisplayName("内存总量")] + public UInt64 Memory { get; set; } + + /// 可用内存。单位Byte + [DisplayName("可用内存")] + public UInt64 AvailableMemory { get; set; } + + /// CPU占用率 + [DisplayName("CPU占用率")] + public Double CpuRate { get; set; } + + /// 网络上行速度。字节每秒,初始化后首次读取为0 + [DisplayName("网络上行速度")] + public UInt64 UplinkSpeed { get; set; } + + /// 网络下行速度。字节每秒,初始化后首次读取为0 + [DisplayName("网络下行速度")] + public UInt64 DownlinkSpeed { get; set; } + + /// 温度。单位度 + [DisplayName("温度")] + public Double Temperature { get; set; } + + /// 电池剩余。小于1的小数,常用百分比表示 + [DisplayName("电池剩余")] + public Double Battery { get; set; } + + #endregion + + #region 全局静态 + /// 当前机器信息。默认null,在RegisterAsync后才能使用 + public static MachineInfo? Current { get; set; } + + /// 机器信息提供者。外部实现可修改部分行为 + public static IMachineInfo? Provider { get; set; } + + //static MachineInfo() => RegisterAsync().Wait(100); + + private static Task? _task; + /// 异步注册一个初始化后的机器信息实例 + /// + public static Task RegisterAsync() + { + + if (_task != null) return _task; + + return _task = Task.Factory.StartNew(() => + { + // 文件缓存,加快机器信息获取。在Linux下,可能StarAgent以root权限写入缓存文件,其它应用以普通用户访问 + var file = Path.GetTempPath().CombinePath("machine_info.json"); + var json = ""; + if (Current == null) + { + var f = file; + if (File.Exists(f)) + { + try + { + //XTrace.WriteLine("Load MachineInfo {0}", f); + json = File.ReadAllText(f); + Current = json.FromJsonNetString(); + } + catch (Exception ex) + { + if (XTrace.Log.Level <= LogLevel.Debug) NewLife.Log.XTrace.WriteException(ex); + } + } + } + + var mi = Current ?? new MachineInfo(); + + mi.Init(); + Current = mi; + + try + { + var json2 = mi.ToJsonNetString(); + if (json != json2) + { + File.WriteAllText(file.EnsureDirectory(true), json2); + } + } + catch (Exception ex) + { + if (XTrace.Log.Level <= LogLevel.Debug) NewLife.Log.XTrace.WriteException(ex); + } + + return mi; + }); + + } + + /// 获取当前信息,如果未设置则等待异步注册结果 + /// + public static MachineInfo GetCurrent() => Current ?? RegisterAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + + #endregion + + #region 方法 + /// 初始化静态数据。可能是实例化后执行,也可能是Json反序列化后执行 + public void Init() + { + var osv = Environment.OSVersion; + if (OSVersion.IsNullOrEmpty()) OSVersion = osv.Version + ""; + if (OSName.IsNullOrEmpty()) OSName = (osv + "").TrimStart("Microsoft").TrimEnd(OSVersion).Trim(); + if (Guid.IsNullOrEmpty()) Guid = ""; + + try + { +#if NET6_0_OR_GREATER + if (OperatingSystem.IsWindows()) + LoadWindowsInfo(); + else if (OperatingSystem.IsLinux()) + LoadLinuxInfo(); + else if (OperatingSystem.IsMacOS()) + LoadMacInfo(); +#else + if (Runtime.Windows) + LoadWindowsInfo(); + else if (Runtime.Linux) + LoadLinuxInfo(); +#endif + + Provider?.Init(this); + } + catch (Exception ex) + { + if (XTrace.Log.Level <= LogLevel.Debug) NewLife.Log.XTrace.WriteException(ex); + } + + // 裁剪不可见字符,顺带去掉两头空白 + OSName = OSName.TrimInvisible()?.Trim(); + OSVersion = OSVersion.TrimInvisible()?.Trim(); + Product = Product.TrimInvisible()?.Trim(); + Processor = Processor.TrimInvisible()?.Trim(); + UUID = UUID.TrimInvisible()?.Trim(); + Guid = Guid.TrimInvisible()?.Trim(); + Serial = Serial.TrimInvisible()?.Trim(); + Board = Board.TrimInvisible()?.Trim(); + DiskID = DiskID.TrimInvisible()?.Trim(); + + // 无法读取系统标识时,随机生成一个guid,借助文件缓存确保其不变 + if (Guid.IsNullOrEmpty()) Guid = "0-" + System.Guid.NewGuid().ToString(); + if (UUID.IsNullOrEmpty()) UUID = "0-" + System.Guid.NewGuid().ToString(); + + try + { + Refresh(); + } + catch (Exception ex) + { + if (XTrace.Log.Level <= LogLevel.Debug) NewLife.Log.XTrace.WriteException(ex); + } + } + +#if NET6_0_OR_GREATER + [SupportedOSPlatform("windows")] +#endif + private void LoadWindowsInfo() + { + var str = ""; + + // 从注册表读取 MachineGuid +#if NETFRAMEWORK || NET6_0_OR_GREATER + using var Cryptography = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); + if (Cryptography != null) str = Cryptography.GetValue("MachineGuid") + ""; + if (str.IsNullOrEmpty()) + { + using var Registry64 = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using var Registry64Cryptography = Registry64?.OpenSubKey(@"SOFTWARE\Microsoft\Cryptography"); + if (Registry64Cryptography != null) str = Registry64Cryptography.GetValue("MachineGuid") + ""; + } + + if (!str.IsNullOrEmpty()) Guid = str; + + using var HardwareConfig = Registry.LocalMachine.OpenSubKey(@"SYSTEM\HardwareConfig"); + if (HardwareConfig != null) + { + str = (HardwareConfig.GetValue("LastConfig") + "")?.Trim('{', '}').ToUpper(); + + // UUID取不到时返回 FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF + if (!str.IsNullOrEmpty() && !str.EqualIgnoreCase("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF")) UUID = str; + } + + using var BIOS = Registry.LocalMachine.OpenSubKey(@"HARDWARE\DESCRIPTION\System\BIOS") ?? Registry.LocalMachine.OpenSubKey(@"SYSTEM\HardwareConfig\Current"); + + if (BIOS != null) + { + Product = (BIOS.GetValue("SystemProductName") + "").Replace("System Product Name", null); + if (Product.IsNullOrEmpty()) Product = BIOS.GetValue("BaseBoardProduct") + ""; + + Vendor = BIOS.GetValue("SystemManufacturer") + ""; + if (Vendor.IsNullOrEmpty()) Vendor = BIOS.GetValue("ASUSTeK COMPUTER INC.") + ""; + } + + using var CentralProcessor = Registry.LocalMachine.OpenSubKey(@"HARDWARE\DESCRIPTION\System\CentralProcessor\0"); + if (CentralProcessor != null) Processor = CentralProcessor.GetValue("ProcessorNameString") + ""; + + // 旧版系统(如win2008)没有UUID的注册表项,需要用wmic查询。也可能因为过去的某个BUG,导致GUID跟UUID相等 + if (UUID.IsNullOrEmpty() || UUID == Guid || Vendor.IsNullOrEmpty()) + { + var csproduct = ReadWmic("csproduct", "Name", "UUID", "Vendor"); + if (csproduct != null) + { + if (csproduct.TryGetValue("Name", out str) && !str.IsNullOrEmpty() && Product.IsNullOrEmpty()) Product = str; + if (csproduct.TryGetValue("UUID", out str) && !str.IsNullOrEmpty()) UUID = str; + if (csproduct.TryGetValue("Vendor", out str) && !str.IsNullOrEmpty()) Vendor = str; + } + } +#else + str = "reg".Execute(@"query HKLM\SOFTWARE\Microsoft\Cryptography /v MachineGuid", 0, false); + if (!str.IsNullOrEmpty() && str.Contains("REG_SZ")) Guid = str.Substring("REG_SZ", null).Trim(); + + var csproduct = ReadWmic("csproduct", "Name", "UUID", "Vendor"); + if (csproduct != null) + { + if (csproduct.TryGetValue("Name", out str)) Product = str; + if (csproduct.TryGetValue("UUID", out str)) UUID = str; + if (csproduct.TryGetValue("Vendor", out str)) Vendor = str; + } +#endif + // 获取内存大小 +#if NETFRAMEWORK || WINDOWS + { + var ci = new Microsoft.VisualBasic.Devices.ComputerInfo(); + Memory = ci.TotalPhysicalMemory; + } +#endif + + // 获取操作系统名称和版本 +#if NETFRAMEWORK + try + { + var ci = new Microsoft.VisualBasic.Devices.ComputerInfo(); + + // 系统名取WMI可能出错 + OSName = ci.OSFullName?.Replace("®", null).TrimStart("Microsoft").Trim(); + OSVersion = ci.OSVersion; + } + catch + { + try + { + var reg2 = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows NT\CurrentVersion"); + if (reg2 != null) + { + OSName = reg2.GetValue("ProductName") + ""; + OSVersion = reg2.GetValue("ReleaseId") + ""; + } + } + catch (Exception ex) + { + if (XTrace.Log.Level <= LogLevel.Debug) NewLife.Log.XTrace.WriteException(ex); + } + } + //#elif NET6_0_OR_GREATER + // OSName = GetInfo("Win32_OperatingSystem", "Caption")?.TrimStart("Microsoft").Trim(); + // OSVersion = GetInfo("Win32_OperatingSystem", "Version"); +#else + var os = ReadWmic("os", "Caption", "Version"); + if (os == null || os.Count == 0) + { + os = ReadPowerShell("Get-WmiObject Win32_OperatingSystem | Select-Object Caption, Version | ConvertTo-Json"); + } + if (os is { Count: > 0 }) + { + if (os.TryGetValue("Caption", out str)) OSName = str.TrimStart("Microsoft").Trim(); + if (os.TryGetValue("Version", out str)) OSVersion = str; + } +#endif + +#if NETFRAMEWORK + //Processor = GetInfo("Win32_Processor", "Name"); + //CpuID = GetInfo("Win32_Processor", "ProcessorId"); + //var uuid = GetInfo("Win32_ComputerSystemProduct", "UUID"); + //Product = GetInfo("Win32_ComputerSystemProduct", "Name"); + DiskID = GetInfo("Win32_DiskDrive where mediatype=\"Fixed hard disk media\"", "SerialNumber"); + + var sn = GetInfo("Win32_BIOS", "SerialNumber"); + if (!sn.IsNullOrEmpty() && !sn.EqualIgnoreCase("System Serial Number")) Serial = sn; + Board = GetInfo("Win32_BaseBoard", "SerialNumber"); +#else + var disk = ReadWmic("diskdrive where mediatype=\"Fixed hard disk media\"", "serialnumber"); + if (disk != null) + { + if (disk.TryGetValue("serialnumber", out str)) DiskID = str?.Trim(); + } + + var sn = ReadWmic("bios", "serialnumber"); + if (sn != null) + { + if (sn.TryGetValue("serialnumber", out str) && !str.EqualIgnoreCase("System Serial Number")) Serial = str?.Trim(); + } + + var board = ReadWmic("baseboard", "serialnumber"); + if (board != null) + { + if (board.TryGetValue("serialnumber", out str)) Board = str?.Trim(); + } + + //// 不要在刷新里面取CPU负载,因为运行wmic会导致CPU负载很不准确,影响测量 + //var cpu = ReadWmic("cpu", "Name", "ProcessorId", "LoadPercentage"); + //if (cpu != null) + //{ + // if (cpu.TryGetValue("Name", out str)) Processor = str; + // //if (cpu.TryGetValue("ProcessorId", out str)) CpuID = str; + // if (cpu.TryGetValue("LoadPercentage", out str)) CpuRate = (Single)(str.ToDouble() / 100); + //} +#endif + +#if !NETFRAMEWORK + if (OSName.IsNullOrEmpty()) + OSName = RuntimeInformation.OSDescription.TrimStart("Microsoft").Trim(); + if (OSVersion.IsNullOrEmpty()) + OSVersion = Environment.OSVersion.Version.ToString(); +#endif + } + + private void LoadLinuxInfo() + { + var str = GetLinuxName(); + if (!str.IsNullOrEmpty()) OSName = str; + + var device = ReadDeviceInfo(); + + if (device.TryGetValue("Platform", out str)) + OSName = str; + if (device.TryGetValue("Version", out str)) + OSVersion = str; + + // 树莓派的Hardware无法区分P0/P4 + var dic = ReadInfo("/proc/cpuinfo"); + if (dic != null) + { + if (dic.TryGetValue("Hardware", out str) || + dic.TryGetValue("cpu model", out str) || + dic.TryGetValue("model name", out str)) + Processor = str?.TrimStart("vendor "); + + if (device.TryGetValue("Product", out str)) + Product = str; + else if (dic.TryGetValue("Model", out str)) + Product = str; + + if (dic.TryGetValue("vendor_id", out str)) + Vendor = str; + + //if (device.TryGetValue("Fingerprint", out str) && !str.IsNullOrEmpty()) + // CpuID = str; + if (dic.TryGetValue("Serial", out str) && !str.IsNullOrEmpty() && !str.Trim('0').IsNullOrEmpty()) + UUID = str; + } + + var mid = "/etc/machine-id"; + if (!File.Exists(mid)) mid = "/var/lib/dbus/machine-id"; + if (TryRead(mid, out var value)) + Guid = value; + else if (device.TryGetValue("android_id", out str) && !str.IsNullOrEmpty() && str != "unknown") + Guid = str; + //else if (android.TryGetValue("Id", out str)) + // Guid = str; + + // DMI信息位于 /sys/class/dmi/id/ 目录,可以直接读取,不需要执行dmidecode命令 + var uuid = ""; + var file = "/sys/class/dmi/id/product_uuid"; + if (!File.Exists(file)) file = "/etc/uuid"; + if (!File.Exists(file)) file = "/proc/serial_num"; // miui12支持/proc/serial_num + if (TryRead(file, out value)) + uuid = value; + else if (device.TryGetValue("Serial", out str) && str != "unknown") + uuid = str; + if (!uuid.IsNullOrEmpty()) UUID = uuid; + + // 从release文件读取产品 + var prd = GetProductByRelease(); + if (!prd.IsNullOrEmpty()) Product = prd; + + if (prd.IsNullOrEmpty() && TryRead("/sys/class/dmi/id/product_name", out var product_name)) + { + Product = product_name; + + // 增加制造商。如 Tencent Cloud,它的产品名只有 CVM。阿里云产品名 Alibaba Cloud ECS + if (TryRead("/sys/class/dmi/id/sys_vendor", out var vendor) && !vendor.IsNullOrEmpty()) + { + Vendor = vendor; + + if (!product_name.IsNullOrEmpty() && !product_name.Contains(vendor)) + { + // 红帽KVM太流行,细化处理 + if (product_name == "KVM" && vendor == "Red Hat" && + TryRead("/sys/class/dmi/id/product_version", out var ver) && !ver.IsNullOrEmpty()) + { + var p = ver.IndexOf('('); + if (p > 0) ver = ver[..p].Trim(); + Product = ver; + } + } + } + } + + file = "/sys/class/dmi/id/product_serial"; + if (TryRead(file, out value)) Serial = value; + + // 在DMI信息内,没有太好的BoardID取值 + file = "/sys/class/dmi/id/product_sku"; + if (TryRead(file, out value) && !value.IsNullOrEmpty()) + Board = value; + else + { + file = "/sys/class/dmi/id/product_family"; + if (TryRead(file, out value)) Board = value; + } + + var disks = GetFiles("/dev/disk/by-id", true); + if (disks.Count == 0) disks = GetFiles("/dev/disk/by-uuid", false); + if (disks.Count > 0) DiskID = disks.Where(e => !e.IsNullOrEmpty()).Join(","); + + // 从*-release文件读取产品信息,具有更高优先级 + file = "/etc/os-release"; + if (TryRead(file, out value)) + { + var dic2 = value.SplitAsDictionary("=", Environment.NewLine, true); + + if (dic2.TryGetValue("Vendor", out str)) Vendor = str; + if (dic2.TryGetValue("Product", out str)) Product = str; + if (dic2.TryGetValue("Serial", out str)) Serial = str; + if (dic2.TryGetValue("Board", out str)) Board = str; + } + } + + private void LoadMacInfo() + { + var dic = ReadCommand("sw_vers"); + if (dic != null) + { + if (dic.TryGetValue("ProductName", out var str)) OSName = str; + if (dic.TryGetValue("productVersion", out str)) OSVersion = str; + } + + dic = ReadCommand("system_profiler", "SPHardwareDataType"); + if (dic != null) + { + //if (dic2.TryGetValue("Model Name", out str)) Product = str; + if (dic.TryGetValue("Model Identifier", out var str)) Product = str; + if (dic.TryGetValue("Processor Name", out str)) Processor = str; + if (dic.TryGetValue("Memory", out str)) Memory = (UInt64)str.TrimEnd("GB").Trim().ToLong() * 1024 * 1024 * 1024; + if (dic.TryGetValue("Serial Number (system)", out str)) Serial = str; + if (dic.TryGetValue("Hardware UUID", out str)) UUID = str; + if (dic.TryGetValue("Processor Name", out str)) Processor = str; + } + + if (Vendor.IsNullOrEmpty()) Vendor = "Apple"; + + dic = ReadCommand("diskutil", "info disk1"); + if (dic != null) + { + if (dic.TryGetValue("Disk / Partition UUID", out var str)) DiskID = str; + } + } + + private readonly ICollection _excludes = []; + + /// 获取实时数据,如CPU、内存、温度 + public void Refresh() + { + if (Runtime.Windows) + RefreshWindows(); + // 特别识别Linux发行版 + else if (Runtime.Linux) + RefreshLinux(); + + RefreshSpeed(); + + Provider?.Refresh(this); + } + + private void RefreshWindows() + { + MEMORYSTATUSEX ms = default; + ms.Init(); + if (GlobalMemoryStatusEx(ref ms)) + { + Memory = ms.ullTotalPhys; + AvailableMemory = ms.ullAvailPhys; + } + + GetSystemTimes(out var idleTime, out var kernelTime, out var userTime); + + var current = new SystemTime + { + IdleTime = idleTime.ToLong(), + TotalTime = kernelTime.ToLong() + userTime.ToLong(), + }; + + var idle = current.IdleTime - (_systemTime?.IdleTime ?? 0); + var total = current.TotalTime - (_systemTime?.TotalTime ?? 0); + _systemTime = current; + + CpuRate = total == 0 ? 0 : Math.Round((Double)(total - idle) / total, 4); + + var power = new PowerStatus(); + +#if NETFRAMEWORK + if (!_excludes.Contains(nameof(Temperature))) + { + // 读取主板温度,不太准。标准方案是ring0通过IOPort读取CPU温度,太难在基础类库实现 + var str = GetInfo("Win32_TemperatureProbe", "CurrentReading"); + if (!str.IsNullOrEmpty()) + { + Temperature = str.SplitAsInt().Average(); + } + else + { + str = GetInfo("MSAcpi_ThermalZoneTemperature", "CurrentTemperature", "root/wmi"); + if (!str.IsNullOrEmpty()) + Temperature = (str.SplitAsInt().Average() - 2732) / 10.0; + else + { + if (XTrace.Log.Level <= LogLevel.Debug) XTrace.WriteLine("Temperature信息无法读取"); + _excludes.Add(nameof(Temperature)); + Temperature = 0; + } + } + } + + if (power.BatteryLifePercent > 0) + Battery = power.BatteryLifePercent; + else if (!_excludes.Contains(nameof(Battery))) + { + // 电池剩余 + var str = GetInfo("Win32_Battery", "EstimatedChargeRemaining"); + if (!str.IsNullOrEmpty()) + Battery = str.SplitAsInt().Average() / 100.0; + else + { + if (XTrace.Log.Level <= LogLevel.Debug) XTrace.WriteLine("Battery信息无法读取"); + _excludes.Add(nameof(Battery)); + Battery = 0; + } + } +#else + if (!_excludes.Contains(nameof(Temperature))) + { + var temp = ReadWmic(@"/namespace:\\root\wmi path MSAcpi_ThermalZoneTemperature", "CurrentTemperature"); + if (temp != null && temp.Count > 0) + { + if (temp.TryGetValue("CurrentTemperature", out var str) && !str.IsNullOrEmpty()) + Temperature = (str.SplitAsInt().Average() - 2732) / 10.0; + } + else + { + if (XTrace.Log.Level <= LogLevel.Debug) XTrace.WriteLine("Temperature信息无法读取"); + _excludes.Add(nameof(Temperature)); + Temperature = 0; + } + } + + if (power.BatteryLifePercent > 0) + Battery = power.BatteryLifePercent; + else if (!_excludes.Contains(nameof(Battery))) + { + var battery = ReadWmic("path win32_battery", "EstimatedChargeRemaining"); + if (battery != null && battery.Count > 0) + { + if (battery.TryGetValue("EstimatedChargeRemaining", out var str) && !str.IsNullOrEmpty()) + Battery = str.SplitAsInt().Average() / 100.0; + } + else + { + if (XTrace.Log.Level <= LogLevel.Debug) XTrace.WriteLine("Battery信息无法读取"); + _excludes.Add(nameof(Battery)); + Battery = 0; + } + } +#endif + } + + private void RefreshLinux() + { + var dic = ReadInfo("/proc/meminfo"); + if (dic != null) + { + if (dic.TryGetValue("MemTotal", out var str) && !str.IsNullOrEmpty()) + Memory = (UInt64)str.TrimEnd(" kB").ToInt() * 1024; + + if (dic.TryGetValue("MemAvailable", out str) && !str.IsNullOrEmpty()) + AvailableMemory = (UInt64)str.TrimEnd(" kB").ToInt() * 1024; + else if (dic.TryGetValue("MemFree", out str) && !str.IsNullOrEmpty()) + AvailableMemory = + (UInt64)(str.TrimEnd(" kB").ToInt() + + dic["Buffers"]?.TrimEnd(" kB").ToInt() ?? 0 + + dic["Cached"]?.TrimEnd(" kB").ToInt() ?? 0) * 1024; + } + + // A2/A4温度获取,Buildroot,CPU温度和主板温度 + if (TryRead("/sys/class/thermal/thermal_zone0/temp", out var value) || + TryRead("/sys/class/thermal/thermal_zone1/temp", out value)) + { + Temperature = value.ToDouble(); + // 有时候温度会超过1000,可能是毫度。机器温度不会低于0度 + if (Temperature > 1000) Temperature /= 1000; + } + // respberrypi + fedora + else if (TryRead("/sys/class/thermal/thermal_zone0/temp", out value) || + TryRead("/sys/class/hwmon/hwmon0/temp1_input", out value) || + TryRead("/sys/class/hwmon/hwmon0/temp2_input", out value) || + TryRead("/sys/class/hwmon/hwmon0/device/hwmon/hwmon0/temp2_input", out value) || + TryRead("/sys/devices/virtual/thermal/thermal_zone0/temp", out value)) + { + Temperature = value.ToDouble() / 1000; + } + // A2温度获取,Ubuntu 16.04 LTS, Linux 3.4.39 + else if (TryRead("/sys/class/hwmon/hwmon0/device/temp_value", out value)) + { + if (!value.IsNullOrEmpty()) Temperature = value.Substring(null, ":").ToDouble(); + } + + // 电池剩余 + if (TryRead("/sys/class/power_supply/BAT0/energy_now", out var energy_now) && + TryRead("/sys/class/power_supply/BAT0/energy_full", out var energy_full)) + { + Battery = energy_now.ToDouble() / energy_full.ToDouble(); + } + else if (TryRead("/sys/class/power_supply/battery/capacity", out var capacity)) + { + Battery = capacity.ToDouble() / 100.0; + } + else if (Runtime.Mono) + { + var battery = ReadDeviceBattery(); + if (battery.TryGetValue("ChargeLevel", out var obj)) Battery = obj.ToDouble(); + } + + var file = "/proc/stat"; + if (!_excludes.Contains(nameof(CpuRate)) && File.Exists(file)) + { + // CPU指标:user,nice, system, idle, iowait, irq, softirq + // cpu 57057 0 14420 1554816 0 443 0 0 0 0 + try + { + using var reader = new StreamReader(file); + var line = reader.ReadLine(); + if (!line.IsNullOrEmpty() && line.StartsWith("cpu")) + { + var vs = line.TrimStart("cpu").Trim().Split(' '); + var current = new SystemTime + { + IdleTime = vs[3].ToLong(), + TotalTime = vs.Take(7).Select(e => e.ToLong()).Sum().ToLong(), + }; + + var idle = current.IdleTime - (_systemTime?.IdleTime ?? 0); + var total = current.TotalTime - (_systemTime?.TotalTime ?? 0); + _systemTime = current; + + CpuRate = total == 0 ? 0 : Math.Round((Double)(total - idle) / total, 4); + } + } + catch + { + _excludes.Add(nameof(_excludes)); + } + } + } + + private Int64 _lastTime; + private Int64 _lastSent; + private Int64 _lastReceived; + /// 刷新网络速度 + public void RefreshSpeed() + { + var sent = 0L; + var received = 0L; + try + { + // 包含本地环回和隧道网卡 + // WSL获取网络列表时可能报错 + foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) + { + try + { + var st = ni.GetIPStatistics(); + sent += st.BytesSent; + received += st.BytesReceived; + } + catch { } + } + } + catch { } + + var now = Runtime.TickCount64; + if (_lastTime > 0) + { + var interval = now - _lastTime; + if (interval > 0) + { + var s1 = (sent - _lastSent) * 1000 / interval; + var s2 = (received - _lastReceived) * 1000 / interval; + if (s1 >= 0) UplinkSpeed = (UInt64)s1; + if (s2 >= 0) DownlinkSpeed = (UInt64)s2; + } + } + + _lastSent = sent; + _lastReceived = received; + _lastTime = now; + } + #endregion + + #region 辅助 + /// 获取Linux发行版名称 + /// + public static String? GetLinuxName() + { + var fr = "/etc/redhat-release"; + if (TryRead(fr, out var value)) return value; + + var dr = "/etc/debian-release"; + if (TryRead(dr, out value)) return value; + + var sr = "/etc/os-release"; + if (TryRead(sr, out value)) return value?.SplitAsDictionary("=", "\n", true)["PRETTY_NAME"].Trim(); + + var uname = "uname".Execute("-sr", 0, false)?.Trim(); + if (!uname.IsNullOrEmpty()) + { + // 支持Android系统名 + var ss = uname.Split('-'); + foreach (var item in ss) + { + if (!item.IsNullOrEmpty() && item.StartsWithIgnoreCase("Android")) return item; + } + + return uname; + } + + return null; + } + + private static String? GetProductByRelease() + { + var di = "/etc/".AsDirectory(); + if (!di.Exists) return null; + + foreach (var fi in di.GetFiles("*-release")) + { + if (!fi.Name.EqualIgnoreCase("redhat-release", "debian-release", "os-release", "system-release")) + { + var dic = File.ReadAllText(fi.FullName).SplitAsDictionary("=", "\n", true); + if (dic.TryGetValue("BOARD", out var str)) return str; + if (dic.TryGetValue("BOARD_NAME", out str)) return str; + } + } + + return null; + } + + private static Boolean TryRead(String fileName, [NotNullWhen(true)] out String? value) + { + value = null; + + if (!File.Exists(fileName)) return false; + + try + { + value = File.ReadAllText(fileName)?.Trim(); + if (value.IsNullOrEmpty()) return false; + } + catch { return false; } + + return true; + } + + /// 读取文件信息,分割为字典 + /// + /// + /// + public static IDictionary? ReadInfo(String file, Char separate = ':') + { + if (file.IsNullOrEmpty() || !File.Exists(file)) return null; + + var dic = new NullableDictionary(StringComparer.OrdinalIgnoreCase); + + using var reader = new StreamReader(file); + while (!reader.EndOfStream) + { + // 按行读取 + var line = reader.ReadLine(); + if (line != null) + { + // 分割 + var p = line.IndexOf(separate); + if (p > 0) + { + var key = line[..p].Trim(); + var value = line[(p + 1)..].Trim(); + dic[key] = value.TrimInvisible(); + } + } + } + + return dic; + } + + private static IDictionary? ReadCommand(String cmd, String? arguments = null) + { + var str = cmd.Execute(arguments, 0, false); + if (str.IsNullOrEmpty()) return null; + + return str.SplitAsDictionary(":", "\n", true); + } + + /// + /// 通过 PowerShell 命令读取信息 + /// + public static IDictionary ReadPowerShell(String command) + { + var dic = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var args = $"-Command \"{command}\""; + var str = "powershell.exe".Execute(args, 3_000) ?? String.Empty; + if (!String.IsNullOrWhiteSpace(str)) + { + foreach (var item in JObject.Parse(str)!) + { + dic[item.Key] = item.Value?.ToString() ?? String.Empty; + } + } + return dic; + } + + /// 通过WMIC命令读取信息 + /// + /// + /// + public static IDictionary ReadWmic(String type, params String[] keys) + { + var dic = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var dic2 = new Dictionary(StringComparer.OrdinalIgnoreCase); + + var args = $"{type} get {keys.Join(",")} /format:list"; + var str = "wmic".Execute(args, 0, false)?.Trim(); + if (str.IsNullOrEmpty()) return dic2; + + var ss = str.Split("\r\n"); + foreach (var item in ss) + { + var ks = item?.Split('='); + if (ks != null && ks.Length >= 2) + { + var k = ks[0].Trim(); + var v = ks[1].Trim().TrimInvisible(); + if (!k.IsNullOrEmpty() && !v.IsNullOrEmpty()) + { + if (!dic.TryGetValue(k, out var list)) + dic[k] = list = []; + + list.Add(v); + } + } + } + + // 排序,避免多个磁盘序列号时,顺序变动 + foreach (var item in dic) + { + dic2[item.Key] = item.Value.OrderBy(e => e).Join(); + } + + return dic2; + } + + /// + /// 获取设备信息。用于Xamarin + /// + /// + public static IDictionary ReadDeviceInfo() + { + var dic = new Dictionary(); + if (!Runtime.Mono) return dic; + + { + var type = "Android.OS.Build".GetTypeEx(); + if (type != null) + { + foreach (var item in type.GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + try + { + dic[item.Name] = item.GetValue(null) + ""; + } + catch { } + } + } + } + { + var type = "Xamarin.Essentials.DeviceInfo".GetTypeEx(); + if (type != null) + { + foreach (var item in type.GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + try + { + dic[item.Name] = item.GetValue(null) + ""; + } + catch { } + } + } + } + { + var type = "Android.Provider.Settings".GetTypeEx()?.GetNestedType("Secure"); + if (type != null) + { + var resolver = "Android.App.Application".GetTypeEx()?.GetValue("Context")?.GetValue("ContentResolver"); + if (resolver != null) + { + var name = "android_id"; + dic[name] = type.Invoke("GetString", resolver, name) as String; + } + } + } + + return dic; + } + + /// + /// 获取设备电量。用于 Xamarin + /// + /// + public static IDictionary ReadDeviceBattery() + { + var dic = new Dictionary(); + if (!Runtime.Mono) return dic; + + var type = "Xamarin.Essentials.Battery".GetTypeEx(); + if (type == null) return dic; + + foreach (var item in type.GetProperties(BindingFlags.Public | BindingFlags.Static)) + { + try + { + dic[item.Name] = item.GetValue(null); + } + catch { } + } + + return dic; + } + #endregion + + #region 内存 + [DllImport("Kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] + [SecurityCritical] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern Boolean GlobalMemoryStatusEx(ref MEMORYSTATUSEX lpBuffer); + + internal struct MEMORYSTATUSEX + { + internal UInt32 dwLength; + + internal UInt32 dwMemoryLoad; + + internal UInt64 ullTotalPhys; + + internal UInt64 ullAvailPhys; + + internal UInt64 ullTotalPageFile; + + internal UInt64 ullAvailPageFile; + + internal UInt64 ullTotalVirtual; + + internal UInt64 ullAvailVirtual; + + internal UInt64 ullAvailExtendedVirtual; + + internal void Init() => dwLength = checked((UInt32)Marshal.SizeOf(typeof(MEMORYSTATUSEX))); + } + #endregion + + #region 磁盘 + /// 获取指定目录所在盘可用空间,默认当前目录 + /// + /// 返回可用空间,字节,获取失败返回-1 + public static Int64 GetFreeSpace(String? path = null) + { + if (path.IsNullOrEmpty()) path = "."; + var root = Path.GetPathRoot(path.GetFullPath()); + if (root.IsNullOrEmpty()) return 0; + + var driveInfo = new DriveInfo(root); + if (driveInfo == null || !driveInfo.IsReady) return -1; + + try + { + return driveInfo.AvailableFreeSpace; + } + catch + { + return -1; + } + } + + /// 获取指定目录下文件名,支持去掉后缀的去重,主要用于Linux + /// + /// + /// + public static ICollection GetFiles(String path, Boolean trimSuffix = false) + { + var list = new List(); + if (path.IsNullOrEmpty()) return list; + + var di = path.AsDirectory(); + if (!di.Exists) return list; + + var list2 = di.GetFiles().Select(e => e.Name).ToList(); + foreach (var item in list2) + { + var line = item?.Trim(); + if (!line.IsNullOrEmpty()) + { + if (trimSuffix) + { + if (!list2.Any(e => e != line && line.StartsWith(e))) list.Add(line); + } + else + { + list.Add(line); + } + } + } + + return list; + } + #endregion + + #region Windows辅助 + [DllImport("kernel32.dll", SetLastError = true)] + private static extern Boolean GetSystemTimes(out FILETIME idleTime, out FILETIME kernelTime, out FILETIME userTime); + + private struct FILETIME + { + public UInt32 Low; + + public UInt32 High; + + public FILETIME(Int64 time) + { + Low = (UInt32)time; + High = (UInt32)(time >> 32); + } + + public Int64 ToLong() => (Int64)(((UInt64)High << 32) | Low); + } + + private sealed class SystemTime + { + public Int64 IdleTime; + public Int64 TotalTime; + } + + private SystemTime? _systemTime; + +#if NETFRAMEWORK + /// 获取WMI信息 + /// + /// + /// + /// + public static String GetInfo(String path, String property, String? nameSpace = null) + { + // Linux Mono不支持WMI + if (Runtime.Mono) return ""; + + var bbs = new List(); + try + { + var wql = $"Select {property} From {path}"; + var cimobject = new ManagementObjectSearcher(nameSpace, wql); + var moc = cimobject.Get(); + foreach (var mo in moc) + { + var val = mo?.Properties?[property]?.Value; + if (val != null) + { + var v = val.ToString().TrimInvisible()?.Trim(); + if (v != null) bbs.Add(v); + } + } + } + catch (Exception ex) + { + if (XTrace.Log.Level <= LogLevel.Debug) XTrace.WriteLine("WMI.GetInfo({0})失败!{1}", path, ex.Message); + return ""; + } + + bbs.Sort(); + + return bbs.Distinct().Join(); + } +#endif + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/PinYin.cs b/src/Admin/ThingsGateway.NewLife.X/Common/PinYin.cs new file mode 100644 index 000000000..2602c5d42 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/PinYin.cs @@ -0,0 +1,655 @@ +using System.Text; + +namespace ThingsGateway.NewLife.Common; + +/// 汉字拼音转换类 +/// +/// 文档 https://newlifex.com/core/pinyin +/// +public class PinYin +{ + #region 数组信息 + private static readonly Int32[] pyValue = new[] { + -20319, -20317, -20304, -20295, -20292, -20283, -20265, -20257, -20242, + -20230, -20051, -20036, -20032, -20026, -20002, -19990, -19986, -19982, + -19976, -19805, -19784, -19775, -19774, -19763, -19756, -19751, -19746, + -19741, -19739, -19728, -19725, -19715, -19540, -19531, -19525, -19515, + -19500, -19484, -19479, -19467, -19289, -19288, -19281, -19275, -19270, + -19263, -19261, -19249, -19243, -19242, -19238, -19235, -19227, -19224, + -19218, -19212, -19038, -19023, -19018, -19006, -19003, -18996, -18977, + -18961, -18952, -18783, -18774, -18773, -18763, -18756, -18741, -18735, + -18731, -18722, -18710, -18697, -18696, -18526, -18518, -18501, -18490, + -18478, -18463, -18448, -18447, -18446, -18239, -18237, -18231, -18220, + -18211, -18201, -18184, -18183, -18181, -18012, -17997, -17988, -17970, + -17964, -17961, -17950, -17947, -17931, -17928, -17922, -17759, -17752, + -17733, -17730, -17721, -17703, -17701, -17697, -17692, -17683, -17676, + -17496, -17487, -17482, -17468, -17454, -17433, -17427, -17417, -17202, + -17185, -16983, -16970, -16942, -16915, -16733, -16708, -16706, -16689, + -16664, -16657, -16647, -16474, -16470, -16465, -16459, -16452, -16448, + -16433, -16429, -16427, -16423, -16419, -16412, -16407, -16403, -16401, + -16393, -16220, -16216, -16212, -16205, -16202, -16187, -16180, -16171, + -16169, -16158, -16155, -15959, -15958, -15944, -15933, -15920, -15915, + -15903, -15889, -15878, -15707, -15701, -15681, -15667, -15661, -15659, + -15652, -15640, -15631, -15625, -15454, -15448, -15436, -15435, -15419, + -15416, -15408, -15394, -15385, -15377, -15375, -15369, -15363, -15362, + -15183, -15180, -15165, -15158, -15153, -15150, -15149, -15144, -15143, + -15141, -15140, -15139, -15128, -15121, -15119, -15117, -15110, -15109, + -14941, -14937, -14933, -14930, -14929, -14928, -14926, -14922, -14921, + -14914, -14908, -14902, -14894, -14889, -14882, -14873, -14871, -14857, + -14678, -14674, -14670, -14668, -14663, -14654, -14645, -14630, -14594, + -14429, -14407, -14399, -14384, -14379, -14368, -14355, -14353, -14345, + -14170, -14159, -14151, -14149, -14145, -14140, -14137, -14135, -14125, + -14123, -14122, -14112, -14109, -14099, -14097, -14094, -14092, -14090, + -14087, -14083, -13917, -13914, -13910, -13907, -13906, -13905, -13896, + -13894, -13878, -13870, -13859, -13847, -13831, -13658, -13611, -13601, + -13406, -13404, -13400, -13398, -13395, -13391, -13387, -13383, -13367, + -13359, -13356, -13343, -13340, -13329, -13326, -13318, -13147, -13138, + -13120, -13107, -13096, -13095, -13091, -13076, -13068, -13063, -13060, + -12888, -12875, -12871, -12860, -12858, -12852, -12849, -12838, -12831, + -12829, -12812, -12802, -12607, -12597, -12594, -12585, -12556, -12359, + -12346, -12320, -12300, -12120, -12099, -12089, -12074, -12067, -12058, + -12039, -11867, -11861, -11847, -11831, -11798, -11781, -11604, -11589, + -11536, -11358, -11340, -11339, -11324, -11303, -11097, -11077, -11067, + -11055, -11052, -11045, -11041, -11038, -11024, -11020, -11019, -11018, + -11014, -10838, -10832, -10815, -10800, -10790, -10780, -10764, -10587, + -10544, -10533, -10519, -10331, -10329, -10328, -10322, -10315, -10309, + -10307, -10296, -10281, -10274, -10270, -10262, -10260, -10256, -10254 + }; + + private static readonly String[] pyName = new[] { + "A", "Ai", "An", "Ang", "Ao", "Ba", "Bai", "Ban", "Bang", "Bao", "Bei", + "Ben", "Beng", "Bi", "Bian", "Biao", "Bie", "Bin", "Bing", "Bo", "Bu", + "Ba", "Cai", "Can", "Cang", "Cao", "Ce", "Ceng", "Cha", "Chai", "Chan", + "Chang", "Chao", "Che", "Chen", "Cheng", "Chi", "Chong", "Chou", "Chu", + "Chuai", "Chuan", "Chuang", "Chui", "Chun", "Chuo", "Ci", "Cong", "Cou", + "Cu", "Cuan", "Cui", "Cun", "Cuo", "Da", "Dai", "Dan", "Dang", "Dao", "De", + "Deng", "Di", "Dian", "Diao", "Die", "Ding", "Diu", "Dong", "Dou", "Du", + "Duan", "Dui", "Dun", "Duo", "E", "En", "Er", "Fa", "Fan", "Fang", "Fei", + "Fen", "Feng", "Fo", "Fou", "Fu", "Ga", "Gai", "Gan", "Gang", "Gao", "Ge", + "Gei", "Gen", "Geng", "Gong", "Gou", "Gu", "Gua", "Guai", "Guan", "Guang", + "Gui", "Gun", "Guo", "Ha", "Hai", "Han", "Hang", "Hao", "He", "Hei", "Hen", + "Heng", "Hong", "Hou", "Hu", "Hua", "Huai", "Huan", "Huang", "Hui", "Hun", + "Huo", "Ji", "Jia", "Jian", "Jiang", "Jiao", "Jie", "Jin", "Jing", "Jiong", + "Jiu", "Ju", "Juan", "Jue", "Jun", "Ka", "Kai", "Kan", "Kang", "Kao", "Ke", + "Ken", "Keng", "Kong", "Kou", "Ku", "Kua", "Kuai", "Kuan", "Kuang", "Kui", + "Kun", "Kuo", "La", "Lai", "Lan", "Lang", "Lao", "Le", "Lei", "Leng", "Li", + "Lia", "Lian", "Liang", "Liao", "Lie", "Lin", "Ling", "Liu", "Long", "Lou", + "Lu", "Lv", "Luan", "Lue", "Lun", "Luo", "Ma", "Mai", "Man", "Mang", "Mao", + "Me", "Mei", "Men", "Meng", "Mi", "Mian", "Miao", "Mie", "Min", "Ming", "Miu", + "Mo", "Mou", "Mu", "Na", "Nai", "Nan", "Nang", "Nao", "Ne", "Nei", "Nen", + "Neng", "Ni", "Nian", "Niang", "Niao", "Nie", "Nin", "Ning", "Niu", "Nong", + "Nu", "Nv", "Nuan", "Nue", "Nuo", "O", "Ou", "Pa", "Pai", "Pan", "Pang", + "Pao", "Pei", "Pen", "Peng", "Pi", "Pian", "Piao", "Pie", "Pin", "Ping", + "Po", "Pu", "Qi", "Qia", "Qian", "Qiang", "Qiao", "Qie", "Qin", "Qing", + "Qiong", "Qiu", "Qu", "Quan", "Que", "Qun", "Ran", "Rang", "Rao", "Re", + "Ren", "Reng", "Ri", "Rong", "Rou", "Ru", "Ruan", "Rui", "Run", "Ruo", + "Sa", "Sai", "San", "Sang", "Sao", "Se", "Sen", "Seng", "Sha", "Shai", + "Shan", "Shang", "Shao", "She", "Shen", "Sheng", "Shi", "Shou", "Shu", + "Shua", "Shuai", "Shuan", "Shuang", "Shui", "Shun", "Shuo", "Si", "Song", + "Sou", "Su", "Suan", "Sui", "Sun", "Suo", "Ta", "Tai", "Tan", "Tang", + "Tao", "Te", "Teng", "Ti", "Tian", "Tiao", "Tie", "Ting", "Tong", "Tou", + "Tu", "Tuan", "Tui", "Tun", "Tuo", "Wa", "Wai", "Wan", "Wang", "Wei", + "Wen", "Weng", "Wo", "Wu", "Xi", "Xia", "Xian", "Xiang", "Xiao", "Xie", + "Xin", "Xing", "Xiong", "Xiu", "Xu", "Xuan", "Xue", "Xun", "Ya", "Yan", + "Yang", "Yao", "Ye", "Yi", "Yin", "Ying", "Yo", "Yong", "You", "Yu", + "Yuan", "Yue", "Yun", "Za", "Zai", "Zan", "Zang", "Zao", "Ze", "Zei", + "Zen", "Zeng", "Zha", "Zhai", "Zhan", "Zhang", "Zhao", "Zhe", "Zhen", + "Zheng", "Zhi", "Zhong", "Zhou", "Zhu", "Zhua", "Zhuai", "Zhuan", + "Zhuang", "Zhui", "Zhun", "Zhuo", "Zi", "Zong", "Zou", "Zu", "Zuan", + "Zui", "Zun", "Zuo" + }; + #endregion + + #region 二级汉字 + /// 二级汉字数组 + private static readonly Char[] _py2 = new[] { + '亍','丌','兀','丐','廿','卅','丕','亘','丞','鬲','孬','噩','丨','禺','丿' + ,'匕','乇','夭','爻','卮','氐','囟','胤','馗','毓','睾','鼗','丶','亟','鼐','乜' + ,'乩','亓','芈','孛','啬','嘏','仄','厍','厝','厣','厥','厮','靥','赝','匚','叵' + ,'匦','匮','匾','赜','卦','卣','刂','刈','刎','刭','刳','刿','剀','剌','剞','剡' + ,'剜','蒯','剽','劂','劁','劐','劓','冂','罔','亻','仃','仉','仂','仨','仡','仫' + ,'仞','伛','仳','伢','佤','仵','伥','伧','伉','伫','佞','佧','攸','佚','佝' + ,'佟','佗','伲','伽','佶','佴','侑','侉','侃','侏','佾','佻','侪','佼','侬' + ,'侔','俦','俨','俪','俅','俚','俣','俜','俑','俟','俸','倩','偌','俳','倬','倏' + ,'倮','倭','俾','倜','倌','倥','倨','偾','偃','偕','偈','偎','偬','偻','傥','傧' + ,'傩','傺','僖','儆','僭','僬','僦','僮','儇','儋','仝','氽','佘','佥','俎','龠' + ,'汆','籴','兮','巽','黉','馘','冁','夔','勹','匍','訇','匐','凫','夙','兕','亠' + ,'兖','亳','衮','袤','亵','脔','裒','禀','嬴','蠃','羸','冫','冱','冽','冼' + ,'凇','冖','冢','冥','讠','讦','讧','讪','讴','讵','讷','诂','诃','诋','诏' + ,'诎','诒','诓','诔','诖','诘','诙','诜','诟','诠','诤','诨','诩','诮','诰','诳' + ,'诶','诹','诼','诿','谀','谂','谄','谇','谌','谏','谑','谒','谔','谕','谖','谙' + ,'谛','谘','谝','谟','谠','谡','谥','谧','谪','谫','谮','谯','谲','谳','谵','谶' + ,'卩','卺','阝','阢','阡','阱','阪','阽','阼','陂','陉','陔','陟','陧','陬','陲' + ,'陴','隈','隍','隗','隰','邗','邛','邝','邙','邬','邡','邴','邳','邶','邺' + ,'邸','邰','郏','郅','邾','郐','郄','郇','郓','郦','郢','郜','郗','郛','郫' + ,'郯','郾','鄄','鄢','鄞','鄣','鄱','鄯','鄹','酃','酆','刍','奂','劢','劬','劭' + ,'劾','哿','勐','勖','勰','叟','燮','矍','廴','凵','凼','鬯','厶','弁','畚','巯' + ,'坌','垩','垡','塾','墼','壅','壑','圩','圬','圪','圳','圹','圮','圯','坜','圻' + ,'坂','坩','垅','坫','垆','坼','坻','坨','坭','坶','坳','垭','垤','垌','垲','埏' + ,'垧','垴','垓','垠','埕','埘','埚','埙','埒','垸','埴','埯','埸','埤','埝' + ,'堋','堍','埽','埭','堀','堞','堙','塄','堠','塥','塬','墁','墉','墚','墀' + ,'馨','鼙','懿','艹','艽','艿','芏','芊','芨','芄','芎','芑','芗','芙','芫','芸' + ,'芾','芰','苈','苊','苣','芘','芷','芮','苋','苌','苁','芩','芴','芡','芪','芟' + ,'苄','苎','芤','苡','茉','苷','苤','茏','茇','苜','苴','苒','苘','茌','苻','苓' + ,'茑','茚','茆','茔','茕','苠','苕','茜','荑','荛','荜','茈','莒','茼','茴','茱' + ,'莛','荞','茯','荏','荇','荃','荟','荀','茗','荠','茭','茺','茳','荦','荥' + ,'荨','茛','荩','荬','荪','荭','荮','莰','荸','莳','莴','莠','莪','莓','莜' + ,'莅','荼','莶','莩','荽','莸','荻','莘','莞','莨','莺','莼','菁','萁','菥','菘' + ,'堇','萘','萋','菝','菽','菖','萜','萸','萑','萆','菔','菟','萏','萃','菸','菹' + ,'菪','菅','菀','萦','菰','菡','葜','葑','葚','葙','葳','蒇','蒈','葺','蒉','葸' + ,'萼','葆','葩','葶','蒌','蒎','萱','葭','蓁','蓍','蓐','蓦','蒽','蓓','蓊','蒿' + ,'蒺','蓠','蒡','蒹','蒴','蒗','蓥','蓣','蔌','甍','蔸','蓰','蔹','蔟','蔺' + ,'蕖','蔻','蓿','蓼','蕙','蕈','蕨','蕤','蕞','蕺','瞢','蕃','蕲','蕻','薤' + ,'薨','薇','薏','蕹','薮','薜','薅','薹','薷','薰','藓','藁','藜','藿','蘧','蘅' + ,'蘩','蘖','蘼','廾','弈','夼','奁','耷','奕','奚','奘','匏','尢','尥','尬','尴' + ,'扌','扪','抟','抻','拊','拚','拗','拮','挢','拶','挹','捋','捃','掭','揶','捱' + ,'捺','掎','掴','捭','掬','掊','捩','掮','掼','揲','揸','揠','揿','揄','揞','揎' + ,'摒','揆','掾','摅','摁','搋','搛','搠','搌','搦','搡','摞','撄','摭','撖' + ,'摺','撷','撸','撙','撺','擀','擐','擗','擤','擢','攉','攥','攮','弋','忒' + ,'甙','弑','卟','叱','叽','叩','叨','叻','吒','吖','吆','呋','呒','呓','呔','呖' + ,'呃','吡','呗','呙','吣','吲','咂','咔','呷','呱','呤','咚','咛','咄','呶','呦' + ,'咝','哐','咭','哂','咴','哒','咧','咦','哓','哔','呲','咣','哕','咻','咿','哌' + ,'哙','哚','哜','咩','咪','咤','哝','哏','哞','唛','哧','唠','哽','唔','哳','唢' + ,'唣','唏','唑','唧','唪','啧','喏','喵','啉','啭','啁','啕','唿','啐','唼' + ,'唷','啖','啵','啶','啷','唳','唰','啜','喋','嗒','喃','喱','喹','喈','喁' + ,'喟','啾','嗖','喑','啻','嗟','喽','喾','喔','喙','嗪','嗷','嗉','嘟','嗑','嗫' + ,'嗬','嗔','嗦','嗝','嗄','嗯','嗥','嗲','嗳','嗌','嗍','嗨','嗵','嗤','辔','嘞' + ,'嘈','嘌','嘁','嘤','嘣','嗾','嘀','嘧','嘭','噘','嘹','噗','嘬','噍','噢','噙' + ,'噜','噌','噔','嚆','噤','噱','噫','噻','噼','嚅','嚓','嚯','囔','囗','囝','囡' + ,'囵','囫','囹','囿','圄','圊','圉','圜','帏','帙','帔','帑','帱','帻','帼' + ,'帷','幄','幔','幛','幞','幡','岌','屺','岍','岐','岖','岈','岘','岙','岑' + ,'岚','岜','岵','岢','岽','岬','岫','岱','岣','峁','岷','峄','峒','峤','峋','峥' + ,'崂','崃','崧','崦','崮','崤','崞','崆','崛','嵘','崾','崴','崽','嵬','嵛','嵯' + ,'嵝','嵫','嵋','嵊','嵩','嵴','嶂','嶙','嶝','豳','嶷','巅','彳','彷','徂','徇' + ,'徉','後','徕','徙','徜','徨','徭','徵','徼','衢','彡','犭','犰','犴','犷','犸' + ,'狃','狁','狎','狍','狒','狨','狯','狩','狲','狴','狷','猁','狳','猃','狺' + ,'狻','猗','猓','猡','猊','猞','猝','猕','猢','猹','猥','猬','猸','猱','獐' + ,'獍','獗','獠','獬','獯','獾','舛','夥','飧','夤','夂','饣','饧','饨','饩','饪' + ,'饫','饬','饴','饷','饽','馀','馄','馇','馊','馍','馐','馑','馓','馔','馕','庀' + ,'庑','庋','庖','庥','庠','庹','庵','庾','庳','赓','廒','廑','廛','廨','廪','膺' + ,'忄','忉','忖','忏','怃','忮','怄','忡','忤','忾','怅','怆','忪','忭','忸','怙' + ,'怵','怦','怛','怏','怍','怩','怫','怊','怿','怡','恸','恹','恻','恺','恂' + ,'恪','恽','悖','悚','悭','悝','悃','悒','悌','悛','惬','悻','悱','惝','惘' + ,'惆','惚','悴','愠','愦','愕','愣','惴','愀','愎','愫','慊','慵','憬','憔','憧' + ,'憷','懔','懵','忝','隳','闩','闫','闱','闳','闵','闶','闼','闾','阃','阄','阆' + ,'阈','阊','阋','阌','阍','阏','阒','阕','阖','阗','阙','阚','丬','爿','戕','氵' + ,'汔','汜','汊','沣','沅','沐','沔','沌','汨','汩','汴','汶','沆','沩','泐','泔' + ,'沭','泷','泸','泱','泗','沲','泠','泖','泺','泫','泮','沱','泓','泯','泾' + ,'洹','洧','洌','浃','浈','洇','洄','洙','洎','洫','浍','洮','洵','洚','浏' + ,'浒','浔','洳','涑','浯','涞','涠','浞','涓','涔','浜','浠','浼','浣','渚','淇' + ,'淅','淞','渎','涿','淠','渑','淦','淝','淙','渖','涫','渌','涮','渫','湮','湎' + ,'湫','溲','湟','溆','湓','湔','渲','渥','湄','滟','溱','溘','滠','漭','滢','溥' + ,'溧','溽','溻','溷','滗','溴','滏','溏','滂','溟','潢','潆','潇','漤','漕','滹' + ,'漯','漶','潋','潴','漪','漉','漩','澉','澍','澌','潸','潲','潼','潺','濑' + ,'濉','澧','澹','澶','濂','濡','濮','濞','濠','濯','瀚','瀣','瀛','瀹','瀵' + ,'灏','灞','宀','宄','宕','宓','宥','宸','甯','骞','搴','寤','寮','褰','寰','蹇' + ,'謇','辶','迓','迕','迥','迮','迤','迩','迦','迳','迨','逅','逄','逋','逦','逑' + ,'逍','逖','逡','逵','逶','逭','逯','遄','遑','遒','遐','遨','遘','遢','遛','暹' + ,'遴','遽','邂','邈','邃','邋','彐','彗','彖','彘','尻','咫','屐','屙','孱','屣' + ,'屦','羼','弪','弩','弭','艴','弼','鬻','屮','妁','妃','妍','妩','妪','妣' + ,'妗','姊','妫','妞','妤','姒','妲','妯','姗','妾','娅','娆','姝','娈','姣' + ,'姘','姹','娌','娉','娲','娴','娑','娣','娓','婀','婧','婊','婕','娼','婢','婵' + ,'胬','媪','媛','婷','婺','媾','嫫','媲','嫒','嫔','媸','嫠','嫣','嫱','嫖','嫦' + ,'嫘','嫜','嬉','嬗','嬖','嬲','嬷','孀','尕','尜','孚','孥','孳','孑','孓','孢' + ,'驵','驷','驸','驺','驿','驽','骀','骁','骅','骈','骊','骐','骒','骓','骖','骘' + ,'骛','骜','骝','骟','骠','骢','骣','骥','骧','纟','纡','纣','纥','纨','纩' + ,'纭','纰','纾','绀','绁','绂','绉','绋','绌','绐','绔','绗','绛','绠','绡' + ,'绨','绫','绮','绯','绱','绲','缍','绶','绺','绻','绾','缁','缂','缃','缇','缈' + ,'缋','缌','缏','缑','缒','缗','缙','缜','缛','缟','缡','缢','缣','缤','缥','缦' + ,'缧','缪','缫','缬','缭','缯','缰','缱','缲','缳','缵','幺','畿','巛','甾','邕' + ,'玎','玑','玮','玢','玟','珏','珂','珑','玷','玳','珀','珉','珈','珥','珙','顼' + ,'琊','珩','珧','珞','玺','珲','琏','琪','瑛','琦','琥','琨','琰','琮','琬' + ,'琛','琚','瑁','瑜','瑗','瑕','瑙','瑷','瑭','瑾','璜','璎','璀','璁','璇' + ,'璋','璞','璨','璩','璐','璧','瓒','璺','韪','韫','韬','杌','杓','杞','杈','杩' + ,'枥','枇','杪','杳','枘','枧','杵','枨','枞','枭','枋','杷','杼','柰','栉','柘' + ,'栊','柩','枰','栌','柙','枵','柚','枳','柝','栀','柃','枸','柢','栎','柁','柽' + ,'栲','栳','桠','桡','桎','桢','桄','桤','梃','栝','桕','桦','桁','桧','桀','栾' + ,'桊','桉','栩','梵','梏','桴','桷','梓','桫','棂','楮','棼','椟','椠','棹' + ,'椤','棰','椋','椁','楗','棣','椐','楱','椹','楠','楂','楝','榄','楫','榀' + ,'榘','楸','椴','槌','榇','榈','槎','榉','楦','楣','楹','榛','榧','榻','榫','榭' + ,'槔','榱','槁','槊','槟','榕','槠','榍','槿','樯','槭','樗','樘','橥','槲','橄' + ,'樾','檠','橐','橛','樵','檎','橹','樽','樨','橘','橼','檑','檐','檩','檗','檫' + ,'猷','獒','殁','殂','殇','殄','殒','殓','殍','殚','殛','殡','殪','轫','轭','轱' + ,'轲','轳','轵','轶','轸','轷','轹','轺','轼','轾','辁','辂','辄','辇','辋' + ,'辍','辎','辏','辘','辚','軎','戋','戗','戛','戟','戢','戡','戥','戤','戬' + ,'臧','瓯','瓴','瓿','甏','甑','甓','攴','旮','旯','旰','昊','昙','杲','昃','昕' + ,'昀','炅','曷','昝','昴','昱','昶','昵','耆','晟','晔','晁','晏','晖','晡','晗' + ,'晷','暄','暌','暧','暝','暾','曛','曜','曦','曩','贲','贳','贶','贻','贽','赀' + ,'赅','赆','赈','赉','赇','赍','赕','赙','觇','觊','觋','觌','觎','觏','觐','觑' + ,'牮','犟','牝','牦','牯','牾','牿','犄','犋','犍','犏','犒','挈','挲','掰' + ,'搿','擘','耄','毪','毳','毽','毵','毹','氅','氇','氆','氍','氕','氘','氙' + ,'氚','氡','氩','氤','氪','氲','攵','敕','敫','牍','牒','牖','爰','虢','刖','肟' + ,'肜','肓','肼','朊','肽','肱','肫','肭','肴','肷','胧','胨','胩','胪','胛','胂' + ,'胄','胙','胍','胗','朐','胝','胫','胱','胴','胭','脍','脎','胲','胼','朕','脒' + ,'豚','脶','脞','脬','脘','脲','腈','腌','腓','腴','腙','腚','腱','腠','腩','腼' + ,'腽','腭','腧','塍','媵','膈','膂','膑','滕','膣','膪','臌','朦','臊','膻' + ,'臁','膦','欤','欷','欹','歃','歆','歙','飑','飒','飓','飕','飙','飚','殳' + ,'彀','毂','觳','斐','齑','斓','於','旆','旄','旃','旌','旎','旒','旖','炀','炜' + ,'炖','炝','炻','烀','炷','炫','炱','烨','烊','焐','焓','焖','焯','焱','煳','煜' + ,'煨','煅','煲','煊','煸','煺','熘','熳','熵','熨','熠','燠','燔','燧','燹','爝' + ,'爨','灬','焘','煦','熹','戾','戽','扃','扈','扉','礻','祀','祆','祉','祛','祜' + ,'祓','祚','祢','祗','祠','祯','祧','祺','禅','禊','禚','禧','禳','忑','忐' + ,'怼','恝','恚','恧','恁','恙','恣','悫','愆','愍','慝','憩','憝','懋','懑' + ,'戆','肀','聿','沓','泶','淼','矶','矸','砀','砉','砗','砘','砑','斫','砭','砜' + ,'砝','砹','砺','砻','砟','砼','砥','砬','砣','砩','硎','硭','硖','硗','砦','硐' + ,'硇','硌','硪','碛','碓','碚','碇','碜','碡','碣','碲','碹','碥','磔','磙','磉' + ,'磬','磲','礅','磴','礓','礤','礞','礴','龛','黹','黻','黼','盱','眄','眍','盹' + ,'眇','眈','眚','眢','眙','眭','眦','眵','眸','睐','睑','睇','睃','睚','睨' + ,'睢','睥','睿','瞍','睽','瞀','瞌','瞑','瞟','瞠','瞰','瞵','瞽','町','畀' + ,'畎','畋','畈','畛','畲','畹','疃','罘','罡','罟','詈','罨','罴','罱','罹','羁' + ,'罾','盍','盥','蠲','钅','钆','钇','钋','钊','钌','钍','钏','钐','钔','钗','钕' + ,'钚','钛','钜','钣','钤','钫','钪','钭','钬','钯','钰','钲','钴','钶','钷','钸' + ,'钹','钺','钼','钽','钿','铄','铈','铉','铊','铋','铌','铍','铎','铐','铑','铒' + ,'铕','铖','铗','铙','铘','铛','铞','铟','铠','铢','铤','铥','铧','铨','铪' + ,'铩','铫','铮','铯','铳','铴','铵','铷','铹','铼','铽','铿','锃','锂','锆' + ,'锇','锉','锊','锍','锎','锏','锒','锓','锔','锕','锖','锘','锛','锝','锞','锟' + ,'锢','锪','锫','锩','锬','锱','锲','锴','锶','锷','锸','锼','锾','锿','镂','锵' + ,'镄','镅','镆','镉','镌','镎','镏','镒','镓','镔','镖','镗','镘','镙','镛','镞' + ,'镟','镝','镡','镢','镤','镥','镦','镧','镨','镩','镪','镫','镬','镯','镱','镲' + ,'镳','锺','矧','矬','雉','秕','秭','秣','秫','稆','嵇','稃','稂','稞','稔' + ,'稹','稷','穑','黏','馥','穰','皈','皎','皓','皙','皤','瓞','瓠','甬','鸠' + ,'鸢','鸨','鸩','鸪','鸫','鸬','鸲','鸱','鸶','鸸','鸷','鸹','鸺','鸾','鹁','鹂' + ,'鹄','鹆','鹇','鹈','鹉','鹋','鹌','鹎','鹑','鹕','鹗','鹚','鹛','鹜','鹞','鹣' + ,'鹦','鹧','鹨','鹩','鹪','鹫','鹬','鹱','鹭','鹳','疒','疔','疖','疠','疝','疬' + ,'疣','疳','疴','疸','痄','疱','疰','痃','痂','痖','痍','痣','痨','痦','痤','痫' + ,'痧','瘃','痱','痼','痿','瘐','瘀','瘅','瘌','瘗','瘊','瘥','瘘','瘕','瘙' + ,'瘛','瘼','瘢','瘠','癀','瘭','瘰','瘿','瘵','癃','瘾','瘳','癍','癞','癔' + ,'癜','癖','癫','癯','翊','竦','穸','穹','窀','窆','窈','窕','窦','窠','窬','窨' + ,'窭','窳','衤','衩','衲','衽','衿','袂','袢','裆','袷','袼','裉','裢','裎','裣' + ,'裥','裱','褚','裼','裨','裾','裰','褡','褙','褓','褛','褊','褴','褫','褶','襁' + ,'襦','襻','疋','胥','皲','皴','矜','耒','耔','耖','耜','耠','耢','耥','耦','耧' + ,'耩','耨','耱','耋','耵','聃','聆','聍','聒','聩','聱','覃','顸','颀','颃' + ,'颉','颌','颍','颏','颔','颚','颛','颞','颟','颡','颢','颥','颦','虍','虔' + ,'虬','虮','虿','虺','虼','虻','蚨','蚍','蚋','蚬','蚝','蚧','蚣','蚪','蚓','蚩' + ,'蚶','蛄','蚵','蛎','蚰','蚺','蚱','蚯','蛉','蛏','蚴','蛩','蛱','蛲','蛭','蛳' + ,'蛐','蜓','蛞','蛴','蛟','蛘','蛑','蜃','蜇','蛸','蜈','蜊','蜍','蜉','蜣','蜻' + ,'蜞','蜥','蜮','蜚','蜾','蝈','蜴','蜱','蜩','蜷','蜿','螂','蜢','蝽','蝾','蝻' + ,'蝠','蝰','蝌','蝮','螋','蝓','蝣','蝼','蝤','蝙','蝥','螓','螯','螨','蟒' + ,'蟆','螈','螅','螭','螗','螃','螫','蟥','螬','螵','螳','蟋','蟓','螽','蟑' + ,'蟀','蟊','蟛','蟪','蟠','蟮','蠖','蠓','蟾','蠊','蠛','蠡','蠹','蠼','缶','罂' + ,'罄','罅','舐','竺','竽','笈','笃','笄','笕','笊','笫','笏','筇','笸','笪','笙' + ,'笮','笱','笠','笥','笤','笳','笾','笞','筘','筚','筅','筵','筌','筝','筠','筮' + ,'筻','筢','筲','筱','箐','箦','箧','箸','箬','箝','箨','箅','箪','箜','箢','箫' + ,'箴','篑','篁','篌','篝','篚','篥','篦','篪','簌','篾','篼','簏','簖','簋' + ,'簟','簪','簦','簸','籁','籀','臾','舁','舂','舄','臬','衄','舡','舢','舣' + ,'舭','舯','舨','舫','舸','舻','舳','舴','舾','艄','艉','艋','艏','艚','艟','艨' + ,'衾','袅','袈','裘','裟','襞','羝','羟','羧','羯','羰','羲','籼','敉','粑','粝' + ,'粜','粞','粢','粲','粼','粽','糁','糇','糌','糍','糈','糅','糗','糨','艮','暨' + ,'羿','翎','翕','翥','翡','翦','翩','翮','翳','糸','絷','綦','綮','繇','纛','麸' + ,'麴','赳','趄','趔','趑','趱','赧','赭','豇','豉','酊','酐','酎','酏','酤' + ,'酢','酡','酰','酩','酯','酽','酾','酲','酴','酹','醌','醅','醐','醍','醑' + ,'醢','醣','醪','醭','醮','醯','醵','醴','醺','豕','鹾','趸','跫','踅','蹙','蹩' + ,'趵','趿','趼','趺','跄','跖','跗','跚','跞','跎','跏','跛','跆','跬','跷','跸' + ,'跣','跹','跻','跤','踉','跽','踔','踝','踟','踬','踮','踣','踯','踺','蹀','踹' + ,'踵','踽','踱','蹉','蹁','蹂','蹑','蹒','蹊','蹰','蹶','蹼','蹯','蹴','躅','躏' + ,'躔','躐','躜','躞','豸','貂','貊','貅','貘','貔','斛','觖','觞','觚','觜' + ,'觥','觫','觯','訾','謦','靓','雩','雳','雯','霆','霁','霈','霏','霎','霪' + ,'霭','霰','霾','龀','龃','龅','龆','龇','龈','龉','龊','龌','黾','鼋','鼍','隹' + ,'隼','隽','雎','雒','瞿','雠','銎','銮','鋈','錾','鍪','鏊','鎏','鐾','鑫','鱿' + ,'鲂','鲅','鲆','鲇','鲈','稣','鲋','鲎','鲐','鲑','鲒','鲔','鲕','鲚','鲛','鲞' + ,'鲟','鲠','鲡','鲢','鲣','鲥','鲦','鲧','鲨','鲩','鲫','鲭','鲮','鲰','鲱','鲲' + ,'鲳','鲴','鲵','鲶','鲷','鲺','鲻','鲼','鲽','鳄','鳅','鳆','鳇','鳊','鳋' + ,'鳌','鳍','鳎','鳏','鳐','鳓','鳔','鳕','鳗','鳘','鳙','鳜','鳝','鳟','鳢' + ,'靼','鞅','鞑','鞒','鞔','鞯','鞫','鞣','鞲','鞴','骱','骰','骷','鹘','骶','骺' + ,'骼','髁','髀','髅','髂','髋','髌','髑','魅','魃','魇','魉','魈','魍','魑','飨' + ,'餍','餮','饕','饔','髟','髡','髦','髯','髫','髻','髭','髹','鬈','鬏','鬓','鬟' + ,'鬣','麽','麾','縻','麂','麇','麈','麋','麒','鏖','麝','麟','黛','黜','黝','黠' + ,'黟','黢','黩','黧','黥','黪','黯','鼢','鼬','鼯','鼹','鼷','鼽','鼾','齄' + }; + + /// 二级汉字对应拼音数组 + private static readonly String[] _pyValue2 = new[] { + "chu","ji","wu","gai","nian","sa","pi","gen","cheng","ge","nao","e","shu","yu","pie" + ,"bi","tuo","yao","yao","zhi","di","xin","yin","kui","yu","gao","tao","dian","ji","nai","nie" + ,"ji","qi","mi","bei","se","gu","ze","she","cuo","yan","jue","si","ye","yan","fang","po" + ,"gui","kui","bian","ze","gua","you","ce","yi","wen","jing","ku","gui","kai","la","ji","yan" + ,"wan","kuai","piao","jue","qiao","huo","yi","tong","wang","dan","ding","zhang","le","sa","yi","mu" + ,"ren","yu","pi","ya","wa","wu","chang","cang","kang","zhu","ning","ka","you","yi","gou" + ,"tong","tuo","ni","ga","ji","er","you","kua","kan","zhu","yi","tiao","chai","jiao","nong" + ,"mou","chou","yan","li","qiu","li","yu","ping","yong","si","feng","qian","ruo","pai","zhuo","shu" + ,"luo","wo","bi","ti","guan","kong","ju","fen","yan","xie","ji","wei","zong","lou","tang","bin" + ,"nuo","chi","xi","jing","jian","jiao","jiu","tong","xuan","dan","tong","tun","she","qian","zu","yue" + ,"cuan","di","xi","xun","hong","guo","chan","kui","bao","pu","hong","fu","fu","su","si","wen" + ,"yan","bo","gun","mao","xie","luan","pou","bing","ying","luo","lei","liang","hu","lie","xian" + ,"song","ping","zhong","ming","yan","jie","hong","shan","ou","ju","ne","gu","he","di","zhao" + ,"qu","dai","kuang","lei","gua","jie","hui","shen","gou","quan","zheng","hun","xu","qiao","gao","kuang" + ,"ei","zou","zhuo","wei","yu","shen","chan","sui","chen","jian","xue","ye","e","yu","xuan","an" + ,"di","zi","pian","mo","dang","su","shi","mi","zhe","jian","zen","qiao","jue","yan","zhan","chen" + ,"dan","jin","zuo","wu","qian","jing","ban","yan","zuo","bei","jing","gai","zhi","nie","zou","chui" + ,"pi","wei","huang","wei","xi","han","qiong","kuang","mang","wu","fang","bing","pi","bei","ye" + ,"di","tai","jia","zhi","zhu","kuai","qie","xun","yun","li","ying","gao","xi","fu","pi" + ,"tan","yan","juan","yan","yin","zhang","po","shan","zou","ling","feng","chu","huan","mai","qu","shao" + ,"he","ge","meng","xu","xie","sou","xie","jue","jian","qian","dang","chang","si","bian","ben","qiu" + ,"ben","e","fa","shu","ji","yong","he","wei","wu","ge","zhen","kuang","pi","yi","li","qi" + ,"ban","gan","long","dian","lu","che","di","tuo","ni","mu","ao","ya","die","dong","kai","shan" + ,"shang","nao","gai","yin","cheng","shi","guo","xun","lie","yuan","zhi","an","yi","pi","nian" + ,"peng","tu","sao","dai","ku","die","yin","leng","hou","ge","yuan","man","yong","liang","chi" + ,"xin","pi","yi","cao","jiao","nai","du","qian","ji","wan","xiong","qi","xiang","fu","yuan","yun" + ,"fei","ji","li","e","ju","pi","zhi","rui","xian","chang","cong","qin","wu","qian","qi","shan" + ,"bian","zhu","kou","yi","mo","gan","pie","long","ba","mu","ju","ran","qing","chi","fu","ling" + ,"niao","yin","mao","ying","qiong","min","tiao","qian","yi","rao","bi","zi","ju","tong","hui","zhu" + ,"ting","qiao","fu","ren","xing","quan","hui","xun","ming","qi","jiao","chong","jiang","luo","ying" + ,"qian","gen","jin","mai","sun","hong","zhou","kan","bi","shi","wo","you","e","mei","you" + ,"li","tu","xian","fu","sui","you","di","shen","guan","lang","ying","chun","jing","qi","xi","song" + ,"jin","nai","qi","ba","shu","chang","tie","yu","huan","bi","fu","tu","dan","cui","yan","zu" + ,"dang","jian","wan","ying","gu","han","qia","feng","shen","xiang","wei","chan","kai","qi","kui","xi" + ,"e","bao","pa","ting","lou","pai","xuan","jia","zhen","shi","ru","mo","en","bei","weng","hao" + ,"ji","li","bang","jian","shuo","lang","ying","yu","su","meng","dou","xi","lian","cu","lin" + ,"qu","kou","xu","liao","hui","xun","jue","rui","zui","ji","meng","fan","qi","hong","xie" + ,"hong","wei","yi","weng","sou","bi","hao","tai","ru","xun","xian","gao","li","huo","qu","heng" + ,"fan","nie","mi","gong","yi","kuang","lian","da","yi","xi","zang","pao","you","liao","ga","gan" + ,"ti","men","tuan","chen","fu","pin","niu","jie","jiao","za","yi","lv","jun","tian","ye","ai" + ,"na","ji","guo","bai","ju","pou","lie","qian","guan","die","zha","ya","qin","yu","an","xuan" + ,"bing","kui","yuan","shu","en","chuai","jian","shuo","zhan","nuo","sang","luo","ying","zhi","han" + ,"zhe","xie","lu","zun","cuan","gan","huan","pi","xing","zhuo","huo","zuan","nang","yi","te" + ,"dai","shi","bu","chi","ji","kou","dao","le","zha","a","yao","fu","mu","yi","tai","li" + ,"e","bi","bei","guo","qin","yin","za","ka","ga","gua","ling","dong","ning","duo","nao","you" + ,"si","kuang","ji","shen","hui","da","lie","yi","xiao","bi","ci","guang","yue","xiu","yi","pai" + ,"kuai","duo","ji","mie","mi","zha","nong","gen","mou","mai","chi","lao","geng","en","zha","suo" + ,"zao","xi","zuo","ji","feng","ze","nuo","miao","lin","zhuan","zhou","tao","hu","cui","sha" + ,"yo","dan","bo","ding","lang","li","shua","chuo","die","da","nan","li","kui","jie","yong" + ,"kui","jiu","sou","yin","chi","jie","lou","ku","wo","hui","qin","ao","su","du","ke","nie" + ,"he","chen","suo","ge","a","en","hao","dia","ai","ai","suo","hei","tong","chi","pei","lei" + ,"cao","piao","qi","ying","beng","sou","di","mi","peng","jue","liao","pu","chuai","jiao","o","qin" + ,"lu","ceng","deng","hao","jin","jue","yi","sai","pi","ru","cha","huo","nang","wei","jian","nan" + ,"lun","hu","ling","you","yu","qing","yu","huan","wei","zhi","pei","tang","dao","ze","guo" + ,"wei","wo","man","zhang","fu","fan","ji","qi","qian","qi","qu","ya","xian","ao","cen" + ,"lan","ba","hu","ke","dong","jia","xiu","dai","gou","mao","min","yi","dong","qiao","xun","zheng" + ,"lao","lai","song","yan","gu","xiao","guo","kong","jue","rong","yao","wai","zai","wei","yu","cuo" + ,"lou","zi","mei","sheng","song","ji","zhang","lin","deng","bin","yi","dian","chi","pang","cu","xun" + ,"yang","hou","lai","xi","chang","huang","yao","zheng","jiao","qu","san","fan","qiu","an","guang","ma" + ,"niu","yun","xia","pao","fei","rong","kuai","shou","sun","bi","juan","li","yu","xian","yin" + ,"suan","yi","guo","luo","ni","she","cu","mi","hu","cha","wei","wei","mei","nao","zhang" + ,"jing","jue","liao","xie","xun","huan","chuan","huo","sun","yin","dong","shi","tang","tun","xi","ren" + ,"yu","chi","yi","xiang","bo","yu","hun","zha","sou","mo","xiu","jin","san","zhuan","nang","pi" + ,"wu","gui","pao","xiu","xiang","tuo","an","yu","bi","geng","ao","jin","chan","xie","lin","ying" + ,"shu","dao","cun","chan","wu","zhi","ou","chong","wu","kai","chang","chuang","song","bian","niu","hu" + ,"chu","peng","da","yang","zuo","ni","fu","chao","yi","yi","tong","yan","ce","kai","xun" + ,"ke","yun","bei","song","qian","kui","kun","yi","ti","quan","qie","xing","fei","chang","wang" + ,"chou","hu","cui","yun","kui","e","leng","zhui","qiao","bi","su","qie","yong","jing","qiao","chong" + ,"chu","lin","meng","tian","hui","shuan","yan","wei","hong","min","kang","ta","lv","kun","jiu","lang" + ,"yu","chang","xi","wen","hun","e","qu","que","he","tian","que","kan","jiang","pan","qiang","san" + ,"qi","si","cha","feng","yuan","mu","mian","dun","mi","gu","bian","wen","hang","wei","le","gan" + ,"shu","long","lu","yang","si","duo","ling","mao","luo","xuan","pan","duo","hong","min","jing" + ,"huan","wei","lie","jia","zhen","yin","hui","zhu","ji","xu","hui","tao","xun","jiang","liu" + ,"hu","xun","ru","su","wu","lai","wei","zhuo","juan","cen","bang","xi","mei","huan","zhu","qi" + ,"xi","song","du","zhuo","pei","mian","gan","fei","cong","shen","guan","lu","shuan","xie","yan","mian" + ,"qiu","sou","huang","xu","pen","jian","xuan","wo","mei","yan","qin","ke","she","mang","ying","pu" + ,"li","ru","ta","hun","bi","xiu","fu","tang","pang","ming","huang","ying","xiao","lan","cao","hu" + ,"luo","huan","lian","zhu","yi","lu","xuan","gan","shu","si","shan","shao","tong","chan","lai" + ,"sui","li","dan","chan","lian","ru","pu","bi","hao","zhuo","han","xie","ying","yue","fen" + ,"hao","ba","bao","gui","dang","mi","you","chen","ning","jian","qian","wu","liao","qian","huan","jian" + ,"jian","zou","ya","wu","jiong","ze","yi","er","jia","jing","dai","hou","pang","bu","li","qiu" + ,"xiao","ti","qun","kui","wei","huan","lu","chuan","huang","qiu","xia","ao","gou","ta","liu","xian" + ,"lin","ju","xie","miao","sui","la","ji","hui","tuan","zhi","kao","zhi","ji","e","chan","xi" + ,"ju","chan","jing","nu","mi","fu","bi","yu","che","shuo","fei","yan","wu","yu","bi" + ,"jin","zi","gui","niu","yu","si","da","zhou","shan","qie","ya","rao","shu","luan","jiao" + ,"pin","cha","li","ping","wa","xian","suo","di","wei","e","jing","biao","jie","chang","bi","chan" + ,"nu","ao","yuan","ting","wu","gou","mo","pi","ai","pin","chi","li","yan","qiang","piao","chang" + ,"lei","zhang","xi","shan","bi","niao","mo","shuang","ga","ga","fu","nu","zi","jie","jue","bao" + ,"zang","si","fu","zou","yi","nu","dai","xiao","hua","pian","li","qi","ke","zhui","can","zhi" + ,"wu","ao","liu","shan","biao","cong","chan","ji","xiang","jiao","yu","zhou","ge","wan","kuang" + ,"yun","pi","shu","gan","xie","fu","zhou","fu","chu","dai","ku","hang","jiang","geng","xiao" + ,"ti","ling","qi","fei","shang","gun","duo","shou","liu","quan","wan","zi","ke","xiang","ti","miao" + ,"hui","si","bian","gou","zhui","min","jin","zhen","ru","gao","li","yi","jian","bin","piao","man" + ,"lei","miao","sao","xie","liao","zeng","jiang","qian","qiao","huan","zuan","yao","ji","chuan","zai","yong" + ,"ding","ji","wei","bin","min","jue","ke","long","dian","dai","po","min","jia","er","gong","xu" + ,"ya","heng","yao","luo","xi","hui","lian","qi","ying","qi","hu","kun","yan","cong","wan" + ,"chen","ju","mao","yu","yuan","xia","nao","ai","tang","jin","huang","ying","cui","cong","xuan" + ,"zhang","pu","can","qu","lu","bi","zan","wen","wei","yun","tao","wu","shao","qi","cha","ma" + ,"li","pi","miao","yao","rui","jian","chu","cheng","cong","xiao","fang","pa","zhu","nai","zhi","zhe" + ,"long","jiu","ping","lu","xia","xiao","you","zhi","tuo","zhi","ling","gou","di","li","tuo","cheng" + ,"kao","lao","ya","rao","zhi","zhen","guang","qi","ting","gua","jiu","hua","heng","gui","jie","luan" + ,"juan","an","xu","fan","gu","fu","jue","zi","suo","ling","chu","fen","du","qian","zhao" + ,"luo","chui","liang","guo","jian","di","ju","cou","zhen","nan","zha","lian","lan","ji","pin" + ,"ju","qiu","duan","chui","chen","lv","cha","ju","xuan","mei","ying","zhen","fei","ta","sun","xie" + ,"gao","cui","gao","shuo","bin","rong","zhu","xie","jin","qiang","qi","chu","tang","zhu","hu","gan" + ,"yue","qing","tuo","jue","qiao","qin","lu","zun","xi","ju","yuan","lei","yan","lin","bo","cha" + ,"you","ao","mo","cu","shang","tian","yun","lian","piao","dan","ji","bin","yi","ren","e","gu" + ,"ke","lu","zhi","yi","zhen","hu","li","yao","shi","zhi","quan","lu","zhe","nian","wang" + ,"chuo","zi","cou","lu","lin","wei","jian","qiang","jia","ji","ji","kan","deng","gai","jian" + ,"zang","ou","ling","bu","beng","zeng","pi","po","ga","la","gan","hao","tan","gao","ze","xin" + ,"yun","gui","he","zan","mao","yu","chang","ni","qi","sheng","ye","chao","yan","hui","bu","han" + ,"gui","xuan","kui","ai","ming","tun","xun","yao","xi","nang","ben","shi","kuang","yi","zhi","zi" + ,"gai","jin","zhen","lai","qiu","ji","dan","fu","chan","ji","xi","di","yu","gou","jin","qu" + ,"jian","jiang","pin","mao","gu","wu","gu","ji","ju","jian","pian","kao","qie","suo","bai" + ,"ge","bo","mao","mu","cui","jian","san","shu","chang","lu","pu","qu","pie","dao","xian" + ,"chuan","dong","ya","yin","ke","yun","fan","chi","jiao","du","die","you","yuan","guo","yue","wo" + ,"rong","huang","jing","ruan","tai","gong","zhun","na","yao","qian","long","dong","ka","lu","jia","shen" + ,"zhou","zuo","gua","zhen","qu","zhi","jing","guang","dong","yan","kuai","sa","hai","pian","zhen","mi" + ,"tun","luo","cuo","pao","wan","niao","jing","yan","fei","yu","zong","ding","jian","cou","nan","mian" + ,"wa","e","shu","cheng","ying","ge","lv","bin","teng","zhi","chuai","gu","meng","sao","shan" + ,"lian","lin","yu","xi","qi","sha","xin","xi","biao","sa","ju","sou","biao","biao","shu" + ,"gou","gu","hu","fei","ji","lan","yu","pei","mao","zhan","jing","ni","liu","yi","yang","wei" + ,"dun","qiang","shi","hu","zhu","xuan","tai","ye","yang","wu","han","men","chao","yan","hu","yu" + ,"wei","duan","bao","xuan","bian","tui","liu","man","shang","yun","yi","yu","fan","sui","xian","jue" + ,"cuan","huo","tao","xu","xi","li","hu","jiong","hu","fei","shi","si","xian","zhi","qu","hu" + ,"fu","zuo","mi","zhi","ci","zhen","tiao","qi","chan","xi","zhuo","xi","rang","te","tan" + ,"dui","jia","hui","nv","nin","yang","zi","que","qian","min","te","qi","dui","mao","men" + ,"gang","yu","yu","ta","xue","miao","ji","gan","dang","hua","che","dun","ya","zhuo","bian","feng" + ,"fa","ai","li","long","zha","tong","di","la","tuo","fu","xing","mang","xia","qiao","zhai","dong" + ,"nao","ge","wo","qi","dui","bei","ding","chen","zhou","jie","di","xuan","bian","zhe","gun","sang" + ,"qing","qu","dun","deng","jiang","ca","meng","bo","kan","zhi","fu","fu","xu","mian","kou","dun" + ,"miao","dan","sheng","yuan","yi","sui","zi","chi","mou","lai","jian","di","suo","ya","ni" + ,"sui","pi","rui","sou","kui","mao","ke","ming","piao","cheng","kan","lin","gu","ding","bi" + ,"quan","tian","fan","zhen","she","wan","tuan","fu","gang","gu","li","yan","pi","lan","li","ji" + ,"zeng","he","guan","juan","jin","ga","yi","po","zhao","liao","tu","chuan","shan","men","chai","nv" + ,"bu","tai","ju","ban","qian","fang","kang","dou","huo","ba","yu","zheng","gu","ke","po","bu" + ,"bo","yue","mu","tan","dian","shuo","shi","xuan","ta","bi","ni","pi","duo","kao","lao","er" + ,"you","cheng","jia","nao","ye","cheng","diao","yin","kai","zhu","ding","diu","hua","quan","ha" + ,"sha","diao","zheng","se","chong","tang","an","ru","lao","lai","te","keng","zeng","li","gao" + ,"e","cuo","lve","liu","kai","jian","lang","qin","ju","a","qiang","nuo","ben","de","ke","kun" + ,"gu","huo","pei","juan","tan","zi","qie","kai","si","e","cha","sou","huan","ai","lou","qiang" + ,"fei","mei","mo","ge","juan","na","liu","yi","jia","bin","biao","tang","man","luo","yong","chuo" + ,"xuan","di","tan","jue","pu","lu","dui","lan","pu","cuan","qiang","deng","huo","zhuo","yi","cha" + ,"biao","zhong","shen","cuo","zhi","bi","zi","mo","shu","lv","ji","fu","lang","ke","ren" + ,"zhen","ji","se","nian","fu","rang","gui","jiao","hao","xi","po","die","hu","yong","jiu" + ,"yuan","bao","zhen","gu","dong","lu","qu","chi","si","er","zhi","gua","xiu","luan","bo","li" + ,"hu","yu","xian","ti","wu","miao","an","bei","chun","hu","e","ci","mei","wu","yao","jian" + ,"ying","zhe","liu","liao","jiao","jiu","yu","hu","lu","guan","bing","ding","jie","li","shan","li" + ,"you","gan","ke","da","zha","pao","zhu","xuan","jia","ya","yi","zhi","lao","wu","cuo","xian" + ,"sha","zhu","fei","gu","wei","yu","yu","dan","la","yi","hou","chai","lou","jia","sao" + ,"chi","mo","ban","ji","huang","biao","luo","ying","zhai","long","yin","chou","ban","lai","yi" + ,"dian","pi","dian","qu","yi","song","xi","qiong","zhun","bian","yao","tiao","dou","ke","yu","xun" + ,"ju","yu","yi","cha","na","ren","jin","mei","pan","dang","jia","ge","ken","lian","cheng","lian" + ,"jian","biao","chu","ti","bi","ju","duo","da","bei","bao","lv","bian","lan","chi","zhe","qiang" + ,"ru","pan","ya","xu","jun","cun","jin","lei","zi","chao","si","huo","lao","tang","ou","lou" + ,"jiang","nou","mo","die","ding","dan","ling","ning","guo","kui","ao","qin","han","qi","hang" + ,"jie","he","ying","ke","han","e","zhuan","nie","man","sang","hao","ru","pin","hu","qian" + ,"qiu","ji","chai","hui","ge","meng","fu","pi","rui","xian","hao","jie","gong","dou","yin","chi" + ,"han","gu","ke","li","you","ran","zha","qiu","ling","cheng","you","qiong","jia","nao","zhi","si" + ,"qu","ting","kuo","qi","jiao","yang","mou","shen","zhe","shao","wu","li","chu","fu","qiang","qing" + ,"qi","xi","yu","fei","guo","guo","yi","pi","tiao","quan","wan","lang","meng","chun","rong","nan" + ,"fu","kui","ke","fu","sou","yu","you","lou","you","bian","mou","qin","ao","man","mang" + ,"ma","yuan","xi","chi","tang","pang","shi","huang","cao","piao","tang","xi","xiang","zhong","zhang" + ,"shuai","mao","peng","hui","pan","shan","huo","meng","chan","lian","mie","li","du","qu","fou","ying" + ,"qing","xia","shi","zhu","yu","ji","du","ji","jian","zhao","zi","hu","qiong","po","da","sheng" + ,"ze","gou","li","si","tiao","jia","bian","chi","kou","bi","xian","yan","quan","zheng","jun","shi" + ,"gang","pa","shao","xiao","qing","ze","qie","zhu","ruo","qian","tuo","bi","dan","kong","wan","xiao" + ,"zhen","kui","huang","hou","gou","fei","li","bi","chi","su","mie","dou","lu","duan","gui" + ,"dian","zan","deng","bo","lai","zhou","yu","yu","chong","xi","nie","nv","chuan","shan","yi" + ,"bi","zhong","ban","fang","ge","lu","zhu","ze","xi","shao","wei","meng","shou","cao","chong","meng" + ,"qin","niao","jia","qiu","sha","bi","di","qiang","suo","jie","tang","xi","xian","mi","ba","li" + ,"tiao","xi","zi","can","lin","zong","san","hou","zan","ci","xu","rou","qiu","jiang","gen","ji" + ,"yi","ling","xi","zhu","fei","jian","pian","he","yi","jiao","zhi","qi","qi","yao","dao","fu" + ,"qu","jiu","ju","lie","zi","zan","nan","zhe","jiang","chi","ding","gan","zhou","yi","gu" + ,"zuo","tuo","xian","ming","zhi","yan","shai","cheng","tu","lei","kun","pei","hu","ti","xu" + ,"hai","tang","lao","bu","jiao","xi","ju","li","xun","shi","cuo","dun","qiong","xue","cu","bie" + ,"bo","ta","jian","fu","qiang","zhi","fu","shan","li","tuo","jia","bo","tai","kui","qiao","bi" + ,"xian","xian","ji","jiao","liang","ji","chuo","huai","chi","zhi","dian","bo","zhi","jian","die","chuai" + ,"zhong","ju","duo","cuo","pian","rou","nie","pan","qi","chu","jue","pu","fan","cu","zhu","lin" + ,"chan","lie","zuan","xie","zhi","diao","mo","xiu","mo","pi","hu","jue","shang","gu","zi" + ,"gong","su","zhi","zi","qing","liang","yu","li","wen","ting","ji","pei","fei","sha","yin" + ,"ai","xian","mai","chen","ju","bao","tiao","zi","yin","yu","chuo","wo","mian","yuan","tuo","zhui" + ,"sun","jun","ju","luo","qu","chou","qiong","luan","wu","zan","mou","ao","liu","bei","xin","you" + ,"fang","ba","ping","nian","lu","su","fu","hou","tai","gui","jie","wei","er","ji","jiao","xiang" + ,"xun","geng","li","lian","jian","shi","tiao","gun","sha","huan","ji","qing","ling","zou","fei","kun" + ,"chang","gu","ni","nian","diao","shi","zi","fen","die","e","qiu","fu","huang","bian","sao" + ,"ao","qi","ta","guan","yao","le","biao","xue","man","min","yong","gui","shan","zun","li" + ,"da","yang","da","qiao","man","jian","ju","rou","gou","bei","jie","tou","ku","gu","di","hou" + ,"ge","ke","bi","lou","qia","kuan","bin","du","mei","ba","yan","liang","xiao","wang","chi","xiang" + ,"yan","tie","tao","yong","biao","kun","mao","ran","tiao","ji","zi","xiu","quan","jiu","bin","huan" + ,"lie","me","hui","mi","ji","jun","zhu","mi","qi","ao","she","lin","dai","chu","you","xia" + ,"yi","qu","du","li","qing","can","an","fen","you","wu","yan","xi","qiu","han","zha" + }; + #endregion + + #region 三级汉字 + private static readonly Char[] _py3 = new[] { + '沚', '埇', '瀍', '浉', '猇', '鄠', '崁', '埗', '漷', '甪', + '滘', '垱' + }; + private static readonly String[] _pyValue3 = new[] { + "Zhi", "Yong", "Chan", "Shi", "Xiao", "Hu", "Kan", "Bu", "Huo", "Lu", + "Jiao", "Dang" + }; + #endregion + + #region 变量定义 + // GB2312-80 标准规范中第一个汉字的机内码.即"啊"的机内码 + private const Int32 firstChCode = -20319; + // GB2312-80 标准规范中最后一个汉字的机内码.即"齄"的机内码 + private const Int32 lastChCode = -2050; + // GB2312-80 标准规范中最后一个一级汉字的机内码.即"座"的机内码 + private const Int32 lastOfOneLevelChCode = -10247; + // 配置中文字符 + //static Regex regex = new Regex("[\u4e00-\u9fa5]$"); + #endregion + +#if NETCOREAPP + static PinYin() => Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); +#endif + + /// 取字符的拼音首字母 + /// + /// + public static Char GetFirst(Char ch) + { + var rs = Get(ch); + if (!rs.IsNullOrEmpty()) return rs[0]; + + return ch; + } + + /// 取字符串各字符的拼音首字母 + /// + /// + public static String GetFirst(String str) + { + if (str.IsNullOrEmpty()) return String.Empty; + + var rs = GetAll(str); + return rs.Where(e => !e.IsNullOrEmpty()).Select(e => e[0]).Join(""); + } + + ///// 取各字符的拼音首字母 + ///// + ///// + //public static String GetFirstOne(String str) + //{ + // if (str.IsNullOrEmpty()) return String.Empty; + + // var sb = Pool.StringBuilder.Get(); + // var chs = str.ToCharArray(); + // if (chs.Length > 0) sb.Append(GetFirst(chs[0])); + + // return sb.Put(true); + //} + + private static Encoding? _gb2312; + /// 获取单字拼音 + /// + /// + public static String Get(Char ch) + { + // 拉丁字符 + if (ch <= '\x00FF') return ch.ToString(); + + // 标点符号、分隔符 + if (Char.IsPunctuation(ch) || Char.IsSeparator(ch)) return ch.ToString(); + + // 非中文字符 + if (ch < '\x4E00' || ch > '\x9FA5') return ch.ToString(); + + _gb2312 ??= Encoding.GetEncoding("gb2312"); + var arr = _gb2312.GetBytes(ch.ToString()); + var chr = arr[0] * 256 + arr[1] - 65536; + + // 单字符--英文或半角字符 + if (chr > 0 && chr < 160) return ch.ToString(); + + #region 中文字符处理 + + // 判断是否超过GB2312-80标准中的汉字范围 + if (chr > lastChCode || chr < firstChCode) + { + var pos = Array.IndexOf(_py3, ch); + if (pos >= 0) return _pyValue3[pos]; + + return ch.ToString(); + } + + // 如果是在一级汉字中 + if (chr <= lastOfOneLevelChCode) + { + // 将一级汉字分为12块,每块33个汉字. + for (var k = 11; k >= 0; k--) + { + var p = k * 33; + // 从最后的块开始扫描,如果机内码大于块的第一个机内码,说明在此块中 + if (chr >= pyValue[p]) + { + // 遍历块中的每个音节机内码,从最后的音节机内码开始扫描, + // 如果音节内码小于机内码,则取此音节 + for (var i = p + 32; i >= p; i--) + { + if (pyValue[i] <= chr) return pyName[i]; + } + break; + } + } + } + else + { + var pos = Array.IndexOf(_py2, ch); + if (pos >= 0) + { + var py = _pyValue2[pos].ToArray(); + py[0] = Char.ToUpper(py[0]); + return new String(py); + } + + pos = Array.IndexOf(_py3, ch); + if (pos >= 0) return _pyValue3[pos]; + } + #endregion 中文字符处理 + + return String.Empty; + } + + /// 把汉字转换成拼音(全拼) + /// 汉字字符串 + /// 转换后的拼音(全拼)字符串 + public static String[] GetAll(String str) + { + if (str.IsNullOrEmpty()) return Array.Empty(); + + // 重点地区支持 + if (str == "重庆") return ["Chong", "Qing"]; + + var list = new List(); + var chs = str.ToCharArray(); + + for (var j = 0; j < chs.Length; j++) + { + list.Add(Get(chs[j])); + } + + return list.ToArray(); + } + + /// 把汉字转换成拼音(全拼) + /// 汉字字符串 + /// 转换后的拼音(全拼)字符串 + public static String Get(String str) => GetAll(str).Join(""); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/Range.cs b/src/Admin/ThingsGateway.NewLife.X/Common/Range.cs new file mode 100644 index 000000000..59692279f --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/Range.cs @@ -0,0 +1,84 @@ +#if NETFRAMEWORK || NETSTANDARD2_0 +using System.Runtime.CompilerServices; + +namespace System; + +/// +public readonly struct Range : IEquatable +{ + /// + public Index Start { get; } + + /// + public Index End { get; } + + /// + public static Range All => new(Index.Start, Index.End); + + /// + /// + /// + public Range(Index start, Index end) + { + Start = start; + End = end; + } + + /// + /// + /// + public override Boolean Equals(Object value) + { + if (value is Range r) + { + if (r.Start.Equals(Start)) + { + return r.End.Equals(End); + } + } + return false; + } + + /// + /// + /// + public Boolean Equals(Range other) => other.Start.Equals(Start) && other.End.Equals(End); + + /// + /// + public override Int32 GetHashCode() => Start.GetHashCode() * 31 + End.GetHashCode(); + + /// + /// + public override String ToString() => Start.ToString() + ".." + End; + + /// + /// + /// + public static Range StartAt(Index start) => new(start, Index.End); + + /// + /// + /// + public static Range EndAt(Index end) => new(Index.Start, end); + + /// + /// + /// + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + //[CLSCompliant(false)] + public (Int32 Offset, Int32 Length) GetOffsetAndLength(Int32 length) + { + var startIndex = Start; + var start = ((!startIndex.IsFromEnd) ? startIndex.Value : (length - startIndex.Value)); + var endIndex = End; + var end = ((!endIndex.IsFromEnd) ? endIndex.Value : (length - endIndex.Value)); + if ((UInt32)end > (UInt32)length || (UInt32)start > (UInt32)end) + { + throw new ArgumentOutOfRangeException("length"); + } + return (start, end - start); + } +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/Runtime.cs b/src/Admin/ThingsGateway.NewLife.X/Common/Runtime.cs new file mode 100644 index 000000000..f8f8b02c7 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/Runtime.cs @@ -0,0 +1,265 @@ +using System.Collections; +using System.Diagnostics; +using System.Runtime; +using System.Runtime.InteropServices; + +using ThingsGateway.NewLife.Log; + +namespace ThingsGateway.NewLife; + +/// 运行时 +/// +/// 文档 https://newlifex.com/core/runtime +/// +public static class Runtime +{ + #region 控制台 + private static Boolean? _IsConsole; + /// 是否控制台。用于判断是否可以执行一些控制台操作。 + public static Boolean IsConsole + { + get + { + if (_IsConsole != null) return _IsConsole.Value; + + // netcore 默认都是控制台,除非主动设置 + _IsConsole = true; + + try + { + var flag = Console.ForegroundColor; + if (Process.GetCurrentProcess().MainWindowHandle != IntPtr.Zero) + _IsConsole = false; + else + _IsConsole = true; + } + catch + { + _IsConsole = false; + } + + return _IsConsole.Value; + } + set => _IsConsole = value; + } + + /// 是否在容器中运行 + public static Boolean Container => Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"; + #endregion + + #region 系统特性 + /// 是否Mono环境 + public static Boolean Mono { get; } = Type.GetType("Mono.Runtime") != null; + +#if !NETFRAMEWORK + private static Boolean? _IsWeb; + /// 是否Web环境 + public static Boolean IsWeb + { + get + { + if (_IsWeb == null) + { + try + { + var asm = AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(e => e.GetName().Name == "Microsoft.AspNetCore"); + _IsWeb = asm != null; + } + catch + { + _IsWeb = false; + } + } + + return _IsWeb.Value; + } + } + + /// 是否Windows环境 + public static Boolean Windows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + + /// 是否Linux环境 + public static Boolean Linux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + /// 是否OSX环境 + public static Boolean OSX => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); +#else + /// 是否Web环境 + public static Boolean IsWeb => !String.IsNullOrEmpty(System.Web.HttpRuntime.AppDomainAppId); + + /// 是否Windows环境 + public static Boolean Windows { get; } = Environment.OSVersion.Platform <= PlatformID.WinCE; + + /// 是否Linux环境 + public static Boolean Linux { get; } = Environment.OSVersion.Platform == PlatformID.Unix; + + /// 是否OSX环境 + public static Boolean OSX { get; } = Environment.OSVersion.Platform == PlatformID.MacOSX; +#endif + #endregion + + #region 扩展 +#if NETCOREAPP3_1_OR_GREATER + /// 系统启动以来的毫秒数 + public static Int64 TickCount64 => Environment.TickCount64; +#else + /// 系统启动以来的毫秒数 + public static Int64 TickCount64 + { + get + { + if (Stopwatch.IsHighResolution) return Stopwatch.GetTimestamp() * 1000 / Stopwatch.Frequency; + + return Environment.TickCount; + } + } +#endif + + private static Int32 _ProcessId; +#if NET6_0_OR_GREATER + /// 当前进程Id + public static Int32 ProcessId => _ProcessId > 0 ? _ProcessId : _ProcessId = Environment.ProcessId; +#else + /// 当前进程Id + public static Int32 ProcessId => _ProcessId > 0 ? _ProcessId : _ProcessId = Process.GetCurrentProcess().Id; +#endif + + /// + /// 获取环境变量。不区分大小写 + /// + /// + /// + public static String? GetEnvironmentVariable(String variable) + { + var val = Environment.GetEnvironmentVariable(variable); + if (!val.IsNullOrEmpty()) return val; + + foreach (var item in Environment.GetEnvironmentVariables()) + { + if (item is DictionaryEntry de) + { + var key = de.Key as String; + if (key.EqualIgnoreCase(variable)) return de.Value as String; + } + } + + return null; + } + + /// + /// 获取环境变量集合。不区分大小写 + /// + /// + public static IDictionary GetEnvironmentVariables() + { + var dic = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var item in Environment.GetEnvironmentVariables()) + { + if (item is not DictionaryEntry de) continue; + + var key = de.Key as String; + if (!key.IsNullOrEmpty()) dic[key] = de.Value as String; + } + + return dic; + } + #endregion + + #region 设置 + private static Boolean? _createConfigOnMissing; + /// 默认配置。配置文件不存在时,是否生成默认配置文件 + public static Boolean CreateConfigOnMissing + { + get + { + if (_createConfigOnMissing == null) + { + var val = Environment.GetEnvironmentVariable("CreateConfigOnMissing"); + _createConfigOnMissing = !val.IsNullOrEmpty() ? val.ToBoolean(true) : true; + } + + return _createConfigOnMissing.Value; + } + set { _createConfigOnMissing = value; } + } + #endregion + + #region 内存 + /// 释放内存。GC回收后再释放虚拟内存 + /// 进程Id。默认0表示当前进程 + /// 是否GC回收 + /// 是否释放工作集 + public static Boolean FreeMemory(Int32 processId = 0, Boolean gc = true, Boolean workingSet = true) + { + if (processId <= 0) processId = ProcessId; + + var p = Process.GetProcessById(processId); + if (p == null) return false; + + if (processId != ProcessId) gc = false; + + var log = XTrace.Log; + if (log != null && log.Enable && log.Level <= LogLevel.Debug) + { + p ??= Process.GetCurrentProcess(); + var gcm = GC.GetTotalMemory(false) / 1024; + var ws = p.WorkingSet64 / 1024; + var prv = p.PrivateMemorySize64 / 1024; + if (gc) + log.Debug("[{3}/{4}]开始释放内存:GC={0:n0}K,WorkingSet={1:n0}K,PrivateMemory={2:n0}K", gcm, ws, prv, p.ProcessName, p.Id); + else + log.Debug("[{3}/{4}]开始释放内存:WorkingSet={1:n0}K,PrivateMemory={2:n0}K", gcm, ws, prv, p.ProcessName, p.Id); + } + + if (gc) + { + var max = GC.MaxGeneration; + var mode = GCCollectionMode.Forced; +#if NET8_0_OR_GREATER + mode = GCCollectionMode.Aggressive; +#endif +#if NET451_OR_GREATER || NETSTANDARD || NETCOREAPP + GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; +#endif + GC.Collect(max, mode); + GC.WaitForPendingFinalizers(); + GC.Collect(max, mode); + } + + if (workingSet) + { + if (Runtime.Windows) + { + try + { + p ??= Process.GetProcessById(processId); + EmptyWorkingSet(p.Handle); + } + catch (Exception ex) + { + log?.Error("EmptyWorkingSet失败:{0}", ex.Message); + return false; + } + } + } + + if (log != null && log.Enable && log.Level <= LogLevel.Debug) + { + p ??= Process.GetProcessById(processId); + p.Refresh(); + var gcm = GC.GetTotalMemory(false) / 1024; + var ws = p.WorkingSet64 / 1024; + var prv = p.PrivateMemorySize64 / 1024; + if (gc) + log.Debug("[{3}/{4}]释放内存完成:GC={0:n0}K,WorkingSet={1:n0}K,PrivateMemory={2:n0}K", gcm, ws, prv, p.ProcessName, p.Id); + else + log.Debug("[{3}/{4}]释放内存完成:WorkingSet={1:n0}K,PrivateMemory={2:n0}K", gcm, ws, prv, p.ProcessName, p.Id); + } + + return true; + } + + [DllImport("psapi.dll", SetLastError = true)] + internal static extern Boolean EmptyWorkingSet(IntPtr hProcess); + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Common/TimeProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Common/TimeProvider.cs new file mode 100644 index 000000000..3965494fa --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Common/TimeProvider.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; + +namespace System; + +#if NETFRAMEWORK || NETSTANDARD || NETCOREAPP3_1 || NET5_0 || NET6_0 || NET7_0 +/// 提供时间的抽象 +public abstract class TimeProvider +{ + private sealed class SystemTimeProvider : TimeProvider + { + internal SystemTimeProvider() { } + } + + private static readonly Int64 s_minDateTicks = DateTime.MinValue.Ticks; + + private static readonly Int64 s_maxDateTicks = DateTime.MaxValue.Ticks; + + /// 获取一个 TimeProvider ,它提供基于 UtcNow的时钟、基于 的 Local时区、基于 的 Stopwatch高性能时间戳和基于 的 Timer计时器。 + public static TimeProvider System { get; set; } = new SystemTimeProvider(); + + /// 根据此 TimeProvider的时间概念获取本地时区。 + public virtual TimeZoneInfo LocalTimeZone => TimeZoneInfo.Local; + + /// 获取 的频率 GetTimestamp() 作为每秒时钟周期数。 + public virtual Int64 TimestampFrequency => Stopwatch.Frequency; + + /// 根据此 TimeProvider的时间概念,获取当前协调世界时 (UTC) 日期和时间,偏移量为零。 + /// + public virtual DateTimeOffset GetUtcNow() => DateTimeOffset.UtcNow; + + /// 根据基于 TimeProvider的时间概念 GetUtcNow()获取当前日期和时间,偏移量设置为 LocalTimeZone与协调世界时 (UTC) 的偏移量。 + /// + public DateTimeOffset GetLocalNow() + { + var utcNow = GetUtcNow(); + var localTimeZone = LocalTimeZone ?? throw new ArgumentNullException(nameof(LocalTimeZone)); + + var utcOffset = localTimeZone.GetUtcOffset(utcNow); + if (utcOffset.Ticks == 0L) return utcNow; + + var num = utcNow.Ticks + utcOffset.Ticks; + if ((UInt64)num > (UInt64)s_maxDateTicks) + num = ((num < s_minDateTicks) ? s_minDateTicks : s_maxDateTicks); + + return new DateTimeOffset(num, utcOffset); + } + + /// 获取当前高频值,该值旨在测量计时器机制中精度较高的小时间间隔。 + /// + public virtual Int64 GetTimestamp() => Stopwatch.GetTimestamp(); + + /// 获取使用 GetTimestamp()检索到的两个时间戳之间的已用时间。 + /// + /// + /// + public TimeSpan GetElapsedTime(Int64 startingTimestamp, Int64 endingTimestamp) + { + var timestampFrequency = TimestampFrequency; + if (timestampFrequency <= 0) throw new ArgumentOutOfRangeException(nameof(TimestampFrequency)); + + return new TimeSpan((Int64)((endingTimestamp - startingTimestamp) * (10000000.0 / timestampFrequency))); + } + + /// 获取自使用 GetTimestamp()检索值以来startingTimestamp的运行时间。 + /// + /// + public TimeSpan GetElapsedTime(Int64 startingTimestamp) => GetElapsedTime(startingTimestamp, GetTimestamp()); +} +#endif diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/CommandParser.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/CommandParser.cs new file mode 100644 index 000000000..7c39abb77 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/CommandParser.cs @@ -0,0 +1,118 @@ +namespace ThingsGateway.NewLife.Configuration; + +/// 命令分析器 +public class CommandParser +{ + /// 不区分大小写 + public Boolean IgnoreCase { get; set; } + + /// 去除前导横杠。默认true + public Boolean TrimStart { get; set; } = true; + + /// 分析参数数组,得到名值字段 + /// + /// + public IDictionary Parse(String[] args) + { + args ??= Environment.GetCommandLineArgs(); + + var dic = IgnoreCase ? + new Dictionary(StringComparer.OrdinalIgnoreCase) : + new Dictionary(); + for (var i = 0; i < args.Length; i++) + { + var key = args[i]; + + // 如果key以-开头,说明是参数名,下一个可能是参数值 + if (key[0] == '-') + { + // 有=表明是kv结构 + var p = key.IndexOf('='); + if (p > 0) + { + var value = key.Substring(p + 1); + key = key.Substring(0, p); + if (TrimStart) key = key.TrimStart('-'); + dic[key] = TrimQuote(value); + } + else + { + // 下一个是值 + if (TrimStart) key = key.TrimStart('-'); + var value = (i + 1 < args.Length && args[i + 1][0] != '-') ? args[++i] : null; + dic[key] = TrimQuote(value); + } + } + else + { + // 下一个是值 + if (TrimStart) key = key.TrimStart('-'); + var value = (i + 1 < args.Length && args[i + 1][0] != '-') ? args[++i] : null; + dic[key] = TrimQuote(value); + } + } + + return dic; + } + + /// 去除两头的双引号 + /// + /// + public static String? TrimQuote(String? value) + { + if (value.IsNullOrEmpty()) return value; + + if (value[0] == '"' && value[value.Length - 1] == '"') value = value.Substring(1, value.Length - 2); + if (value[0] == '\'' && value[value.Length - 1] == '\'') value = value.Substring(1, value.Length - 2); + + return value; + } + + /// 把字符串分割为参数数组,支持双引号 + /// + /// + public static String[] Split(String? value) + { + value = value?.Trim(); + if (value.IsNullOrEmpty()) return []; + + // 分割参数,特殊支持双引号 + var args = new List(); + var p = 0; + while (p < value.Length) + { + var p2 = value.IndexOf(' ', p); + if (p2 < 0) + { + args.Add(value.Substring(p).Trim().Trim('"')); + break; + } + else if (p2 == p) + { + } + else + { + // 如果双引号位于空格前面,则找到下一个双引号,再从那开始找空格 + if (value[p] == '"') + { + var p3 = value.IndexOf('"', p + 1); + if (p3 >= 0 && p3 > p2) + { + // 下一个必须是空格,要么就是末尾 + if (p3 == value.Length - 1 || value[p3 + 1] == ' ') + { + p++; + p2 = p3; + } + } + } + + args.Add(value.Substring(p, p2 - p).Trim()); + } + + p = p2 + 1; + } + + return args.ToArray(); + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/Config.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/Config.cs new file mode 100644 index 000000000..79df08e32 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/Config.cs @@ -0,0 +1,110 @@ +using System.Reflection; +using System.Runtime.Serialization; +using System.Xml.Serialization; + +using ThingsGateway.NewLife.Log; + +namespace ThingsGateway.NewLife.Configuration; + +/// 配置文件基类 +/// +/// 标准用法:TConfig.Current +/// +/// 配置实体类通过特性指定配置文件路径。 +/// Current将加载配置文件,如果文件不存在或者加载失败,将实例化一个对象返回。 +/// +/// +public class Config where TConfig : Config, new() +{ + #region 静态 + /// 当前使用的提供者 + public static IConfigProvider? Provider { get; set; } + + static Config() + { + // 创建提供者 + var att = typeof(TConfig).GetCustomAttribute(true); + var value = att?.Name; + if (value.IsNullOrEmpty()) + { + value = typeof(TConfig).Name; + if (value.EndsWith("Config") && value != "Config") value = value.TrimEnd("Config"); + if (value.EndsWith("Setting") && value != "Setting") value = value.TrimEnd("Setting"); + } + var prv = ConfigProvider.Create(att?.Provider); + if (prv is ConfigProvider prv2) + { + prv2.Init(value); + } + + Provider = prv; + } + + private static TConfig? _Current; + /// 当前实例。通过置空可以使其重新加载。 + public static TConfig Current + { + get + { + if (_Current != null) return _Current; + lock (typeof(TConfig)) + { + if (_Current != null) return _Current; + + var config = new TConfig(); + var prv = Provider ?? throw new ArgumentNullException(nameof(Provider)); + + // 配置文件损坏时,要能够忽略错误,强行加载,避免影响系统正常运行 + try + { + // 绑定提供者数据到配置对象 + prv.Bind(config, true); + } + catch (Exception ex) + { + XTrace.Log?.Error(ex.Message); + } + + try + { + config.OnLoaded(); + } + catch (Exception ex) + { + XTrace.Log?.Error(ex.Message); + } + + try + { + // OnLoad 中可能有变化,存回去 + //prv.Save(config); + if (!prv.IsNew || Runtime.CreateConfigOnMissing) config.Save(); + } + catch (Exception ex) + { + XTrace.Log?.Error(ex.Message); + } + + return _Current = config; + } + } + set { _Current = value; } + } + #endregion + + #region 属性 + /// 是否新的配置文件 + [XmlIgnore, IgnoreDataMember] + //[Obsolete("=>_Provider.IsNew")] + public Boolean IsNew => Provider?.IsNew ?? false; + #endregion + + #region 成员方法 + /// 从配置文件中读取完成后触发 + protected virtual void OnLoaded() { } + + /// 保存到配置文件中去 + //[Obsolete("=>Provider.Save")] + public virtual void Save() => Provider?.Save(this); + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/ConfigAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/ConfigAttribute.cs new file mode 100644 index 000000000..177e13b14 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/ConfigAttribute.cs @@ -0,0 +1,25 @@ +namespace ThingsGateway.NewLife.Configuration; + +/// 配置特性 +/// +/// 声明配置模型使用哪一种配置提供者,以及所需要的文件名和分类名。 +/// 如未指定提供者,则使用全局默认,此时将根据全局代码配置或环境变量配置使用不同提供者,实现配置信息整体转移。 +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public sealed class ConfigAttribute : Attribute +{ + /// 提供者。内置ini/xml/json/http,一般不指定,使用全局默认 + public String? Provider { get; set; } + + /// 配置名。可以是文件名或分类名 + public String Name { get; set; } + + /// 指定配置名 + /// 配置名。可以是文件名或分类名 + /// 提供者。内置ini/xml/json/http,一般不指定,使用全局默认 + public ConfigAttribute(String name, String? provider = null) + { + Provider = provider; + Name = name; + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/ConfigHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/ConfigHelper.cs new file mode 100644 index 000000000..90a21aece --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/ConfigHelper.cs @@ -0,0 +1,366 @@ +using System.Collections; +using System.ComponentModel; +using System.Reflection; + +using ThingsGateway.NewLife.Reflection; +using ThingsGateway.NewLife.Serialization; + +namespace ThingsGateway.NewLife.Configuration; + +/// 配置助手 +public static class ConfigHelper +{ + #region 扩展 + /// 查找配置项。可得到子级和配置 + /// + /// + /// + /// + public static IConfigSection? Find(this IConfigSection section, String key, Boolean createOnMiss = false) + { + if (key.IsNullOrEmpty()) return section; + + // 分层 + var ss = key.Split(':'); + + var sec = section; + + // 逐级下钻 + for (var i = 0; i < ss.Length; i++) + { + var cfg = sec.Childs?.FirstOrDefault(e => e.Key.EqualIgnoreCase(ss[i])); + if (cfg == null) + { + if (!createOnMiss) return null; + + cfg = sec.AddChild(ss[i]); + } + + sec = cfg; + } + + return sec; + } + + /// 添加子节点 + /// + /// + /// + public static IConfigSection AddChild(this IConfigSection section, String key) + { + //if (section == null) return null; + + var cfg = new ConfigSection { Key = key }; + section.Childs ??= new List(); + section.Childs.Add(cfg); + + return cfg; + } + + /// 查找或添加子节点 + /// + /// + /// + public static IConfigSection GetOrAddChild(this IConfigSection section, String key) + { + //if (section == null) return null; + + var cfg = section.Childs?.FirstOrDefault(e => e.Key.EqualIgnoreCase(key)); + if (cfg != null) return cfg; + + cfg = new ConfigSection { Key = key }; + section.Childs ??= new List(); + section.Childs.Add(cfg); + + return cfg; + } + + /// 设置节点值。格式化友好字符串 + /// + /// + internal static void SetValue(this IConfigSection section, Object? value) + { + if (value is DateTime dt) + section.Value = dt.ToFullString(); + else if (value is Boolean b) + section.Value = b.ToString().ToLower(); + else + section.Value = value?.ToString(); + } + #endregion + + #region 映射 + /// 映射配置树到实例公有属性 + /// 数据源 + /// 模型 + /// 提供者 + public static void MapTo(this IConfigSection section, Object model, IConfigProvider provider) + { + var childs = section?.Childs?.ToArray(); + if (childs == null || childs.Length == 0 || model == null) return; + + // 支持字典 + if (model is IDictionary dic) + { + foreach (var cfg in childs) + { + if (cfg.Key.IsNullOrEmpty()) continue; + + dic[cfg.Key] = cfg.Value; + + if (cfg.Childs != null && cfg.Childs.Count > 0) + dic[cfg.Key] = cfg.Childs; + } + + return; + } + + var prv = provider as ConfigProvider; + + // 反射公有实例属性 + foreach (var pi in model.GetType().GetProperties(true)) + { + if (!pi.CanRead || !pi.CanWrite) continue; + //if (pi.GetIndexParameters().Length > 0) continue; + //if (pi.GetCustomAttribute(false) != null) continue; + //if (pi.GetCustomAttribute() != null) continue; + + var name = SerialHelper.GetName(pi); + if (name.EqualIgnoreCase("ConfigFile", "IsNew")) continue; + + prv?.UseKey(name); + var cfg = childs.FirstOrDefault(e => e.Key.EqualIgnoreCase(name)); + if (cfg == null) + { + prv?.MissKey(name); + continue; + } + + // 分别处理基本类型、数组类型、复杂类型 + MapToObject(cfg, model, pi, provider); + } + } + + private static void MapToObject(IConfigSection cfg, Object model, PropertyInfo pi, IConfigProvider provider) + { + // 分别处理基本类型、数组类型、复杂类型 + if (pi.PropertyType.IsBaseType()) + { + model.SetValue(pi, cfg.Value); + } + else if (cfg.Childs != null) + { + if (pi.PropertyType.As() || pi.PropertyType.As(typeof(IList<>))) + { + if (pi.PropertyType.IsArray) + MapToArray(cfg, model, pi, provider); + else + MapToList(cfg, model, pi, provider); + } + else + { + // 复杂类型需要递归处理 + var val = model.GetValue(pi); + if (val == null) + { + // 如果有无参构造函数,则实例化一个 + var ctor = pi.PropertyType.GetConstructor(Array.Empty()); + if (ctor != null) + { + val = ctor.Invoke(null); + model.SetValue(pi, val); + } + } + + // 递归映射 + if (val != null) MapTo(cfg, val, provider); + } + } + } + + private static void MapToArray(IConfigSection section, Object model, PropertyInfo pi, IConfigProvider provider) + { + if (section.Childs == null) return; + + var elementType = pi.PropertyType.GetElementTypeEx(); + if (elementType == null) return; + + var count = section.Childs.Count; + + // 实例化数组 + if (model.GetValue(pi) is not Array arr || arr.Length == 0) + { + arr = Array.CreateInstance(elementType, count); + model.SetValue(pi, arr); + } + + // 逐个映射 + for (var i = 0; i < count && i < arr.Length; i++) + { + var sec = section.Childs[i]; + + // 基元类型 + if (elementType.IsBaseType()) + { + if (sec.Key == elementType.Name) + { + arr.SetValue(sec.Value?.ChangeType(elementType), i); + } + } + else + { + var val = elementType.CreateInstance(); + if (val != null) MapTo(sec, val, provider); + arr.SetValue(val, i); + } + } + } + + private static void MapToList(IConfigSection section, Object model, PropertyInfo pi, IConfigProvider provider) + { + var elementType = pi.PropertyType.GetElementTypeEx(); + if (elementType == null) return; + + // 实例化列表 + if (model.GetValue(pi) is IList list) + { + // 映射前清空原有数据 + list.Clear(); + + if (section.Childs == null) return; + + // 逐个映射 + var childs = section.Childs.ToArray(); + for (var i = 0; i < childs.Length; i++) + { + var val = elementType.CreateInstance(); + if (elementType.IsBaseType()) + { + val = childs[i].Value; + } + else + { + if (val != null) MapTo(childs[i], val, provider); + //list[i] = val; + } + list.Add(val); + } + } + else + { + var obj = !pi.PropertyType.IsInterface ? + pi.PropertyType.CreateInstance() : + typeof(List<>).MakeGenericType(elementType).CreateInstance(); + + if (obj is not IList list2) return; + + model.SetValue(pi, list2); + } + } + + /// 从实例公有属性映射到配置树 + /// + /// + public static void MapFrom(this IConfigSection section, Object model) + { + if (section == null) return; + + // 支持字典 + if (model is IDictionary dic) + { + foreach (var item in dic) + { + var cfg = section.GetOrAddChild(item.Key); + var value = item.Value; + + // 分别处理基本类型、数组类型、复杂类型 + if (value != null) MapObject(section, cfg, value, value.GetType()); + } + + return; + } + + // 反射公有实例属性 + foreach (var pi in model.GetType().GetProperties(true)) + { + if (!pi.CanRead || !pi.CanWrite) continue; + //if (pi.GetIndexParameters().Length > 0) continue; + //if (pi.GetCustomAttribute(false) != null) continue; + //if (pi.GetCustomAttribute() != null) continue; + + var name = SerialHelper.GetName(pi); + if (name.EqualIgnoreCase("ConfigFile", "IsNew")) continue; + + // 名称前面加上命名空间 + var cfg = section.GetOrAddChild(name); + + // 反射获取属性值 + var value = model.GetValue(pi); + var att = pi.GetCustomAttribute(); + cfg.Comment = att?.Description; + if (cfg.Comment.IsNullOrEmpty()) + { + var att2 = pi.GetCustomAttribute(); + cfg.Comment = att2?.DisplayName; + } + + //!! 即使模型字段值为空,也必须拷贝,否则修改设置时,无法清空某字段 + //if (val == null) continue; + + // 分别处理基本类型、数组类型、复杂类型 + MapObject(section, cfg, value, pi.PropertyType); + } + } + + private static void MapObject(IConfigSection section, IConfigSection cfg, Object? val, Type type) + { + // 分别处理基本类型、数组类型、复杂类型 + if (type.IsBaseType()) + { + cfg.SetValue(val); + } + else if (type.As() || type.As(typeof(IList<>))) + { + if (val is IList list) + { + var elementType = type.GetElementTypeEx(); + if (elementType != null) MapArray(section, cfg, list, elementType); + } + } + else if (val != null) + { + // 递归映射 + MapFrom(cfg, val); + } + } + + private static void MapArray(IConfigSection section, IConfigSection cfg, IList list, Type elementType) + { + if (section.Childs == null) return; + + // 为了避免数组元素叠加,干掉原来的 + section.Childs.Remove(cfg); + cfg = new ConfigSection + { + Key = cfg.Key, + Childs = new List(), + Comment = cfg.Comment + }; + section.Childs.Add(cfg); + + // 数组元素是没有key的集合 + foreach (var item in list) + { + if (item == null) continue; + + var cfg2 = cfg.AddChild(elementType.Name); + + // 分别处理基本类型和复杂类型 + if (item.GetType().IsBaseType()) + cfg2.SetValue(item); + else + MapFrom(cfg2, item); + } + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/FileConfigProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/FileConfigProvider.cs new file mode 100644 index 000000000..624551b0b --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/FileConfigProvider.cs @@ -0,0 +1,209 @@ + +using ThingsGateway.NewLife.Log; +using ThingsGateway.NewLife.Threading; + +namespace ThingsGateway.NewLife.Configuration; + +/// 文件配置提供者 +/// +/// 每个提供者实例对应一个配置文件,支持热更新 +/// +public abstract class FileConfigProvider : ConfigProvider +{ + #region 属性 + /// 文件名。最高优先级,优先于模型特性指定的文件名 + public String? FileName { get; set; } + + /// 更新周期。默认5秒 + public Int32 Period { get; set; } = 5; + #endregion + + #region 构造 + /// 销毁 + /// + protected override void Dispose(Boolean disposing) + { + base.Dispose(disposing); + + _timer.TryDispose(); + } + + /// 已重载。输出友好信息 + /// + public override String ToString() => $"{GetType().Name} FileName={FileName}"; + #endregion + + #region 方法 + /// 初始化 + /// + public override void Init(String value) + { + base.Init(value); + + // 加上文件名 + if (FileName.IsNullOrEmpty() && !value.IsNullOrEmpty()) + { + // 加上配置目录 + var str = value; + if (!str.StartsWithIgnoreCase("Config/", "Config\\")) str = "Config".CombinePath(str); + + FileName = str; + } + } + + /// 加载配置 + public override Boolean LoadAll() + { + // 准备文件名 + var fileName = FileName; + if (fileName.IsNullOrEmpty()) throw new ArgumentNullException(nameof(FileName)); + + fileName = fileName.GetBasePath(); + + IsNew = true; + + //if (!File.Exists(fileName)) throw new FileNotFoundException("找不到文件", fileName); + if (!File.Exists(fileName)) return false; + + // 读取文件,换个对象,避免数组元素在多次加载后重叠 + var section = new ConfigSection { }; + OnRead(fileName, section); + Root = section; + + IsNew = false; + _lastTime = fileName.AsFile().LastWriteTime; + + return true; + } + + /// 读取配置文件 + /// 文件名 + /// 配置段 + protected abstract void OnRead(String fileName, IConfigSection section); + + /// 保存配置树到数据源 + public override Boolean SaveAll() + { + // 准备文件名 + var fileName = FileName; + if (fileName.IsNullOrEmpty()) throw new ArgumentNullException(nameof(FileName)); + + fileName = fileName.GetBasePath(); + fileName.EnsureDirectory(true); + + // 写入文件 + OnWrite(fileName, Root); + _lastTime = fileName.AsFile().LastWriteTime; + + // 通知绑定对象,配置数据有改变 + NotifyChange(); + + return true; + } + + /// 保存模型实例 + /// 模型 + /// 模型实例 + /// 路径。配置树位置 + public override Boolean Save(T model, String? path = null) + { + if (model == null) return false; + + // 加锁,避免多线程冲突 + lock (this) + { + // 文件存储,直接覆盖Root + Root.Childs?.Clear(); + Root.MapFrom(model); + + return SaveAll(); + } + } + + /// 写入配置文件 + /// 文件名 + /// 配置段 + protected virtual void OnWrite(String fileName, IConfigSection section) + { + var str = GetString(section); + var old = ""; + if (File.Exists(fileName)) old = File.ReadAllText(fileName); + + if (str != old) + { + XTrace.WriteLine("保存配置 {0}", fileName); + + File.WriteAllText(fileName, str); + } + } + + /// 获取字符串形式 + /// 配置段 + /// + public virtual String? GetString(IConfigSection? section = null) => null; + #endregion + + #region 绑定 + /// 绑定模型,使能热更新,配置存储数据改变时同步修改模型属性 + /// 模型 + /// 模型实例 + /// 是否自动更新。默认true + /// 路径。配置树位置,配置中心等多对象混合使用时 + public override void Bind(T model, Boolean autoReload = true, String? path = null) + { + base.Bind(model, autoReload, path); + + if (autoReload) InitTimer(); + } + + private TimerX? _timer; + private void InitTimer() + { + if (_timer != null) return; + lock (this) + { + if (_timer != null) return; + + var p = Period; + if (p <= 0) p = 60; + _timer = new TimerX(DoRefresh, null, p * 1000, p * 1000) { Async = true }; + } + } + + private Boolean _reading; + private DateTime _lastTime; + private void DoRefresh(Object? state) + { + if (_reading) return; + if (FileName.IsNullOrEmpty()) return; + + var fileName = FileName.GetBasePath(); + var fi = FileName.AsFile(); + if (!fi.Exists) return; + + fi.Refresh(); + if (_lastTime.Year > 2000 && fi.LastWriteTime <= _lastTime) return; + _lastTime = fi.LastWriteTime; + + XTrace.WriteLine("配置文件改变,重新加载 {0}", fileName); + + _reading = true; + try + { + var section = new ConfigSection { }; + OnRead(fileName, section); + Root = section; + + NotifyChange(); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + finally + { + _reading = false; + } + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/GetConfigCallback.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/GetConfigCallback.cs new file mode 100644 index 000000000..5c4d2ff5d --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/GetConfigCallback.cs @@ -0,0 +1,6 @@ +namespace ThingsGateway.NewLife.Configuration; + +/// 获取配置委托。便于集成配置中心 +/// +/// +public delegate String? GetConfigCallback(String key); \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/IConfigMapping.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/IConfigMapping.cs new file mode 100644 index 000000000..f8a51f671 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/IConfigMapping.cs @@ -0,0 +1,14 @@ +namespace ThingsGateway.NewLife.Configuration +{ + /// 配置映射接口。用于自定义映射配置树到当前对象 + /// + /// 整体配置数据改变时触发调用该接口,但不表示当前对象所绑定路径的配置数据有改变,用户需要自己判断所属配置数据是否已改变。 + /// + public interface IConfigMapping + { + /// 映射配置树到当前对象 + /// 配置提供者 + /// 配置数据段 + void MapConfig(IConfigProvider provider, IConfigSection section); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/IConfigProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/IConfigProvider.cs new file mode 100644 index 000000000..baaa335b8 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/IConfigProvider.cs @@ -0,0 +1,387 @@ +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Configuration; + +/// 配置提供者 +/// +/// 建立树状配置数据体系,以分布式配置中心为核心,支持基于key的索引读写,也支持Load/Save/Bind的实体模型转换。 +/// key索引支持冒号分隔的多层结构,在配置中心中不同命名空间使用不同提供者实例,在文件配置中不同文件使用不同提供者实例。 +/// +/// 一个配置类,支持从不同持久化提供者读取,可根据需要选择配置持久化策略。 +/// 例如,小系统采用ini/xml/json文件配置,分布式系统采用配置中心。 +/// +/// 可通过实现IConfigMapping接口来自定义映射配置到模型实例。 +/// +public interface IConfigProvider +{ + /// 名称 + String Name { get; set; } + + /// 根元素 + IConfigSection Root { get; set; } + + /// 所有键 + ICollection Keys { get; } + + /// 是否新的配置文件 + Boolean IsNew { get; set; } + + /// 获取 或 设置 配置值 + /// 配置名,支持冒号分隔的多级名称 + /// + String? this[String key] { get; set; } + + /// 查找配置项。可得到子级和配置 + /// 配置名 + /// + IConfigSection? GetSection(String key); + + /// 配置改变事件。执行了某些动作,可能导致配置数据发生改变时触发 + event EventHandler Changed; + + /// 返回获取配置的委托 + GetConfigCallback GetConfig { get; } + + /// 从数据源加载数据到配置树 + Boolean LoadAll(); + + /// 保存配置树到数据源 + Boolean SaveAll(); + + /// 加载配置到模型 + /// 模型。可通过实现IConfigMapping接口来自定义映射配置到模型实例 + /// 路径。配置树位置,配置中心等多对象混合使用时 + /// + T? Load(String? path = null) where T : new(); + + /// 保存模型实例 + /// 模型 + /// 模型实例 + /// 路径。配置树位置,配置中心等多对象混合使用时 + Boolean Save(T model, String? path = null); + + /// 绑定模型,使能热更新,配置存储数据改变时同步修改模型属性 + /// 模型。可通过实现IConfigMapping接口来自定义映射配置到模型实例 + /// 模型实例 + /// 是否自动更新。默认true + /// 命名空间。配置树位置,配置中心等多对象混合使用时 + void Bind(T model, Boolean autoReload = true, String? path = null); + + /// 绑定模型,使能热更新,配置存储数据改变时同步修改模型属性 + /// 模型。可通过实现IConfigMapping接口来自定义映射配置到模型实例 + /// 模型实例 + /// 命名空间。配置树位置,配置中心等多对象混合使用时 + /// 配置改变时执行的委托 + void Bind(T model, String path, Action onChange); +} + +/// 配置提供者基类 +/// +/// 同时也是基于Items字典的内存配置提供者。 +/// +public abstract class ConfigProvider : DisposeBase, IConfigProvider +{ + #region 属性 + /// 名称 + public String Name { get; set; } + + /// 根元素 + public virtual IConfigSection Root { get; set; } = new ConfigSection { Childs = new List() }; + + /// 所有键 + public virtual ICollection Keys + { + get + { + //Root?.Childs?.Select(e => e.Key).ToList(); + + var list = new List(); + + var childs = Root?.Childs; + if (childs == null) return list; + + foreach (var item in childs) + { + if (item.Key != null) list.Add(item.Key); + } + + return list; + } + } + + /// 已使用的键 + public ICollection UsedKeys { get; } = new List(); + + /// 缺失的键 + public ICollection MissedKeys { get; } = new List(); + + /// 返回获取配置的委托 + public virtual GetConfigCallback GetConfig => key => Find(key, false)?.Value; + + /// 配置改变事件。执行了某些动作,可能导致配置数据发生改变时触发 + public event EventHandler? Changed; + + /// 是否新的配置文件 + public Boolean IsNew { get; set; } + #endregion + + #region 构造 + /// 构造函数 + public ConfigProvider() => Name = GetType().Name.TrimEnd("ConfigProvider"); + #endregion + + #region 方法 + /// 获取 或 设置 配置值 + /// 键 + /// + public virtual String? this[String key] + { + get { EnsureLoad(); return Find(key, false)?.Value; } + set + { + var section = Find(key, true); + if (section != null) section.Value = value; + } + } + + /// 查找配置项。可得到子级和配置 + /// + /// + public virtual IConfigSection? GetSection(String key) => Find(key, false); + + /// 查找配置项,可指定是否创建 + /// 配置提供者可以重载该方法以实现增强功能。例如星尘配置从注册中心读取数据 + /// + /// + /// + protected virtual IConfigSection? Find(String key, Boolean createOnMiss) + { + UseKey(key); + + EnsureLoad(); + + var sec = Root.Find(key, createOnMiss); + if (sec == null) MissKey(key); + + return sec; + } + + internal void UseKey(String key) + { + if (!key.IsNullOrEmpty() && !UsedKeys.Contains(key)) UsedKeys.Add(key); + } + + internal void MissKey(String key) + { + if (!key.IsNullOrEmpty() && !MissedKeys.Contains(key)) MissedKeys.Add(key); + } + + /// 初始化提供者 + /// + public virtual void Init(String value) { } + #endregion + + #region 加载/保存 + /// 从数据源加载数据到配置树 + public virtual Boolean LoadAll() => true; + + private Boolean _Loaded; + private void EnsureLoad() + { + if (_Loaded) return; + lock (this) + { + if (_Loaded) return; + + LoadAll(); + + _Loaded = true; + } + } + + /// 加载配置到模型 + /// 模型。可通过实现IConfigMapping接口来自定义映射配置到模型实例 + /// 路径。配置树位置,配置中心等多对象混合使用时 + /// + public virtual T? Load(String? path = null) where T : new() + { + EnsureLoad(); + + // 如果有命名空间则使用指定层级数据源 + var source = path.IsNullOrEmpty() ? Root : GetSection(path); + if (source == null) return default; + + var model = new T(); + if (model is IConfigMapping map) + map.MapConfig(this, source); + else + source.MapTo(model, this); + + return model; + } + + /// 保存配置树到数据源 + public virtual Boolean SaveAll() + { + NotifyChange(); + + return true; + } + + /// 保存模型实例 + /// 模型 + /// 模型实例 + /// 路径。配置树位置 + public virtual Boolean Save(T model, String? path = null) + { + if (model == null) throw new ArgumentNullException(nameof(model)); + + EnsureLoad(); + + // 如果有命名空间则使用指定层级数据源 + var source = path.IsNullOrEmpty() ? Root : Find(path, true); + source?.MapFrom(model); + + return SaveAll(); + } + #endregion + + #region 绑定 + private readonly Dictionary _models = new Dictionary(); + private readonly Dictionary _models2 = new Dictionary(); + /// 绑定模型,使能热更新,配置存储数据改变时同步修改模型属性 + /// 模型。可通过实现IConfigMapping接口来自定义映射配置到模型实例 + /// 模型实例 + /// 是否自动更新。默认true + /// 命名空间。配置树位置,配置中心等多对象混合使用时 + public virtual void Bind(T model, Boolean autoReload = true, String? path = null) + { + if (model == null) throw new ArgumentNullException(nameof(model)); + + EnsureLoad(); + + // 如果有命名空间则使用指定层级数据源 + var source = path.IsNullOrEmpty() ? Root : GetSection(path); + if (source != null) + { + if (model is IConfigMapping map) + map.MapConfig(this, source); + else + source.MapTo(model, this); + } + + if (autoReload && !_models.ContainsKey(model)) + { + path ??= String.Empty; + _models.Add(model, path); + } + } + + /// 绑定模型,使能热更新,配置存储数据改变时同步修改模型属性 + /// 模型。可通过实现IConfigMapping接口来自定义映射配置到模型实例 + /// 模型实例 + /// 命名空间。配置树位置,配置中心等多对象混合使用时 + /// 配置改变时执行的委托 + public virtual void Bind(T model, String path, Action onChange) + { + if (model == null) throw new ArgumentNullException(nameof(model)); + + EnsureLoad(); + + // 如果有命名空间则使用指定层级数据源 + var source = path.IsNullOrEmpty() ? Root : GetSection(path); + if (source != null) + { + if (model is IConfigMapping map) + map.MapConfig(this, source); + else + source.MapTo(model, this); + } + + if (onChange != null && !_models2.ContainsKey(model)) + { + _models2.Add(model, new ModelWrap(path, onChange)); + } + } + + private sealed record ModelWrap(String Path, Action OnChange); + + /// 通知绑定对象,配置数据有改变 + protected virtual void NotifyChange() + { + foreach (var item in _models) + { + var model = item.Key; + var source = GetSection(item.Value); + if (source != null) + { + if (model is IConfigMapping map) + map.MapConfig(this, source); + else + source.MapTo(model, this); + } + } + foreach (var item in _models2) + { + var model = item.Key; + var source = GetSection(item.Value.Path); + if (source != null) item.Value.OnChange(source); + } + + // 通过事件通知外部 + Changed?.Invoke(this, EventArgs.Empty); + } + #endregion + + #region 静态 + /// 默认提供者。默认xml + public static String DefaultProvider { get; set; } = "xml"; + + static ConfigProvider() + { + // 支持从命令行参数和环境变量设定默认配置提供者 + var str = ""; + var args = Environment.GetCommandLineArgs(); + for (var i = 0; i < args.Length; i++) + { + if (args[i].EqualIgnoreCase("-DefaultConfig", "--DefaultConfig") && i + 1 < args.Length) + { + str = args[i + 1]; + break; + } + } + if (str.IsNullOrEmpty()) str = ThingsGateway.NewLife.Runtime.GetEnvironmentVariable("DefaultConfig"); + if (!str.IsNullOrEmpty()) DefaultProvider = str; + + Register("ini"); + Register("xml"); + Register("config"); + } + + private static readonly Dictionary _providers = new Dictionary(StringComparer.OrdinalIgnoreCase); + /// 注册提供者 + /// + /// + public static void Register(String name) where TProvider : IConfigProvider, new() => _providers[name] = typeof(TProvider); + + /// 根据指定名称创建提供者 + /// + /// 如果是文件名,根据后缀确定使用哪一种提供者。 + /// + /// + /// + public static IConfigProvider? Create(String? name) + { + if (name.IsNullOrEmpty()) name = DefaultProvider; + + var p = name.LastIndexOf('.'); + var ext = p >= 0 ? name[(p + 1)..] : name; + if (!_providers.TryGetValue(ext, out _)) ext = DefaultProvider; + if (!_providers.TryGetValue(ext, out var type)) throw new Exception($"Unable to find an appropriate configuration provider for [{name}]"); + + var config = type.CreateInstance() as IConfigProvider; + + return config; + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/IConfigSection.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/IConfigSection.cs new file mode 100644 index 000000000..a4245d64f --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/IConfigSection.cs @@ -0,0 +1,59 @@ +namespace ThingsGateway.NewLife.Configuration; + +/// 配置对象 +public interface IConfigSection +{ + /// 配置名 + String Key { get; set; } + + /// 配置值 + String? Value { get; set; } + + /// 注释 + String? Comment { get; set; } + + /// 子级 + IList? Childs { get; set; } + + /// 获取 或 设置 配置值 + /// 配置名,支持冒号分隔的多级名称 + /// + String? this[String key] { get; set; } +} + +/// 配置项 +public class ConfigSection : IConfigSection +{ + #region 属性 + /// 配置名 + public String Key { get; set; } = null!; + + /// 配置值 + public String? Value { get; set; } + + /// 注释 + public String? Comment { get; set; } + + /// 子级 + public IList? Childs { get; set; } + #endregion + + #region 方法 + /// 获取 或 设置 配置值 + /// 键 + /// + public virtual String? this[String key] + { + get => this.Find(key, false)?.Value; + set + { + var section = this.Find(key, true); + if (section != null) section.Value = value; + } + } + + /// 已重载。 + /// + public override String ToString() => Childs != null && Childs.Count > 0 ? $"{Key}[{Childs.Count}]" : $"{Key}={Value}"; + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/IniConfigProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/IniConfigProvider.cs new file mode 100644 index 000000000..a3c65f1db --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/IniConfigProvider.cs @@ -0,0 +1,106 @@ +using System.Text; + +namespace ThingsGateway.NewLife.Configuration; + +/// Ini文件配置提供者 +/// +/// 支持从不同配置文件加载到不同配置模型 +/// +public class InIConfigProvider : FileConfigProvider +{ + /// 初始化 + /// + public override void Init(String value) + { + // 加上默认后缀 + if (!value.IsNullOrEmpty() && Path.GetExtension(value).IsNullOrEmpty()) value += ".ini"; + + base.Init(value); + } + + /// 读取配置文件 + /// 文件名 + /// 配置段 + protected override void OnRead(String fileName, IConfigSection section) + { + var lines = File.ReadAllLines(fileName); + + var currentSection = section; + var remark = ""; + foreach (var item in lines) + { + var str = item.Trim(); + if (str.IsNullOrEmpty()) continue; + + // 读取注释 + if (str[0] is '#' or ';') + { + remark = str.TrimStart('#', ';').Trim(); + continue; + } + + if (str[0] == '[' && str[^1] == ']') + { + currentSection = section.GetOrAddChild(str.Trim('[', ']')); + currentSection.Comment = remark; + } + else + { + var p = str.IndexOf('='); + if (p > 0) + { + var name = str[..p].Trim(); + + // 构建配置值和注释 + var cfg = currentSection.AddChild(name); + if (p + 1 < str.Length) cfg.Value = str[(p + 1)..].Trim(); + cfg.Comment = remark; + } + } + + // 清空注释 + remark = null; + } + } + + /// 获取字符串形式 + /// 配置段 + /// + public override String GetString(IConfigSection? section = null) + { + section ??= Root; + if (section.Childs == null) return String.Empty; + + // 分组写入 + var sb = new StringBuilder(); + foreach (var item in section.Childs.ToArray()) + { + if (item.Childs != null && item.Childs.Count > 0) + { + // 段前空一行 + sb.AppendLine(); + // 注释 + if (!item.Comment.IsNullOrEmpty()) sb.AppendLine("; " + item.Comment); + sb.AppendLine($"[{item.Key}]"); + + // 写入当前段 + foreach (var elm in item.Childs.ToArray()) + { + // 注释 + if (!elm.Comment.IsNullOrEmpty()) sb.AppendLine("; " + elm.Comment); + + sb.AppendLine($"{elm.Key} = {elm.Value}"); + } + } + else + { + // 注释 + if (!item.Comment.IsNullOrEmpty()) sb.AppendLine("; " + item.Comment); + + sb.AppendLine($"{item.Key} = {item.Value}"); + } + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Configuration/XmlConfigProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Configuration/XmlConfigProvider.cs new file mode 100644 index 000000000..2b413ac9e --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Configuration/XmlConfigProvider.cs @@ -0,0 +1,196 @@ +using System.Text; +using System.Xml; + +namespace ThingsGateway.NewLife.Configuration; + +/// Xml文件配置提供者 +/// +/// 支持从不同配置文件加载到不同配置模型 +/// +public class XmlConfigProvider : FileConfigProvider +{ + /// 根元素名称 + public String RootName { get; set; } = "Root"; + + /// 初始化 + /// + public override void Init(String value) + { + if ((RootName.IsNullOrEmpty() || RootName == "Root") && !value.IsNullOrEmpty()) RootName = Path.GetFileNameWithoutExtension(value); + + // 加上默认后缀 + if (!value.IsNullOrEmpty() && Path.GetExtension(value).IsNullOrEmpty()) value += ".config"; + + base.Init(value); + } + + /// 读取配置文件 + /// 文件名 + /// 配置段 + protected override void OnRead(String fileName, IConfigSection section) + { + using var fs = File.Open(fileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = XmlReader.Create(fs); + + // 移动到第一个元素 + while (reader.NodeType != XmlNodeType.Element) reader.Read(); + + if (!reader.Name.IsNullOrEmpty()) RootName = reader.Name; + + reader.ReadStartElement(); + while (reader.NodeType == XmlNodeType.Whitespace) reader.Skip(); + + ReadNode(reader, section); + + if (reader.NodeType == XmlNodeType.EndElement) reader.ReadEndElement(); + } + + private static void ReadNode(XmlReader reader, IConfigSection section) + { + while (true) + { + var remark = ""; + if (reader.NodeType == XmlNodeType.Comment) remark = reader.Value; + while (reader.NodeType is XmlNodeType.Comment or XmlNodeType.Whitespace) reader.Skip(); + if (reader.NodeType != XmlNodeType.Element) break; + + var name = reader.Name; + var cfg = section.AddChild(name); + // 前一行是注释 + if (!remark.IsNullOrEmpty()) cfg.Comment = remark; + + // 读取属性值 + if (reader.HasAttributes) + { + var dic = new Dictionary(); + reader.MoveToFirstAttribute(); + do + { + //var cfg2 = cfg.AddChild(reader.Name); + //cfg2.Value = reader.Value; + dic[reader.Name] = reader.Value; + } while (reader.MoveToNextAttribute()); + + // 如果只有一个Value属性,可能是基元类型数组 + if (dic.Count == 1 && dic.TryGetValue("Value", out var val)) + cfg.Value = val; + else + { + foreach (var item in dic) + { + var cfg2 = cfg.AddChild(item.Key); + cfg2.Value = item.Value; + } + } + } + else + reader.ReadStartElement(); + while (reader.NodeType == XmlNodeType.Whitespace) reader.Skip(); + + // 遇到下一层节点 + if (reader.NodeType is XmlNodeType.Element or XmlNodeType.Comment) + ReadNode(reader, cfg); + else if (reader.NodeType == XmlNodeType.Text) + cfg.Value = reader.ReadContentAsString(); + + if (reader.NodeType == XmlNodeType.Attribute) reader.Read(); + if (reader.NodeType == XmlNodeType.EndElement) reader.ReadEndElement(); + while (reader.NodeType == XmlNodeType.Whitespace) reader.Skip(); + } + } + + /// 获取字符串形式 + /// 配置段 + /// + public override String GetString(IConfigSection? section = null) + { + section ??= Root; + + var set = new XmlWriterSettings + { + Encoding = Encoding.UTF8, + Indent = true, + CloseOutput = true, + }; + + using var ms = new MemoryStream(); + using var writer = XmlWriter.Create(ms, set); + + writer.WriteStartDocument(); + WriteNode(writer, RootName, section); + writer.WriteEndDocument(); + + writer.Flush(); + ms.Position = 0; + + return ms.ToStr(); + } + + private static void WriteNode(XmlWriter writer, String name, IConfigSection section) + { + if (section.Childs == null) return; + + writer.WriteStartElement(name); + + foreach (var item in section.Childs.ToArray()) + { + if (item.Key.IsNullOrEmpty()) continue; + + // 写注释 + if (!item.Comment.IsNullOrEmpty()) writer.WriteComment(item.Comment); + + var cs = item.Childs; + if (cs != null) + { + // 数组 + if (cs.Count >= 2 && cs[0].Key == cs[1].Key) + { + writer.WriteStartElement(item.Key); + foreach (var elm in cs) + { + if (!elm.Key.IsNullOrEmpty()) WriteAttributeNode(writer, elm.Key, elm); + } + writer.WriteEndElement(); + } + else + { + WriteNode(writer, item.Key, item); + } + } + else + { + // 避免写null时导致xml元素未闭合 + writer.WriteStartElement(item.Key); + writer.WriteValue(item.Value + ""); + writer.WriteEndElement(); + } + } + + writer.WriteEndElement(); + } + + private static void WriteAttributeNode(XmlWriter writer, String name, IConfigSection section) + { + writer.WriteStartElement(name); + //writer.WriteStartAttribute(name); + + if (/*section != null &&*/ section.Childs != null) + { + foreach (var item in section.Childs.ToArray()) + { + if (item.Key.IsNullOrEmpty()) continue; + + writer.WriteAttributeString(item.Key, item.Value + ""); + } + } + else + { + writer.WriteAttributeString("Value", section.Value + ""); + } + + if (writer.WriteState == WriteState.Attribute) + writer.WriteEndAttribute(); + else + writer.WriteEndElement(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Event/EventArgs.cs b/src/Admin/ThingsGateway.NewLife.X/Event/EventArgs.cs new file mode 100644 index 000000000..a3d1e219f --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Event/EventArgs.cs @@ -0,0 +1,135 @@ +using System.Runtime.InteropServices; + +namespace ThingsGateway.NewLife; + +/// 泛型事件参数 +/// +[Serializable] +[ComVisible(true)] +public class EventArgs : EventArgs +{ + /// 参数 + public TArg Arg { get; set; } + + /// 使用参数初始化 + /// + public EventArgs(TArg arg) => Arg = arg; + + /// 弹出 + /// + public void Pop(ref TArg arg) => arg = Arg; +} + +/// 泛型事件参数 +/// +/// +public class EventArgs : EventArgs +{ + /// 参数 + public TArg1 Arg1 { get; set; } + + /// 参数2 + public TArg2 Arg2 { get; set; } + + /// 使用参数初始化 + /// + /// + public EventArgs(TArg1 arg1, TArg2 arg2) + { + Arg1 = arg1; + Arg2 = arg2; + } + + /// 弹出 + /// + /// + public void Pop(ref TArg1 arg1, ref TArg2 arg2) + { + arg1 = Arg1; + arg2 = Arg2; + } +} + +/// 泛型事件参数 +/// +/// +/// +public class EventArgs : EventArgs +{ + /// 参数 + public TArg1 Arg1 { get; set; } + + /// 参数2 + public TArg2 Arg2 { get; set; } + + /// 参数3 + public TArg3 Arg3 { get; set; } + + /// 使用参数初始化 + /// + /// + /// + public EventArgs(TArg1 arg1, TArg2 arg2, TArg3 arg3) + { + Arg1 = arg1; + Arg2 = arg2; + Arg3 = arg3; + } + + /// 弹出 + /// + /// + /// + public void Pop(ref TArg1 arg1, ref TArg2 arg2, ref TArg3 arg3) + { + arg1 = Arg1; + arg2 = Arg2; + arg3 = Arg3; + } +} + +/// 泛型事件参数 +/// +/// +/// +/// +public class EventArgs : EventArgs +{ + /// 参数 + public TArg1 Arg1 { get; set; } + + /// 参数2 + public TArg2 Arg2 { get; set; } + + /// 参数3 + public TArg3 Arg3 { get; set; } + + /// 参数4 + public TArg4 Arg4 { get; set; } + + /// 使用参数初始化 + /// + /// + /// + /// + public EventArgs(TArg1 arg1, TArg2 arg2, TArg3 arg3, TArg4 arg4) + { + Arg1 = arg1; + Arg2 = arg2; + Arg3 = arg3; + Arg4 = arg4; + } + + /// 弹出 + /// + /// + /// + /// + public void Pop(ref TArg1 arg1, ref TArg2 arg2, ref TArg3 arg3, ref TArg4 arg4) + { + arg1 = Arg1; + arg2 = Arg2; + arg3 = Arg3; + arg4 = Arg4; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Event/WeakAction.cs b/src/Admin/ThingsGateway.NewLife.X/Event/WeakAction.cs new file mode 100644 index 000000000..0a30f6b30 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Event/WeakAction.cs @@ -0,0 +1,140 @@ +using System.Reflection; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife; + +/// 弱引用Action +/// +/// 常见的事件和委托,都包括两部分:对象和方法,当然如果委托到静态方法上,对象是为空的。 +/// 如果把事件委托到某个对象的方法上,同时就间接的引用了这个对象,导致其一直无法被回收,从而造成内存泄漏。 +/// 弱引用Action,原理就是把委托拆分,然后弱引用对象部分,需要调用委托的时候,再把对象“拉”回来,如果被回收了,就没有必要再调用它的方法了。 +/// +/// 文档 https://newlifex.com/core/weak_action +/// +/// +public class WeakAction +{ + #region 属性 + /// 目标对象。弱引用,使得调用方对象可以被GC回收 + private readonly WeakReference? Target; + + /// 委托方法 + private readonly MethodBase Method; + + /// 经过包装的新的委托 + private readonly Action Handler; + + /// 取消注册的委托 + private Action>? UnHandler; + + /// 是否只使用一次,如果只使用一次,执行委托后马上取消注册 + private readonly Boolean Once; + #endregion + + #region 扩展属性 + /// 是否可用 + public Boolean IsAlive + { + get + { + var target = Target; + if (target == null && Method.IsStatic) return true; + + return target != null && target.IsAlive; + } + } + #endregion + + #region 构造 + /// 实例化 + /// 目标对象 + /// 目标方法 + public WeakAction(Object? target, MethodInfo method) : this(target, method, null, false) { } + + /// 实例化 + /// 目标对象 + /// 目标方法 + /// 取消注册回调 + /// 是否一次性事件 + public WeakAction(Object? target, MethodInfo method, Action>? unHandler, Boolean once) + { + if (target != null) + { + Target = new WeakReference(target); + } + else + { + if (!method.IsStatic) throw new InvalidOperationException("Illegal event, no specified class instance and not a static method!"); + } + + Method = method; + Handler = Invoke; + UnHandler = unHandler; + Once = once; + } + + /// 实例化 + /// 事件处理器 + public WeakAction(Delegate handler) : this(handler.Target, handler.Method, null, false) { } + + /// 使用事件处理器、取消注册回调、是否一次性事件来初始化 + /// 事件处理器 + /// 取消注册回调 + /// 是否一次性事件 + public WeakAction(Delegate handler, Action> unHandler, Boolean once) : this(handler.Target, handler.Method, unHandler, once) { } + #endregion + + #region 方法 + /// 调用委托 + /// + public void Invoke(TArgs e) + { + //if (!Target.IsAlive) return; + // Keep in mind that,不要用上面的写法,因为判断可能通过,但是接着就被GC回收了,如果判断Target,则会增加引用 + Object? target = null; + if (Target == null) + { + if (Method.IsStatic) Reflect.Invoke(null, Method, e); + } + else + { + target = Target.Target; + if (target != null) + { + // 优先使用委托 + if (Method is MethodInfo mi) + mi.As>(target)!.Invoke(e); + else + target.Invoke(Method, e); + } + } + + // 调用方已被回收,或者该事件只使用一次,则取消注册 + if ((Target != null && target == null || Once) && UnHandler != null) + { + UnHandler(Handler); + UnHandler = null; + } + } + + /// 把弱引用事件处理器转换为普通事件处理器 + /// + /// + public static implicit operator Action(WeakAction handler) => handler.Handler; + #endregion + + #region 辅助 + /// 已重载 + /// + public override String? ToString() + { + if (Method == null) return base.ToString(); + + if (Method.DeclaringType != null) + return $"{Method.DeclaringType.Name}.{Method.Name}"; + else + return Method.Name; + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Exceptions/XException.cs b/src/Admin/ThingsGateway.NewLife.X/Exceptions/XException.cs new file mode 100644 index 000000000..d2da958e5 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Exceptions/XException.cs @@ -0,0 +1,71 @@ +using System.ComponentModel; + +namespace ThingsGateway.NewLife; + +/// X组件异常 +[Serializable] +public class XException : Exception +{ + #region 构造 + /// 初始化 + public XException() { } + + /// 初始化 + /// + public XException(String message) : base(message) { } + + /// 初始化 + /// + /// + public XException(String format, params Object?[] args) : base(String.Format(format, args)) { } + + /// 初始化 + /// + /// + public XException(String message, Exception innerException) : base(message, innerException) { } + + /// 初始化 + /// + /// + /// + public XException(Exception innerException, String format, params Object?[] args) : base(String.Format(format, args), innerException) { } + + /// 初始化 + /// + public XException(Exception innerException) : base((innerException?.Message), innerException) { } + + ///// 初始化 + ///// + ///// + //protected XException(SerializationInfo info, StreamingContext context) : base(info, context) { } + #endregion +} + +/// 异常事件参数 +public class ExceptionEventArgs : CancelEventArgs +{ + /// 发生异常时进行的动作 + public String Action { get; set; } + + /// 异常 + public Exception Exception { get; set; } + + /// 实例化 + /// + /// + public ExceptionEventArgs(String action, Exception ex) + { + Action = action; + Exception = ex; + } +} + +/// 异常助手 +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ExceptionHelper +{ + /// 是否对象已被释放异常 + /// + /// + public static Boolean IsDisposed(this Exception ex) => ex is ObjectDisposedException; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/BitHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/BitHelper.cs new file mode 100644 index 000000000..810acf92a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/BitHelper.cs @@ -0,0 +1,86 @@ +namespace ThingsGateway.NewLife; + +/// 数据位助手 +public static class BitHelper +{ + /// 设置数据位 + /// 数值 + /// + /// + /// + public static UInt16 SetBit(this UInt16 value, Int32 position, Boolean flag) + { + return SetBits(value, position, 1, (flag ? (Byte)1 : (Byte)0)); + } + + /// 设置数据位 + /// 数值 + /// + /// + /// + /// + public static UInt16 SetBits(this UInt16 value, Int32 position, Int32 length, UInt16 bits) + { + if (length <= 0 || position >= 16) return value; + + var mask = (2 << (length - 1)) - 1; + + value &= (UInt16)~(mask << position); + value |= (UInt16)((bits & mask) << position); + + return value; + } + + /// 设置数据位 + /// 数值 + /// + /// + /// + public static Byte SetBit(this Byte value, Int32 position, Boolean flag) + { + if (position >= 8) return value; + + var mask = (2 << (1 - 1)) - 1; + + value &= (Byte)~(mask << position); + value |= (Byte)(((flag ? 1 : 0) & mask) << position); + + return value; + } + + /// 获取数据位 + /// 数值 + /// + /// + public static Boolean GetBit(this UInt16 value, Int32 position) + { + return GetBits(value, position, 1) == 1; + } + + /// 获取数据位 + /// 数值 + /// + /// + /// + public static UInt16 GetBits(this UInt16 value, Int32 position, Int32 length) + { + if (length <= 0 || position >= 16) return 0; + + var mask = (2 << (length - 1)) - 1; + + return (UInt16)((value >> position) & mask); + } + + /// 获取数据位 + /// 数值 + /// + /// + public static Boolean GetBit(this Byte value, Int32 position) + { + if (position >= 8) return false; + + var mask = (2 << (1 - 1)) - 1; + + return ((Byte)((value >> position) & mask)) == 1; + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/ByteArrayToNumberArrayConverter.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/ByteArrayToNumberArrayConverter.cs new file mode 100644 index 000000000..10d2ffd76 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/ByteArrayToNumberArrayConverter.cs @@ -0,0 +1,57 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Newtonsoft.Json; + +namespace ThingsGateway.NewLife.Extension; + +public class ByteArrayToNumberArrayConverter : JsonConverter +{ + public override void WriteJson(JsonWriter writer, byte[]? value, JsonSerializer serializer) + { + if (value == null) + { + writer.WriteNull(); + return; + } + // 将 byte[] 转换为数值数组 + writer.WriteStartArray(); + foreach (var b in value) + { + writer.WriteValue(b); + } + writer.WriteEndArray(); + } + + public override byte[] ReadJson(JsonReader reader, Type objectType, byte[]? existingValue, bool hasExistingValue, JsonSerializer serializer) + { + // 从数值数组读取 byte[] + if (reader.TokenType == JsonToken.StartArray) + { + var byteList = new System.Collections.Generic.List(); + while (reader.Read()) + { + if (reader.TokenType == JsonToken.EndArray) + { + break; + } + + if (reader.TokenType == JsonToken.Integer) + { + byteList.Add(Convert.ToByte(reader.Value)); + } + } + return byteList.ToArray(); + } + throw new JsonSerializationException("Invalid JSON format for byte array."); + } + + public override bool CanRead => true; +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/ConcurrentDictionaryExtensions.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/ConcurrentDictionaryExtensions.cs new file mode 100644 index 000000000..3ddbea0ec --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/ConcurrentDictionaryExtensions.cs @@ -0,0 +1,45 @@ +using System.Collections.Concurrent; + +namespace ThingsGateway.NewLife.Extension; + +/// 并发字典扩展 +public static class ConcurrentDictionaryExtensions +{ + /// 从并发字典中删除 + /// + /// + /// + /// + /// + public static Boolean Remove(this ConcurrentDictionary dict, TKey key) where TKey : notnull => dict.TryRemove(key, out _); + + /// + public static int RemoveWhere(this IDictionary pairs, Func, bool> func) + { + // 存储需要移除的键的列表,以便之后统一移除 + var list = new List(); + foreach (var item in pairs) + { + // 使用提供的函数判断当前项目是否应该被移除 + if (func?.Invoke(item) == true) + { + list.Add(item.Key); + } + } + + // 记录成功移除的项目数量 + var count = 0; + foreach (var item in list) + { + // 尝试移除项目,如果成功则增加计数 + if (pairs.Remove(item)) + { + count++; + } + } + // 返回成功移除的项目数量 + return count; + } + + +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/ConcurrentQueueExtensions.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/ConcurrentQueueExtensions.cs new file mode 100644 index 000000000..3a062476a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/ConcurrentQueueExtensions.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace ThingsGateway.Extension; + +/// +public static class ConcurrentQueueExtensions +{ + /// + /// 批量出队 + /// + public static List ToListWithDequeue(this ConcurrentQueue values, int maxCount = 0) + { + if (maxCount <= 0) + { + maxCount = values.Count; + } + else + { + maxCount = Math.Min(maxCount, values.Count); + } + + var list = new List(maxCount); + while (maxCount-- > 0 && values.TryDequeue(out var result)) + { + list.Add(result); + } + return list; + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/DateExtensions.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/DateExtensions.cs new file mode 100644 index 000000000..717361690 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/DateExtensions.cs @@ -0,0 +1,172 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Extension; +/// +/// 时间扩展类 +/// +public static class DateExtensions +{ + /// + /// 将 DateTimeOffset 转换成本地 DateTime + /// + /// + /// + public static DateTime ConvertToDateTime(this DateTimeOffset dateTime) + { + if (dateTime.Offset.Equals(TimeSpan.Zero)) + return dateTime.UtcDateTime; + if (dateTime.Offset.Equals(TimeZoneInfo.Local.GetUtcOffset(dateTime.DateTime))) + return dateTime.ToLocalTime().DateTime; + else + return dateTime.DateTime; + } + + /// + /// 将 DateTimeOffset? 转换成本地 DateTime? + /// + /// + /// + public static DateTime? ConvertToDateTime(this DateTimeOffset? dateTime) + { + return dateTime.HasValue ? dateTime.Value.ConvertToDateTime() : null; + } + + /// + /// 将 DateTime 转换成 DateTimeOffset + /// + /// + /// + public static DateTimeOffset ConvertToDateTimeOffset(this DateTime dateTime) + { + return DateTime.SpecifyKind(dateTime, DateTimeKind.Local); + } + + /// + /// 将 DateTime? 转换成 DateTimeOffset? + /// + /// + /// + public static DateTimeOffset? ConvertToDateTimeOffset(this DateTime? dateTime) + { + return dateTime.HasValue ? dateTime.Value.ConvertToDateTimeOffset() : null; + } + + /// + /// 计算2个时间差,返回文字描述 + /// + /// 开始时间 + /// 结束时间 + /// 时间差 + public static string GetDiffTime(this in DateTime beginTime, in DateTime endTime) + { + TimeSpan timeDifference = endTime - beginTime; + if (timeDifference.TotalDays >= 1) + { + return $"{(int)timeDifference.TotalDays} d {timeDifference.Hours} H"; + } + else if (timeDifference.TotalHours >= 1) + { + return $"{(int)timeDifference.TotalHours} H {timeDifference.Minutes} m"; + } + else + { + return $"{(int)timeDifference.TotalMinutes} m"; + } + } + + /// + /// 计算2个时间差,返回文字描述 + /// + /// 开始时间 + /// 结束时间 + /// 时间差 + public static string GetDiffTime(this in DateTimeOffset beginTime, in DateTimeOffset endTime) + { + TimeSpan timeDifference = endTime - beginTime; + if (timeDifference.TotalDays >= 1) + { + return $"{(int)timeDifference.TotalDays} d {timeDifference.Hours} H"; + } + else if (timeDifference.TotalHours >= 1) + { + return $"{(int)timeDifference.TotalHours} H {timeDifference.Minutes} m"; + } + else + { + return $"{(int)timeDifference.TotalMinutes} m"; + } + } + + /// + /// 返回yyyy-MM-ddTHH:mm:ss.fffffffzzz时间格式字符串 + /// + public static string ToDefaultDateTimeFormat(this in DateTime dt, TimeSpan offset) + { + if (dt.Kind == DateTimeKind.Utc) + return new DateTimeOffset(dt.ToLocalTime(), offset).ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"); + else if (dt == DateTime.MinValue || dt == DateTime.MaxValue) + return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"); + else + { + if (offset == TimeSpan.Zero) + { + return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"); + } + else if (dt.Kind != DateTimeKind.Local) + return new DateTimeOffset(dt, offset).ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"); + } + return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"); + } + + /// + /// 返回yyyy-MM-ddTHH:mm:ss.fffffffzzz时间格式字符串 + /// + public static string ToDefaultDateTimeFormat(this in DateTime dt) + { + return dt.ToString("yyyy-MM-ddTHH:mm:ss.fffffffzzz"); + } + + /// + /// 返回yyyy-MM-dd HH-mm-ss-fff zz时间格式字符串 + /// + public static string ToFileDateTimeFormat(this in DateTime dt) + { + return ToDefaultDateTimeFormat(dt).Replace(":", "-"); + } + + /// + /// 返回yyyy-MM-dd HH-mm-ss-fff zz时间格式字符串 + /// + public static string ToFileDateTimeFormat(this in DateTime dt, TimeSpan offset) + { + return ToDefaultDateTimeFormat(dt, offset).Replace(":", "-"); + } + + /// + /// 将时间戳转换为 DateTime + /// + /// + /// + internal static DateTime ConvertToDateTime(this long timestamp) + { + var timeStampDateTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var digitCount = (int)Math.Floor(Math.Log10(timestamp) + 1); + + if (digitCount != 13 && digitCount != 10) + { + throw new ArgumentException("Data is not a valid timestamp format."); + } + + return (digitCount == 13 + ? timeStampDateTime.AddMilliseconds(timestamp) // 13 位时间戳 + : timeStampDateTime.AddSeconds(timestamp)).ToLocalTime(); // 10 位时间戳 + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/EndPointExtensions.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/EndPointExtensions.cs new file mode 100644 index 000000000..45312254d --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/EndPointExtensions.cs @@ -0,0 +1,62 @@ +using System.Net; + + +namespace ThingsGateway.NewLife.Extension; + +/// 网络结点扩展 +public static class EndPointExtensions +{ + /// + /// + /// + /// + /// + public static String ToAddress(this EndPoint endpoint) + { + return ((IPEndPoint)endpoint).ToAddress(); + } + + /// + /// + /// + /// + /// + public static String ToAddress(this IPEndPoint endpoint) + { + return String.Format("{0}:{1}", endpoint.Address, endpoint.Port); + } + private static readonly String[] SplitColon = new String[] { ":" }; + /// + /// + /// + /// + /// + public static IPEndPoint ToEndPoint(this String address) + { + var array = address.Split(SplitColon, StringSplitOptions.RemoveEmptyEntries); + if (array.Length != 2) + { + throw new Exception("Invalid endpoint address: " + address); + } + var ip = IPAddress.Parse(array[0]); + var port = Int32.Parse(array[1]); + return new IPEndPoint(ip, port); + } + + private static readonly String[] SplitComma = new String[] { "," }; + /// + /// + /// + /// + /// + public static IEnumerable ToEndPoints(this String addresses) + { + var array = addresses.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); + var list = new List(); + foreach (var item in array) + { + list.Add(item.ToEndPoint()); + } + return list; + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/EnumHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/EnumHelper.cs new file mode 100644 index 000000000..a6663dac0 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/EnumHelper.cs @@ -0,0 +1,111 @@ +using System.ComponentModel; +using System.Reflection; + +namespace ThingsGateway.NewLife; + +/// 枚举类型助手类 +[EditorBrowsable(EditorBrowsableState.Never)] +public static class EnumHelper +{ + /// 枚举变量是否包含指定标识 + /// 枚举变量 + /// 要判断的标识 + /// + public static Boolean Has(this Enum value, Enum flag) + { + if (value.GetType() != flag.GetType()) throw new ArgumentException("flag", "Enumeration identification judgment must be of the same type"); + + var num = Convert.ToUInt64(flag); + return (Convert.ToUInt64(value) & num) == num; + } + + /// 设置标识位 + /// + /// + /// + /// 数值 + /// + public static T Set(this Enum source, T flag, Boolean value) + { + if (source is not T) throw new ArgumentException("source", "Enumeration identification judgment must be of the same type"); + + var s = Convert.ToUInt64(source); + var f = Convert.ToUInt64(flag); + + if (value) + { + s |= f; + } + else + { + s &= ~f; + } + + return (T)Enum.ToObject(typeof(T), s); + } + + /// 获取枚举字段的注释 + /// 数值 + /// + public static String? GetDescription(this Enum value) + { + if (value == null) return null; + + var type = value.GetType(); + var item = type.GetField(value.ToString(), BindingFlags.Public | BindingFlags.Static); + //云飞扬 2017-07-06 传的枚举值可能并不存在,需要判断是否为null + if (item == null) return null; + //var att = AttributeX.GetCustomAttribute(item, false); + var att = item.GetCustomAttribute(false); + if (att != null && !String.IsNullOrEmpty(att.Description)) return att.Description; + + return null; + } + + /// 获取枚举类型的所有字段注释 + /// + /// + public static Dictionary GetDescriptions() where TEnum : notnull + { + var dic = new Dictionary(); + + foreach (var item in GetDescriptions(typeof(TEnum))) + { + dic.Add((TEnum)Enum.ToObject(typeof(TEnum), item.Key), item.Value); + } + + return dic; + } + + /// 获取枚举类型的所有字段注释 + /// + /// + public static Dictionary GetDescriptions(Type enumType) + { + var dic = new Dictionary(); + foreach (var item in enumType.GetFields(BindingFlags.Public | BindingFlags.Static)) + { + if (!item.IsStatic) continue; + + // 这里的快速访问方法会报错 + //FieldInfoX fix = FieldInfoX.Create(item); + //PermissionFlags value = (PermissionFlags)fix.GetValue(null); + var value = Convert.ToInt32(item.GetValue(null)); + + var des = item.Name; + + //var dna = AttributeX.GetCustomAttribute(item, false); + var dna = item.GetCustomAttribute(false); + if (dna != null && !String.IsNullOrEmpty(dna.DisplayName)) des = dna.DisplayName; + + //var att = AttributeX.GetCustomAttribute(item, false); + var att = item.GetCustomAttribute(false); + if (att != null && !String.IsNullOrEmpty(att.Description)) des = att.Description; + //dic.Add(value, des); + // 有些枚举可能不同名称有相同的值 + dic[value] = des; + } + + return dic; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/JsonExtensions.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/JsonExtensions.cs new file mode 100644 index 000000000..800303485 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/JsonExtensions.cs @@ -0,0 +1,66 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Newtonsoft.Json; + +namespace ThingsGateway.NewLife.Json.Extension; + +/// +/// json扩展 +/// +public static class JsonExtensions +{ + /// + /// 默认Json规则 + /// + public static JsonSerializerSettings Options; + static JsonExtensions() + { + Options = new JsonSerializerSettings + { + Formatting = Formatting.Indented,// 使用缩进格式化输出 + NullValueHandling = NullValueHandling.Ignore, // 忽略空值属性 + }; + Options.Converters.Add(new ByteArrayToNumberArrayConverter()); + } + + /// + /// 反序列化 + /// + /// + /// + /// + /// + public static object FromJsonNetString(this string json, Type type, JsonSerializerSettings? jsonSerializerSettings = null) + { + return Newtonsoft.Json.JsonConvert.DeserializeObject(json, type, jsonSerializerSettings ?? Options); + } + /// + /// 反序列化 + /// + /// + /// + /// + public static T FromJsonNetString(this string json, JsonSerializerSettings? jsonSerializerSettings = null) + { + return (T)FromJsonNetString(json, typeof(T), jsonSerializerSettings); + } + + /// + /// 序列化 + /// + /// + /// + /// + public static string ToJsonNetString(this object item, JsonSerializerSettings? jsonSerializerSettings = null) + { + return Newtonsoft.Json.JsonConvert.SerializeObject(item, jsonSerializerSettings ?? Options); + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/LinqExtensions.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/LinqExtensions.cs new file mode 100644 index 000000000..05ae016ce --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/LinqExtensions.cs @@ -0,0 +1,74 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace ThingsGateway.Extension.Generic; + +/// +public static class LinqExtensions +{ + /// + public static ICollection AddIF(this ICollection thisValue, bool isOk, Func predicate) + { + if (isOk) + { + thisValue.Add(predicate()); + } + + return thisValue; + } + + /// + public static void RemoveWhere(this ICollection @this, Func @where) + { + foreach (var obj in @this.Where(where).ToList()) + { + @this.Remove(obj); + } + } + + /// + public static IEnumerable WhereIf(this IEnumerable thisValue, bool isOk, Func predicate) + { + if (isOk) + { + thisValue = thisValue.Where(predicate); + } + return thisValue; + } + + /// + public static void AddRange(this ICollection @this, IEnumerable values) + { + foreach (T value in values) + { + @this.Add(value); + } + } + /// + public static void AddRange(this ICollection @this, params T[] values) + { + foreach (T item in values) + { + @this.Add(item); + } + } + + /// + /// 从并发字典中删除 + /// + public static bool Remove(this ConcurrentDictionary dict, TKey key) where TKey : notnull + { + return dict.TryRemove(key, out TValue? _); + } + + +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/ListExtension.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/ListExtension.cs new file mode 100644 index 000000000..3c07267a1 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/ListExtension.cs @@ -0,0 +1,27 @@ +namespace ThingsGateway.NewLife.Extension; + +/// 扩展List,支持遍历中修改元素 +public static class ListExtension +{ + /// 线程安全,搜索并返回第一个,支持遍历中修改元素 + /// 实体列表 + /// 条件 + /// + public static T? Find(this IList list, Predicate match) + { + if (list is List list2) return list2.Find(match); + + return list.ToArray().FirstOrDefault(e => match(e)); + } + + /// 线程安全,搜索并返回第一个,支持遍历中修改元素 + /// 实体列表 + /// 条件 + /// + public static IList FindAll(this IList list, Predicate match) + { + if (list is List list2) return list2.FindAll(match); + + return list.ToArray().Where(e => match(e)).ToList(); + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/PathExtensions.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/PathExtensions.cs new file mode 100644 index 000000000..ec8fb2416 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/PathExtensions.cs @@ -0,0 +1,47 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +public static class PathExtensions +{ + /// + /// 处理 Windows 和 Linux 路径分隔符不一致问题 + /// + /// + /// + /// + public static string CombinePathWithOs(this string? path, params string[] ps) + { + if (path == null) + { + path = string.Empty; + } + + if (ps == null || ps.Length == 0) + { + return path; + } + + foreach (string text in ps) + { + if (!string.IsNullOrEmpty(text)) + { + path = Path.Combine(path, text); + } + } + // 处理路径分隔符,兼容Windows和Linux + var sep = Path.DirectorySeparatorChar; + var sep2 = sep == '/' ? '\\' : '/'; + path = path.Replace(sep2, sep); + return path; + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/ProcessHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/ProcessHelper.cs new file mode 100644 index 000000000..763a05baa --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/ProcessHelper.cs @@ -0,0 +1,527 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +using ThingsGateway.NewLife.Configuration; +using ThingsGateway.NewLife.Log; + +namespace ThingsGateway.NewLife; + +/// 进程助手类 +/// +/// 文档 https://newlifex.com/core/process_helper +/// +public static class ProcessHelper +{ + #region 进程查找 + /// 获取二级进程名。默认一级,如果是dotnet/java则取二级 + /// + /// + public static String GetProcessName(this Process process) + { + var pname = process.ProcessName; + if (pname == "dotnet" || "*/dotnet".IsMatch(pname)) + { + var args = GetCommandLineArgs(process.Id); + if (args != null && args.Length >= 2 && args[0].Contains("dotnet")) + { + return Path.GetFileNameWithoutExtension(args[1]); + } + } + if (pname == "java" || "*/java".IsMatch(pname)) + { + var args = GetCommandLineArgs(process.Id); + if (args != null && args.Length >= 3 && args[0].Contains("java") && args[1] == "-jar") + { + return Path.GetFileNameWithoutExtension(args[2]); + } + } + + return pname; + } + + /// 获取二级进程名 + /// + /// + [Obsolete("=>GetProcessName", true)] + public static String GetProcessName2(this Process process) => GetProcessName(process); + + ///// 根据名称获取进程。支持dotnet/java + ///// + ///// + //public static IEnumerable GetProcessByName(String name) + //{ + // // 跳过自己 + // var sid = Process.GetCurrentProcess().Id; + // foreach (var p in Process.GetProcesses()) + // { + // if (p.Id == sid) continue; + + // var pname = p.ProcessName; + // if (pname == name) + // yield return p; + // else + // { + // if (GetProcessName2(p) == name) yield return p; + // } + // } + //} + + /// 获取指定进程的命令行参数 + /// + /// + public static String? GetCommandLine(Int32 processId) + { + if (Runtime.Linux) + { + try + { + var file = $"/proc/{processId}/cmdline"; + if (File.Exists(file)) + { + var lines = File.ReadAllText(file).Trim('\0', ' ').Split('\0'); + return lines.Join(" "); + } + } + catch { } + } + else if (Runtime.Windows) + { + return GetCommandLineOnWindows(processId); + } + + return null; + } + + /// 获取指定进程的命令行参数 + /// + /// + public static String[]? GetCommandLineArgs(Int32 processId) + { + if (Runtime.Linux) + { + try + { + var file = $"/proc/{processId}/cmdline"; + if (File.Exists(file)) + { + var lines = File.ReadAllText(file).Trim('\0', ' ').Split('\0'); + //if (lines.Length > 1) return lines[1]; + return lines; + } + } + catch { } + } + else if (Runtime.Windows) + { + var str = GetCommandLineOnWindows(processId); + if (str.IsNullOrEmpty()) return []; + + // 分割参数,特殊支持双引号 + return CommandParser.Split(str); + } + + return null; + } + + private static String? GetCommandLineOnWindows(Int32 processId) + { + var processHandle = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, processId); + if (processHandle == IntPtr.Zero) + return null; + + try + { + var pbi = new PROCESS_BASIC_INFORMATION(); + var status = NtQueryInformationProcess(processHandle, 0, ref pbi, (UInt32)Marshal.SizeOf(pbi), out _); + if (status != 0) return null; + + var rs = ReadStruct(processHandle, pbi.PebBaseAddress, out var peb); + if (!rs) return null; + + rs = ReadStruct(processHandle, peb.ProcessParameters, out var upp); + if (!rs) return null; + + rs = ReadStringUni(processHandle, upp.CommandLine, out var commandLine); + if (!rs) return null; + + return commandLine?.TrimEnd('\0'); + } + finally + { + CloseHandle(processHandle); + } + } + + private static Boolean ReadStruct(IntPtr hProcess, IntPtr lpBaseAddress, out T val) + { + val = default!; + var size = Marshal.SizeOf(typeof(T)); + var ptr = Marshal.AllocHGlobal(size); + try + { + if (ReadProcessMemory(hProcess, lpBaseAddress, ptr, (UInt32)size, out var len) && len == size) + { + val = (T)Marshal.PtrToStructure(ptr, typeof(T))!; + return true; + } + } + finally + { + Marshal.FreeHGlobal(ptr); + } + + return false; + } + + private static Boolean ReadStringUni(IntPtr hProcess, UNICODE_STRING us, out String? val) + { + val = default; + var size = us.MaximumLength; + var ptr = Marshal.AllocHGlobal(size); + try + { + if (ReadProcessMemory(hProcess, us.Buffer, ptr, size, out var len) && len == size) + { + val = Marshal.PtrToStringUni(ptr); + return true; + } + } + finally + { + Marshal.FreeHGlobal(ptr); + } + + return false; + } + #endregion + + #region 进程控制 + /// 安全退出进程,目标进程还有机会执行退出代码 + /// + /// Linux系统下,使用kill命令发送信号,等待一段时间后再Kill。 + /// Windows系统下,使用taskkill命令,等待一段时间后再Kill。 + /// + /// 目标进程 + /// 等待退出的时间。默认5000毫秒 + /// 重试次数 + /// 间隔时间,毫秒 + /// + public static Process? SafetyKill(this Process process, Int32 msWait = 5_000, Int32 times = 50, Int32 interval = 200) + { + if (process == null || process.GetHasExited()) return process; + + //XTrace.WriteLine("安全,温柔一刀!PID={0}/{1}", process.Id, process.ProcessName); + + try + { + if (Runtime.Linux) + { + Process.Start("kill", process.Id.ToString()).WaitForExit(msWait); + + for (var i = 0; i < times && !process.GetHasExited(); i++) + { + Thread.Sleep(interval); + } + } + else if (Runtime.Windows) + { + Process.Start("taskkill", $"-pid {process.Id}").WaitForExit(msWait); + + for (var i = 0; i < times && !process.GetHasExited(); i++) + { + Thread.Sleep(interval); + } + } + } + catch { } + + //if (!process.GetHasExited()) process.Kill(); + + return process; + } + + /// 强制结束进程树,包含子进程 + /// 目标进程 + /// 等待退出的时间。默认5000毫秒 + /// + public static Process? ForceKill(this Process process, Int32 msWait = 5_000) + { + if (process == null || process.GetHasExited()) return process; + + //XTrace.WriteLine("强杀,大力出奇迹!PID={0}/{1}", process.Id, process.ProcessName); + + // 终止指定的进程及启动的子进程,如nginx等 + // 在Core 3.0, Core 3.1, 5, 6, 7, 8, 9 中支持此重载 + // https://learn.microsoft.com/zh-cn/dotnet/api/system.diagnostics.process.kill?view=net-8.0#system-diagnostics-process-kill(system-boolean) +#if NETCOREAPP + process.Kill(true); +#else + process.Kill(); +#endif + if (process.GetHasExited()) return process; + + try + { + if (Runtime.Linux) + { + //-9 SIGKILL 强制终止信号 + Process.Start("kill", $"-9 {process.Id}").WaitForExit(msWait); + } + else if (Runtime.Windows) + { + // /f 指定强制终止进程,有子进程时只能强制 + // /t 终止指定的进程和由它启用的子进程 + Process.Start("taskkill", $"/t /f /pid {process.Id}").WaitForExit(msWait); + } + } + catch { } + + // 兜底再来一次 + if (!process.GetHasExited()) process.Kill(); + + return process; + } + + /// 获取进程是否终止 + public static Boolean GetHasExited(this Process process) + { + try + { + return process.HasExited; + } + catch (Win32Exception) + { + return true; + } + //catch + //{ + // return false; + //} + } + #endregion + + #region 执行命令行 + + /// 以隐藏窗口执行命令行 + /// 文件名 + /// 命令参数 + /// 等待毫秒数 + /// 进程输出内容。默认为空时输出到日志 + /// 进程输出内容。默认为空时输出到日志 + /// 输出内容编码 + /// 进程退出时执行 + /// 工作目录 + /// 进程退出代码 + public static Int32 RunNew(this String cmd, String? arguments = null, Int32 msWait = 0, Action? output = null, Action? error = null, Encoding? encoding = null, Action? onExit = null, String? working = null) + { + if (XTrace.Log.Level <= LogLevel.Debug) XTrace.WriteLine("Run {0} {1} {2}", cmd, arguments, msWait); + + // 修正文件路径 + var fileName = cmd; + //if (!Path.IsPathRooted(fileName) && !working.IsNullOrEmpty()) fileName = working.CombinePath(fileName); + encoding ??= Encoding.UTF8; + using var p = new Process(); + var si = p.StartInfo; + si.FileName = fileName; + if (arguments != null) si.Arguments = arguments; + si.WindowStyle = ProcessWindowStyle.Hidden; + si.CreateNoWindow = true; + if (!String.IsNullOrWhiteSpace(working)) si.WorkingDirectory = working; + // 对于控制台项目,这里需要捕获输出 + if (msWait > 0) + { + si.UseShellExecute = false; + si.RedirectStandardOutput = true; + si.RedirectStandardError = true; + si.StandardOutputEncoding = encoding; + si.StandardErrorEncoding = encoding; + if (output != null) + { + p.OutputDataReceived += (s, e) => output(e.Data); + } + else + { + p.OutputDataReceived += (s, e) => { if (e.Data != null) XTrace.WriteLine(e.Data); }; + } + if (error != null) + { + p.ErrorDataReceived += (s, e) => error(e.Data); + } + else + { + p.ErrorDataReceived += (s, e) => { if (e.Data != null) XTrace.Log.Error(e.Data); }; + } + } + if (onExit != null) p.Exited += (s, e) => { if (s is Process proc) onExit(proc); }; + + p.Start(); + if (msWait > 0) + { + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + } + + if (msWait == 0) return -1; + + // 如果未退出,则不能拿到退出代码 + if (msWait < 0) + p.WaitForExit(); + else if (!p.WaitForExit(msWait)) + { +#if NETCOREAPP + p.Kill(true); +#else + p.Kill(); +#endif + return -1; + } + + return p.ExitCode; + } + + /// + /// 在Shell上执行命令。目标进程不是子进程,不会随着当前进程退出而退出 + /// + /// 文件名 + /// 参数 + /// 工作目录。目标进程的当前目录 + /// + public static Process ShellExecute(this String fileName, String? arguments = null, String? workingDirectory = null) + { + if (XTrace.Log.Level <= LogLevel.Debug) XTrace.WriteLine("ShellExecute {0} {1} {2}", fileName, arguments, workingDirectory); + + //// 修正文件路径 + //if (!Path.IsPathRooted(fileName) && !workingDirectory.IsNullOrEmpty()) fileName = workingDirectory.CombinePath(fileName); + + var p = new Process(); + var si = p.StartInfo; + si.UseShellExecute = true; + si.FileName = fileName; + if (arguments != null) si.Arguments = arguments; + if (workingDirectory != null) si.WorkingDirectory = workingDirectory; + + p.Start(); + + return p; + } + + /// 执行命令并等待返回 + /// 命令 + /// 命令参数 + /// 等待退出的时间。默认0毫秒不等待 + /// 没有标准输出时,是否返回错误内容。默认false + /// + public static String? Execute(this String cmd, String? arguments = null, Int32 msWait = 0, Boolean returnError = false) + { + return Execute(cmd, arguments, msWait, returnError, null); + } + + /// 执行命令并等待返回 + /// 命令 + /// 命令参数 + /// 等待退出的时间。默认0毫秒不等待 + /// 没有标准输出时,是否返回错误内容。默认false + /// 输出字符编码 + /// + public static String? Execute(this String cmd, String? arguments, Int32 msWait, Boolean returnError, Encoding? outputEncoding) + { + try + { + if (XTrace.Log.Level <= LogLevel.Debug) XTrace.WriteLine("Execute {0} {1}", cmd, arguments); + + var psi = new ProcessStartInfo(cmd, arguments ?? String.Empty) + { + // UseShellExecute 必须 false,以便于后续重定向输出流 + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + RedirectStandardOutput = true, + StandardOutputEncoding = outputEncoding, + StandardErrorEncoding = outputEncoding, + }; + var process = Process.Start(psi); + if (process == null) return null; + + if (msWait > 0 && !process.WaitForExit(msWait)) + { +#if NETCOREAPP + process.Kill(true); +#else + process.Kill(); +#endif + return null; + } + + var rs = process.StandardOutput.ReadToEnd(); + if (rs.IsNullOrEmpty() && returnError) rs = process.StandardError.ReadToEnd(); + + return rs; + } + catch { return null; } + } + #endregion + + #region 原生方法 + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr OpenProcess(UInt32 processAccess, Boolean bInheritHandle, Int32 processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern Boolean CloseHandle(IntPtr hObject); + + [DllImport("ntdll.dll")] + private static extern Int32 NtQueryInformationProcess(IntPtr processHandle, Int32 processInformationClass, ref PROCESS_BASIC_INFORMATION processInformation, UInt32 processInformationLength, out UInt32 returnLength); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern Boolean ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, [Out] Byte[] lpBuffer, UInt32 size, out UInt32 lpNumberOfBytesRead); + + [DllImport("kernel32.dll")] + private static extern Boolean ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, IntPtr lpBuffer, UInt32 nSize, out UInt32 lpNumberOfBytesRead); + + private const UInt32 PROCESS_QUERY_INFORMATION = 0x0400; + private const UInt32 PROCESS_VM_READ = 0x0010; + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_BASIC_INFORMATION + { + public IntPtr Reserved1; + public IntPtr PebBaseAddress; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 2)] + public IntPtr[] Reserved2; + public IntPtr UniqueProcessId; + public IntPtr Reserved3; + } + + [StructLayout(LayoutKind.Sequential)] + private struct UNICODE_STRING + { + public UInt16 Length; + public UInt16 MaximumLength; + public IntPtr Buffer; + } + + // This is not the real struct! + // I faked it to get ProcessParameters address. + // Actual struct definition: + // https://learn.microsoft.com/en-us/windows/win32/api/winternl/ns-winternl-peb + [StructLayout(LayoutKind.Sequential)] + private struct PEB + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] + public IntPtr[] Reserved; + public IntPtr ProcessParameters; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RtlUserProcessParameters + { + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 16)] + public Byte[] Reserved1; + [MarshalAs(UnmanagedType.ByValArray, SizeConst = 10)] + public IntPtr[] Reserved2; + public UNICODE_STRING ImagePathName; + public UNICODE_STRING CommandLine; + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/SpeakProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/SpeakProvider.cs new file mode 100644 index 000000000..52caed361 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/SpeakProvider.cs @@ -0,0 +1,92 @@ +using System.Reflection; + +using ThingsGateway.NewLife.Log; +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Extension; + +internal sealed class SpeakProvider +{ + private const String typeName = "System.Speech.Synthesis.SpeechSynthesizer"; + private Type? _type; + + public SpeakProvider() + { + try + { + //_type = typeName.GetTypeEx(true); + _type = Type.GetType(typeName); + if (_type == null) + { + Assembly? asm = null; + try + { + // 新版系统内置 + if (Environment.OSVersion.Version.Major >= 6) + { + asm ??= Assembly.Load("System.Speech, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35"); + } + } + catch { } + try + { + asm ??= Assembly.Load("System.Speech"); + } + catch { } + _type = asm?.GetType(typeName); + } + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + + if (_type == null) XTrace.WriteLine("找不到语音库System.Speech,需要从nuget引用"); + } + + private Object? synth; + + private void EnsureSynth() + { + if (synth == null && _type != null) + { + try + { + synth = _type.CreateInstance(Array.Empty()); + synth?.Invoke("SetOutputToDefaultAudioDevice", Array.Empty()); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + _type = null; + } + } + } + + public void Speak(String value) + { + if (_type == null) return; + + EnsureSynth(); + synth?.Invoke("Speak", value); + } + + public void SpeakAsync(String value) + { + if (_type == null) return; + + EnsureSynth(); + synth?.Invoke("SpeakAsync", value); + } + + /// + /// 停止话音播报 + /// + public void SpeakAsyncCancelAll() + { + if (_type == null) return; + + EnsureSynth(); + synth?.Invoke("SpeakAsyncCancelAll"); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/StringHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/StringHelper.cs new file mode 100644 index 000000000..6e44d7783 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/StringHelper.cs @@ -0,0 +1,986 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +using ThingsGateway.NewLife.Collections; +namespace ThingsGateway.NewLife.Extension; + +/// 字符串助手类 +/// +/// 文档 https://newlifex.com/core/string_helper +/// +public static class StringHelper +{ + #region 字符串扩展 + /// 忽略大小写的字符串相等比较,判断是否与任意一个待比较字符串相等 + /// 字符串 + /// 待比较字符串数组 + /// + public static Boolean EqualIgnoreCase(this String? value, params String?[] strs) + { + foreach (var item in strs) + { + if (String.Equals(value, item, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + /// 忽略大小写的字符串开始比较,判断是否与任意一个待比较字符串开始 + /// 字符串 + /// 待比较字符串数组 + /// + public static Boolean StartsWithIgnoreCase(this String? value, params String?[] strs) + { + if (value == null || String.IsNullOrEmpty(value)) return false; + + foreach (var item in strs) + { + if (!String.IsNullOrEmpty(item) && value.StartsWith(item, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + /// 忽略大小写的字符串结束比较,判断是否以任意一个待比较字符串结束 + /// 字符串 + /// 待比较字符串数组 + /// + public static Boolean EndsWithIgnoreCase(this String? value, params String?[] strs) + { + if (value == null || String.IsNullOrEmpty(value)) return false; + + foreach (var item in strs) + { + if (item != null && value.EndsWith(item, StringComparison.OrdinalIgnoreCase)) return true; + } + return false; + } + + /// 指示指定的字符串是 null 还是 String.Empty 字符串 + /// 字符串 + /// + public static Boolean IsNullOrEmpty([NotNullWhen(false)] this String? value) => value == null || value.Length <= 0; + + /// 是否空或者空白字符串 + /// 字符串 + /// + public static Boolean IsNullOrWhiteSpace([NotNullWhen(false)] this String? value) + { + if (value != null) + { + for (var i = 0; i < value.Length; i++) + { + if (!Char.IsWhiteSpace(value[i])) return false; + } + } + return true; + } +#if !NET6_0_OR_GREATER + public static Boolean EndsWith(this String? value, char charValue) + { + return value.EndsWith(charValue.ToString()); + } + public static Boolean StartsWith(this String? value, char charValue) + { + return value.StartsWith(charValue.ToString()); + } + + public static Boolean Contains(this String? value, string stringValue, StringComparison stringComparison) + { + return value.IndexOf(stringValue, stringComparison) >= 0; + } + +#endif + + /// 拆分字符串,过滤空格,无效时返回空数组 + /// 字符串 + /// 分组分隔符,默认逗号分号 + /// + public static String[] Split(this String? value, params String[] separators) + { + //!! netcore3.0中新增Split(String? separator, StringSplitOptions options = StringSplitOptions.None),优先于StringHelper扩展 + if (value == null || String.IsNullOrEmpty(value)) return []; + if (separators == null || separators.Length <= 0 || separators.Length == 1 && separators[0].IsNullOrEmpty()) separators = [",", ";"]; + + return value.Split(separators, StringSplitOptions.RemoveEmptyEntries); + } + + /// 拆分字符串成为整型数组,默认逗号分号分隔,无效时返回空数组 + /// 过滤空格、过滤无效、不过滤重复 + /// 字符串 + /// 分组分隔符,默认逗号分号 + /// + public static Int32[] SplitAsInt(this String? value, params String[] separators) + { + if (value == null || String.IsNullOrEmpty(value)) return []; + if (separators == null || separators.Length <= 0) separators = [",", ";"]; + + var ss = value.Split(separators, StringSplitOptions.RemoveEmptyEntries); + var list = new List(); + foreach (var item in ss) + { + if (!Int32.TryParse(item.Trim(), out var id)) continue; + + // 本意只是拆分字符串然后转为数字,不应该过滤重复项 + //if (!list.Contains(id)) + list.Add(id); + } + + return list.ToArray(); + } + + /// 拆分字符串成为不区分大小写的可空名值字典。逗号分组,等号分隔 + /// 字符串 + /// 名值分隔符,默认等于号 + /// 分组分隔符,默认分号 + /// 去掉括号 + /// + public static IDictionary SplitAsDictionary(this String? value, String nameValueSeparator = "=", String separator = ";", Boolean trimQuotation = false) + { + var dic = new NullableDictionary(StringComparer.OrdinalIgnoreCase); + if (value == null || value.IsNullOrWhiteSpace()) return dic; + + if (nameValueSeparator.IsNullOrEmpty()) nameValueSeparator = "="; + //if (separator == null || separator.Length <= 0) separator = new String[] { ",", ";" }; + + var ss = value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries); + if (ss == null || ss.Length <= 0) return dic; + + var k = 0; + foreach (var item in ss) + { + var p = item.IndexOf(nameValueSeparator); + if (p <= 0) + { + dic[$"[{k}]"] = item; + k++; + continue; + } + + var key = item[..p].Trim(); + var val = item[(p + nameValueSeparator.Length)..].Trim(); + + // 处理单引号双引号 + if (trimQuotation && !val.IsNullOrEmpty()) + { + if (val[0] == '\'' && val[^1] == '\'') val = val.Trim('\''); + if (val[0] == '"' && val[^1] == '"') val = val.Trim('"'); + } + + k++; + //dic[key] = val; +#if NETFRAMEWORK || NETSTANDARD2_0 + if (!dic.ContainsKey(key)) dic.Add(key, val); +#else + dic.TryAdd(key, val); +#endif + } + + return dic; + } + + ///// + ///// 在.netCore需要区分该部分内容 + ///// + ///// + ///// + ///// + ///// + ///// + //public static IDictionary SplitAsDictionaryT(this String? value, Char nameValueSeparator = '=', Char separator = ';', Boolean trimQuotation = false) + //{ + // var dic = new NullableDictionary(StringComparer.OrdinalIgnoreCase); + // if (value == null || value.IsNullOrWhiteSpace()) return dic; + + // //if (nameValueSeparator == null) nameValueSeparator = '='; + // //if (separator == null || separator.Length <= 0) separator = new String[] { ",", ";" }; + + // var ss = value.Split(new[] { separator }, StringSplitOptions.RemoveEmptyEntries); + // if (ss == null || ss.Length <= 0) return dic; + + // foreach (var item in ss) + // { + // var p = item.IndexOf(nameValueSeparator); + // if (p <= 0) continue; + + // var key = item[..p].Trim(); + // var val = item[(p + 1)..].Trim(); + + + // // 处理单引号双引号 + // if (trimQuotation && !val.IsNullOrEmpty()) + // { + // if (val[0] == '\'' && val[^1] == '\'') val = val.Trim('\''); + // if (val[0] == '"' && val[^1] == '"') val = val.Trim('"'); + // } + + // dic[key] = val; + // } + + // return dic; + //} + + /// 把一个列表组合成为一个字符串,默认逗号分隔 + /// + /// 组合分隔符,默认逗号 + /// + public static String Join(this IEnumerable value, String separator = ",") + { + var sb = Pool.StringBuilder.Get(); + if (value != null) + { + foreach (var item in value) + { + sb.Separate(separator).Append(item + ""); + } + } + return sb.Return(true); + } + + ///// 把一个列表组合成为一个字符串,默认逗号分隔 + ///// + ///// 组合分隔符,默认逗号 + ///// 把对象转为字符串的委托 + ///// + //[Obsolete] + //public static String Join(this IEnumerable value, String separator, Func? func) + //{ + // var sb = Pool.StringBuilder.Get(); + // if (value != null) + // { + // if (func == null) func = obj => obj + ""; + // foreach (var item in value) + // { + // sb.Separate(separator).Append(func(item)); + // } + // } + // return sb.Put(true); + //} + + /// 把一个列表组合成为一个字符串,默认逗号分隔 + /// + /// 组合分隔符,默认逗号 + /// 把对象转为字符串的委托 + /// + public static String Join(this IEnumerable value, String separator = ",", Func? func = null) + { + var sb = Pool.StringBuilder.Get(); + if (value != null) + { + func ??= obj => obj; + foreach (var item in value) + { + sb.Separate(separator).Append(func(item)); + } + } + return sb.Return(true); + } + + /// 追加分隔符字符串,忽略开头,常用于拼接 + /// 字符串构造者 + /// 分隔符 + /// + public static StringBuilder Separate(this StringBuilder sb, String separator) + { + if (/*sb == null ||*/ String.IsNullOrEmpty(separator)) return sb; + + if (sb.Length > 0) sb.Append(separator); + + return sb; + } + + /// 字符串转数组 + /// 字符串 + /// 编码,默认utf-8无BOM + /// + public static Byte[] GetBytes(this String? value, Encoding? encoding = null) + { + //if (value == null) return null; + if (String.IsNullOrEmpty(value)) return []; + + encoding ??= Encoding.UTF8; + return encoding.GetBytes(value); + } + + /// 格式化字符串。特别支持无格式化字符串的时间参数 + /// 格式字符串 + /// 参数 + /// + [Obsolete("建议使用插值字符串")] + public static String F(this String value, params Object?[] args) + { + if (String.IsNullOrEmpty(value)) return value; + + // 特殊处理时间格式化。这些年,无数项目实施因为时间格式问题让人发狂 + for (var i = 0; i < args.Length; i++) + { + if (args[i] is DateTime dt) + { + // 没有写格式化字符串的时间参数,一律转为标准时间字符串 + if (value.Contains("{" + i + "}")) args[i] = dt.ToFullString(); + } + } + + return String.Format(value, args); + } + + /// 指定输入是否匹配目标表达式,支持*匹配 + /// 匹配表达式 + /// 输入字符串 + /// 字符串比较方式 + /// + public static Boolean IsMatch(this String pattern, String input, StringComparison comparisonType = StringComparison.CurrentCulture) + { + if (pattern.IsNullOrEmpty()) return false; + + // 单独*匹配所有,即使输入字符串为空 + if (pattern == "*") return true; + if (input.IsNullOrEmpty()) return false; + + // 普通表达式,直接包含 + var p = pattern.IndexOf('*'); + if (p < 0) return String.Equals(input, pattern, comparisonType); + + // 表达式分组 + var ps = pattern.Split('*'); + + // 头尾专用匹配 + if (ps.Length == 2) + { + if (p == 0) return input.EndsWith(ps[1], comparisonType); + if (p == pattern.Length - 1) return input.StartsWith(ps[0], comparisonType); + } + + // 逐项跳跃式匹配 + p = 0; + for (var i = 0; i < ps.Length; i++) + { + // 最后一组反向匹配 + if (i == ps.Length - 1) + p = input.LastIndexOf(ps[i], input.Length - 1, input.Length - p, comparisonType); + else + p = input.IndexOf(ps[i], p, comparisonType); + if (p < 0) return false; + + // 第一组必须开头 + if (i == 0 && p > 0) return false; + + p += ps[i].Length; + } + + // 最后一组*允许不到边界 + if (ps[^1].IsNullOrEmpty()) return p <= input.Length; + + // 最后一组必须结尾 + return p == input.Length; + } + +#if NETFRAMEWORK || NETSTANDARD2_0 + /// Returns a value indicating whether a specified character occurs within this string. + /// + /// The character to seek. + /// + /// if the parameter occurs within this string; otherwise, . + public static Boolean Contains(this String value, Char inputChar) => value.IndexOf(inputChar) >= 0; + + /// Splits a string into substrings based on the characters in an array. You can specify whether the substrings include empty array elements. + /// + /// A character array that delimits the substrings in this string, an empty array that contains no delimiters, or . + /// + /// to omit empty array elements from the array returned; or to include empty array elements in the array returned. + /// An array whose elements contain the substrings in this string that are delimited by one or more characters in . For more information, see the Remarks section. + /// + /// is not one of the values. + public static String[] Split(this String value, Char separator, StringSplitOptions options = StringSplitOptions.None) => value.Split(new Char[] { separator }, options); +#endif + #endregion + + #region 截取扩展 + /// 确保字符串以指定的另一字符串开始,不区分大小写 + /// 字符串 + /// + /// + public static String EnsureStart(this String? str, String start) + { + if (String.IsNullOrEmpty(start)) return str + ""; + if (String.IsNullOrEmpty(str) || str == null) return start + ""; + + if (str.StartsWith(start, StringComparison.OrdinalIgnoreCase)) return str; + + return start + str; + } + + /// 确保字符串以指定的另一字符串结束,不区分大小写 + /// 字符串 + /// + /// + public static String EnsureEnd(this String? str, String end) + { + if (String.IsNullOrEmpty(end)) return str + ""; + if (String.IsNullOrEmpty(str) || str == null) return end + ""; + + if (str.EndsWith(end, StringComparison.OrdinalIgnoreCase)) return str; + + return str + end; + } + + /// 从当前字符串开头移除另一字符串,不区分大小写,循环多次匹配前缀 + /// 当前字符串 + /// 另一字符串 + /// + public static String TrimStart(this String str, params String[] starts) + { + if (String.IsNullOrEmpty(str)) return str; + if (starts == null || starts.Length <= 0 || String.IsNullOrEmpty(starts[0])) return str; + + for (var i = 0; i < starts.Length; i++) + { + if (str.StartsWith(starts[i], StringComparison.OrdinalIgnoreCase)) + { + str = str[starts[i].Length..]; + if (String.IsNullOrEmpty(str)) break; + + // 从头开始 + i = -1; + } + } + return str; + } + + /// 从当前字符串结尾移除另一字符串,不区分大小写,循环多次匹配后缀 + /// 当前字符串 + /// 另一字符串 + /// + public static String TrimEnd(this String str, params String[] ends) + { + if (String.IsNullOrEmpty(str)) return str; + if (ends == null || ends.Length <= 0 || String.IsNullOrEmpty(ends[0])) return str; + + for (var i = 0; i < ends.Length; i++) + { + if (str.EndsWith(ends[i], StringComparison.OrdinalIgnoreCase)) + { + str = str[..^ends[i].Length]; + if (String.IsNullOrEmpty(str)) break; + + // 从头开始 + i = -1; + } + } + return str; + } + + /// 修剪不可见字符。仅修剪ASCII,不包含Unicode + /// + /// + public static String? TrimInvisible(this String? value) + { + if (value.IsNullOrEmpty()) return value; + + var builder = new StringBuilder(); + + for (var i = 0; i < value.Length; i++) + { + // 可见字符。ASCII码中,第0~31号及第127号(共33个)是控制字符或通讯专用字符 + if (value[i] is > (Char)31 and not (Char)127) + builder.Append(value[i]); + } + + return builder.ToString(); + } + + /// 从字符串中检索子字符串,在指定头部字符串之后,指定尾部字符串之前 + /// 常用于截取xml某一个元素等操作 + /// 目标字符串 + /// 头部字符串,在它之后 + /// 尾部字符串,在它之前 + /// 搜索的开始位置 + /// 位置数组,两个元素分别记录头尾位置 + /// + public static String Substring(this String str, String? after, String? before = null, Int32 startIndex = 0, Int32[]? positions = null) + { + if (String.IsNullOrEmpty(str)) return str; + if (String.IsNullOrEmpty(after) && String.IsNullOrEmpty(before)) return str; + + /* + * 1,只有start,从该字符串之后部分 + * 2,只有end,从开头到该字符串之前 + * 3,同时start和end,取中间部分 + */ + + var p = -1; + if (!after.IsNullOrEmpty()) + { + p = str.IndexOf(after, startIndex); + if (p < 0) return String.Empty; + p += after.Length; + + // 记录位置 + if (positions != null && positions.Length > 0) positions[0] = p; + } + + if (String.IsNullOrEmpty(before)) return str[p..]; + + var f = str.IndexOf(before, p >= 0 ? p : startIndex); + if (f < 0) return String.Empty; + + // 记录位置 + if (positions != null && positions.Length > 1) positions[1] = f; + + if (p >= 0) + return str[p..f]; + else + return str[..f]; + } + + /// 根据最大长度截取字符串,并允许以指定空白填充末尾 + /// 字符串 + /// 截取后字符串的最大允许长度,包含后面填充 + /// 需要填充在后面的字符串,比如几个圆点 + /// + public static String Cut(this String str, Int32 maxLength, String? pad = null) + { + if (String.IsNullOrEmpty(str) || maxLength <= 0 || str.Length < maxLength) return str; + + // 计算截取长度 + var len = maxLength; + if (pad != null && !String.IsNullOrEmpty(pad)) len -= pad.Length; + if (len <= 0) throw new ArgumentOutOfRangeException(nameof(maxLength)); + + return str[..len] + pad; + } + + /// 从当前字符串开头移除另一字符串以及之前的部分 + /// 当前字符串 + /// 另一字符串 + /// + public static String CutStart(this String str, params String[] starts) + { + if (str.IsNullOrEmpty()) return str; + if (starts == null || starts.Length <= 0 || starts[0].IsNullOrEmpty()) return str; + + for (var i = 0; i < starts.Length; i++) + { + var p = str.IndexOf(starts[i]); + if (p >= 0) + { + str = str[(p + starts[i].Length)..]; + if (str.IsNullOrEmpty()) break; + } + } + return str; + } + + /// 从当前字符串结尾移除另一字符串以及之后的部分 + /// 当前字符串 + /// 另一字符串 + /// + public static String CutEnd(this String str, params String[] ends) + { + if (String.IsNullOrEmpty(str)) return str; + if (ends == null || ends.Length <= 0 || String.IsNullOrEmpty(ends[0])) return str; + + for (var i = 0; i < ends.Length; i++) + { + var p = str.LastIndexOf(ends[i]); + if (p >= 0) + { + str = str[..p]; + if (String.IsNullOrEmpty(str)) break; + } + } + return str; + } + #endregion + + #region LD编辑距离算法 + private static readonly Char[] _separator = [' ', ' ']; + /// 编辑距离搜索,从词组中找到最接近关键字的若干匹配项 + /// + /// 算法代码由@Aimeast 独立完成。http://www.cnblogs.com/Aimeast/archive/2011/09/05/2167844.html + /// + /// 关键字 + /// 词组 + /// + public static String[] LevenshteinSearch(String key, String[] words) + { + if (IsNullOrWhiteSpace(key)) return []; + + var keys = key.Split(_separator, StringSplitOptions.RemoveEmptyEntries); + + foreach (var item in keys) + { + var maxDist = (item.Length - 1) / 2; + + var q = from str in words + where item.Length <= str.Length + && Enumerable.Range(0, maxDist + 1) + .Any(dist => + { + return Enumerable.Range(0, Math.Max(str.Length - item.Length - dist + 1, 0)) + .Any(f => + { + return LevenshteinDistance(item, str.Substring(f, item.Length + dist)) <= maxDist; + }); + }) + orderby str + select str; + words = q.ToArray(); + } + + return words; + } + + /// 编辑距离 + /// + /// 又称Levenshtein距离(也叫做Edit Distance),是指两个字串之间,由一个转成另一个所需的最少编辑操作次数。 + /// 许可的编辑操作包括将一个字符替换成另一个字符,插入一个字符,删除一个字符。 + /// 算法代码由@Aimeast 独立完成。http://www.cnblogs.com/Aimeast/archive/2011/09/05/2167844.html + /// + /// + /// + /// + public static Int32 LevenshteinDistance(String str1, String str2) + { + var n = str1.Length; + var m = str2.Length; + var C = new Int32[n + 1, m + 1]; + Int32 i, j, x, y, z; + for (i = 0; i <= n; i++) + C[i, 0] = i; + for (i = 1; i <= m; i++) + C[0, i] = i; + for (i = 0; i < n; i++) + for (j = 0; j < m; j++) + { + x = C[i, j + 1] + 1; + y = C[i + 1, j] + 1; + if (str1[i] == str2[j]) + z = C[i, j]; + else + z = C[i, j] + 1; + C[i + 1, j + 1] = Math.Min(Math.Min(x, y), z); + } + return C[n, m]; + } + #endregion + + #region LCS算法 + private static readonly Char[] _separator2 = [' ', '\u3000']; + /// 最长公共子序列搜索,从词组中找到最接近关键字的若干匹配项 + /// + /// 算法代码由@Aimeast 独立完成。http://www.cnblogs.com/Aimeast/archive/2011/09/05/2167844.html + /// + /// + /// + /// + public static String[] LCSSearch(String key, String[] words) + { + if (IsNullOrWhiteSpace(key) || words == null || words.Length == 0) return []; + + var keys = key + .Split(_separator2, StringSplitOptions.RemoveEmptyEntries) + .OrderBy(s => s.Length) + .ToArray(); + + //var q = from sentence in items.AsParallel() + var q = from word in words + let MLL = LCSDistance(word, keys) + where MLL >= 0 + orderby (MLL + 0.5) / word.Length, word + select word; + + return q.ToArray(); + } + + /// + /// 最长公共子序列问题是寻找两个或多个已知数列最长的子序列。 + /// 一个数列 S,如果分别是两个或多个已知数列的子序列,且是所有符合此条件序列中最长的,则 S 称为已知序列的最长公共子序列。 + /// The longest common subsequence (LCS) problem is to find the longest subsequence common to all sequences in a set of sequences (often just two). Note that subsequence is different from a substring, see substring vs. subsequence. It is a classic computer science problem, the basis of diff (a file comparison program that outputs the differences between two files), and has applications in bioinformatics. + /// + /// + /// 算法代码由@Aimeast 独立完成。http://www.cnblogs.com/Aimeast/archive/2011/09/05/2167844.html + /// + /// + /// 多个关键字。长度必须大于0,必须按照字符串长度升序排列。 + /// + public static Int32 LCSDistance(String word, String[] keys) + { + var sLength = word.Length; + var result = sLength; + var flags = new Boolean[sLength]; + var C = new Int32[sLength + 1, keys[^1].Length + 1]; + //int[,] C = new int[sLength + 1, words.Select(s => s.Length).Max() + 1]; + foreach (var key in keys) + { + var wLength = key.Length; + Int32 first = 0, last = 0; + Int32 i = 0, j = 0, LCS_L; + //foreach 速度会有所提升,还可以加剪枝 + for (i = 0; i < sLength; i++) + for (j = 0; j < wLength; j++) + if (word[i] == key[j]) + { + C[i + 1, j + 1] = C[i, j] + 1; + if (first < C[i, j]) + { + last = i; + first = C[i, j]; + } + } + else + C[i + 1, j + 1] = Math.Max(C[i, j + 1], C[i + 1, j]); + + LCS_L = C[i, j]; + if (LCS_L <= wLength >> 1) + return -1; + + while (i > 0 && j > 0) + { + if (C[i - 1, j - 1] + 1 == C[i, j]) + { + i--; + j--; + if (!flags[i]) + { + flags[i] = true; + result--; + } + first = i; + } + else if (C[i - 1, j] == C[i, j]) + i--; + else// if (C[i, j - 1] == C[i, j]) + j--; + } + + if (LCS_L <= (last - first + 1) >> 1) + return -1; + } + + return result; + } + + /// 根据列表项成员计算距离 + /// + /// + /// + /// + /// + public static IEnumerable> LCS(this IEnumerable list, String keys, Func keySelector) + { + var rs = new List>(); + + if (list == null || !list.Any()) return rs; + if (keys.IsNullOrWhiteSpace()) return rs; + if (keySelector == null) throw new ArgumentNullException(nameof(keySelector)); + + var ks = keys.Split(' ').OrderBy(_ => _.Length).ToArray(); + + // 计算每个项到关键字的距离 + foreach (var item in list) + { + var name = keySelector(item); + if (name.IsNullOrEmpty()) continue; + + var dist = LCSDistance(name, ks); + if (dist >= 0) + { + var val = (Double)dist / name.Length; + rs.Add(new KeyValuePair(item, val)); + } + } + + //return rs.OrderBy(e => e.Value); + return rs; + } + + /// 在列表项中进行模糊搜索 + /// + /// + /// + /// + /// + /// + public static IEnumerable LCSSearch(this IEnumerable list, String keys, Func keySelector, Int32 count = -1) + { + var rs = LCS(list, keys, keySelector); + + if (count >= 0) + rs = rs.OrderBy(e => e.Value).Take(count); + else + rs = rs.OrderBy(e => e.Value); + + return rs.Select(e => e.Key); + } + #endregion + + #region 字符串模糊匹配 + /// 模糊匹配 + /// + /// + /// + /// + /// + public static IList> Match(this IEnumerable list, String keys, Func keySelector) + { + var rs = new List>(); + + if (list == null || !list.Any()) return rs; + if (keys.IsNullOrWhiteSpace()) return rs; + if (keySelector == null) throw new ArgumentNullException(nameof(keySelector)); + + var ks = keys.Split(' ').OrderBy(_ => _.Length).ToArray(); + + // 计算每个项到关键字的权重 + foreach (var item in list) + { + var name = keySelector(item); + if (name.IsNullOrEmpty()) continue; + + var dist = ks.Sum(e => + { + var kv = Match(name, e, e.Length); + return kv.Key - kv.Value * 0.1; + }); + if (dist > 0) + { + var val = dist / keys.Length; + //var val = dist; + rs.Add(new KeyValuePair(item, val)); + } + } + + return rs; + } + + /// 模糊匹配 + /// + /// + /// + /// + public static KeyValuePair Match(String str, String key, Int32 maxError = 0) + { + /* + * 字符串 abcdef + * 少字符 ace (3, 0) + * 多字符 abkcd (4, 1) + * 改字符 abmd (3, 1) + */ + + // str下一次要匹配的位置 + var m = 0; + // key下一次要匹配的位置 + var k = 0; + + // 总匹配数 + var match = 0; + // 跳过次数 + var skip = 0; + + while (skip <= maxError && k < key.Length) + { + // 向前逐个匹配 + for (var i = m; i < str.Length; i++) + { + if (str[i] == key[k]) + { + k++; + m = i + 1; + match++; + + // 如果已完全匹配,则结束 + if (k == key.Length) break; + } + } + + // 如果已完全匹配,则结束 + if (k == key.Length) break; + + // 没有完全匹配,跳过关键字中的一个字符串,从上一次匹配后面继续找 + k++; + skip++; + } + + return new KeyValuePair(match, skip); + } + + /// 模糊匹配 + /// + /// 列表项 + /// 关键字 + /// 匹配字符串选择 + /// 获取个数 + /// 权重阀值 + /// + public static IEnumerable Match(this IEnumerable list, String keys, Func keySelector, Int32 count, Double confidence = 0.5) + { + var rs = Match(list, keys, keySelector).Where(e => e.Value >= confidence); + + if (count >= 0) + rs = rs.OrderByDescending(e => e.Value).Take(count); + else + rs = rs.OrderByDescending(e => e.Value); + + return rs.Select(e => e.Key); + } + #endregion + + #region 文字转语音 + private static ThingsGateway.NewLife.Extension.SpeakProvider? _provider; + //private static System.Speech.Synthesis.SpeechSynthesizer _provider; + [MemberNotNull(nameof(_provider))] + private static void Init() + { + //_provider = new Speech.Synthesis.SpeechSynthesizer(); + //_provider.SetOutputToDefaultAudioDevice(); + _provider ??= new ThingsGateway.NewLife.Extension.SpeakProvider(); + } + + /// 调用语音引擎说出指定话 + /// + public static void Speak(this String value) + { + Init(); + + _provider.Speak(value); + } + + /// 异步调用语音引擎说出指定话。可能导致后来的调用打断前面的语音 + /// + public static void SpeakAsync(this String value) + { + Init(); + + _provider.SpeakAsync(value); + } + + /// 启用语音提示 + public static Boolean EnableSpeechTip { get; set; } = true; + + /// 语音提示操作 + /// + public static void SpeechTip(this String value) + { + if (!EnableSpeechTip) return; + + try + { + SpeakAsync(value); + } + catch { } + } + + /// + /// 停止所有语音播报 + /// + /// + public static String SpeakAsyncCancelAll(this String value) + { + Init(); + + _provider.SpeakAsyncCancelAll(); + + return value; + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Extension/TextWriterExtensions.cs b/src/Admin/ThingsGateway.NewLife.X/Extension/TextWriterExtensions.cs new file mode 100644 index 000000000..5a74ace73 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Extension/TextWriterExtensions.cs @@ -0,0 +1,94 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +/// TextWriter扩展 +/// +public static class TextWriterExtensions +{ + private const string DefaultBackgroundColor = "\x1B[49m"; + private const string DefaultForegroundColor = "\x1B[39m\x1B[22m"; + + /// + /// 写入流 + /// + /// + /// + /// + /// + public static void WriteWithColor( + this TextWriter textWriter, + string message, + ConsoleColor? background, + ConsoleColor? foreground) + { + var backgroundColor = background.HasValue ? GetBackgroundColorEscapeCode(background.Value) : null; + var foregroundColor = foreground.HasValue ? GetForegroundColorEscapeCode(foreground.Value) : null; + + if (backgroundColor is not null) + { + textWriter.Write(backgroundColor); + } + if (foregroundColor is not null) + { + textWriter.Write(foregroundColor); + } + + textWriter.WriteLine(message); + + if (foregroundColor is not null) + { + textWriter.Write(DefaultForegroundColor); + } + if (backgroundColor is not null) + { + textWriter.Write(DefaultBackgroundColor); + } + } + + private static string GetBackgroundColorEscapeCode(ConsoleColor color) => + color switch + { + ConsoleColor.Black => "\x1B[40m", + ConsoleColor.DarkRed => "\x1B[41m", + ConsoleColor.DarkGreen => "\x1B[42m", + ConsoleColor.DarkYellow => "\x1B[43m", + ConsoleColor.DarkBlue => "\x1B[44m", + ConsoleColor.DarkMagenta => "\x1B[45m", + ConsoleColor.DarkCyan => "\x1B[46m", + ConsoleColor.Gray => "\x1B[47m", + + _ => DefaultBackgroundColor + }; + + private static string GetForegroundColorEscapeCode(ConsoleColor color) => + color switch + { + ConsoleColor.Black => "\x1B[30m", + ConsoleColor.DarkRed => "\x1B[31m", + ConsoleColor.DarkGreen => "\x1B[32m", + ConsoleColor.DarkYellow => "\x1B[33m", + ConsoleColor.DarkBlue => "\x1B[34m", + ConsoleColor.DarkMagenta => "\x1B[35m", + ConsoleColor.DarkCyan => "\x1B[36m", + ConsoleColor.Gray => "\x1B[37m", + ConsoleColor.Red => "\x1B[1m\x1B[31m", + ConsoleColor.Green => "\x1B[1m\x1B[32m", + ConsoleColor.Yellow => "\x1B[1m\x1B[33m", + ConsoleColor.Blue => "\x1B[1m\x1B[34m", + ConsoleColor.Magenta => "\x1B[1m\x1B[35m", + ConsoleColor.Cyan => "\x1B[1m\x1B[36m", + ConsoleColor.White => "\x1B[1m\x1B[37m", + + _ => DefaultForegroundColor + }; +} diff --git a/src/Admin/ThingsGateway.NewLife.X/GlobalUsings.cs b/src/Admin/ThingsGateway.NewLife.X/GlobalUsings.cs new file mode 100644 index 000000000..925e9e052 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/GlobalUsings.cs @@ -0,0 +1,11 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +global using ThingsGateway.NewLife.Extension; diff --git a/src/Admin/ThingsGateway.NewLife.X/IO/CsvFile.cs b/src/Admin/ThingsGateway.NewLife.X/IO/CsvFile.cs new file mode 100644 index 000000000..356e8eb9c --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/IO/CsvFile.cs @@ -0,0 +1,272 @@ +using System.Text; + +using ThingsGateway.NewLife.Collections; + +namespace ThingsGateway.NewLife.IO; + +/// Csv文件 +/// +/// 文档 https://newlifex.com/core/csv_file +/// 支持整体读写以及增量式读写,目标是读写超大Csv文件 +/// +#if NET5_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER +public class CsvFile : IDisposable, IAsyncDisposable +#else +public class CsvFile : IDisposable +#endif +{ + #region 属性 + /// 文件编码 + public Encoding Encoding { get; set; } = Encoding.UTF8; + + private readonly Stream _stream; + private readonly Boolean _leaveOpen; + + /// 分隔符。默认逗号 + public Char Separator { get; set; } = ','; + #endregion + + #region 构造 + /// 数据流实例化 + /// + public CsvFile(Stream stream) => _stream = stream; + + /// 数据流实例化 + /// + /// 保留打开 + public CsvFile(Stream stream, Boolean leaveOpen) + { + _stream = stream; + _leaveOpen = leaveOpen; + } + + /// Csv文件实例化 + /// + /// + public CsvFile(String file, Boolean write = false) + { + file = file.GetFullPath(); + if (write) + _stream = new FileStream(file.EnsureDirectory(true), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); + else + _stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + + private Boolean _disposed; + /// 销毁 + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// 销毁 + /// + protected virtual void Dispose(Boolean disposing) + { + if (_disposed) return; + _disposed = true; + + // 必须刷新写入器,否则可能丢失一截数据 + _writer?.Flush(); + + if (!_leaveOpen && _stream != null) + { + _reader.TryDispose(); + + _writer.TryDispose(); + + _stream.Close(); + } + } + +#if NET5_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER + /// 异步销毁 + /// + public virtual async ValueTask DisposeAsync() + { + if (_disposed) return; + _disposed = true; + + // 必须刷新写入器,否则可能丢失一截数据 + if (_writer != null) await _writer.FlushAsync().ConfigureAwait(false); + + if (!_leaveOpen && _stream != null) + { + _reader.TryDispose(); + + if (_writer != null) await _writer.DisposeAsync().ConfigureAwait(false); + + await _stream.DisposeAsync().ConfigureAwait(false); + } + + GC.SuppressFinalize(this); + } +#endif + #endregion + + #region 读取 + private Int32 _columnCount; + /// 读取一行 + /// + public String[]? ReadLine() + { + EnsureReader(); + + var line = _reader?.ReadLine(); + if (line == null) return null; + + var list = new List(); + + // 直接分解,引号合并 + var arr = line.Split(Separator); + // 如果字段数不足,可能有换行符,读取后面的行 + while (_columnCount > 0 && arr.Length < _columnCount) + { + var next = _reader?.ReadLine(); + if (next == null) break; + + line += Environment.NewLine + next; + + arr = line.Split(Separator); + } + for (var i = 0; i < arr.Length; i++) + { + var txt = (arr[i] + "").Trim(); + if (txt.Length >= 2 && txt[0] == '\"' && txt[^1] == '\"') + { + txt = txt[1..^1]; + + // 两个引号是一个引号的转义 + txt = txt.Replace("\"\"", "\""); + } + + list.Add(txt); + } + + // 记录列数 + if (_columnCount == 0 && list.Count > 0) _columnCount = list.Count; + + return list.ToArray(); + } + + /// 读取所有行 + /// + public IEnumerable ReadAll() + { + while (true) + { + var line = ReadLine(); + if (line == null) break; + + yield return line; + } + } + + private StreamReader? _reader; + private void EnsureReader() + { + _reader ??= new StreamReader(_stream, Encoding); + } + #endregion + + #region 写入 + /// 写入全部 + /// + public void WriteAll(IEnumerable> data) + { + foreach (var line in data) + { + WriteLine(line); + } + } + + /// 写入一行 + /// + public void WriteLine(IEnumerable line) + { + EnsureWriter(); + + if (_writer == null) throw new ArgumentNullException(nameof(_writer)); + + var str = BuildLine(line); + + _writer.WriteLine(str); + } + + /// + /// 写入一行 + /// + /// + public void WriteLine(params Object[] values) => WriteLine(line: values); + + /// 异步写入一行 + /// + public async Task WriteLineAsync(IEnumerable line) + { + EnsureWriter(); + + if (_writer == null) throw new ArgumentNullException(nameof(_writer)); + + var str = BuildLine(line); + + await _writer.WriteLineAsync(str).ConfigureAwait(false); + } + + /// 构建一行 + /// + /// + protected virtual String BuildLine(IEnumerable line) + { + var sb = Pool.StringBuilder.Get(); + + foreach (var item in line) + { + if (sb.Length > 0) sb.Append(Separator); + + if (item is DateTime dt) + { + sb.Append(dt.ToFullString("")); + } + else if (item is Boolean b) + { + sb.Append(b ? "1" : "0"); + } + else + { + if (item is not String str) str = item + ""; + + // 避免出现科学计数问题 数据前增加制表符"\t" + // 不同软件显示不太一样 wps超过9位就自动转为科学计数,有的软件是超过11位,所以采用最小范围9 + if (str.Length > 9 && Int64.TryParse(str, out _)) + { + sb.Append('\t'); + sb.Append(str); + } + else if (str.Contains('"')) + { + sb.Append('\"'); + sb.Append(str.Replace("\"", "\"\"")); + sb.Append('\"'); + } + else if (str.Contains(Separator) || str.Contains('\r') || str.Contains('\n')) + { + sb.Append('\"'); + sb.Append(str); + sb.Append('\"'); + } + else + sb.Append(str); + } + } + + return sb.Return(true); + } + + private StreamWriter? _writer; + private void EnsureWriter() + { + _writer ??= new StreamWriter(_stream, Encoding, 1024, _leaveOpen); + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/IO/EncodingHelper.cs b/src/Admin/ThingsGateway.NewLife.X/IO/EncodingHelper.cs new file mode 100644 index 000000000..4a12177bb --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/IO/EncodingHelper.cs @@ -0,0 +1,369 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; + +namespace ThingsGateway.NewLife.IO +{ + /// 编码助手 + public static class EncodingHelper + { + #region 编码检测 + /// 检测文件编码 + /// 文件名 + /// + public static Encoding Detect(String filename) + { + using var fs = File.OpenRead(filename); + return Detect(fs); + } + + /// 检测文件编码 + /// + /// + public static Encoding DetectEncoding(this FileInfo file) + { + using var fs = file.OpenRead(); + return fs.Detect(); + } + + /// 检测数据流编码 + /// 数据流 + /// BOM检测失败时用于启发式探索的数据大小 + /// + public static Encoding Detect(this Stream stream, Int64 sampleSize = 0x400) + { + // 记录数据流原始位置,后面需要复原 + var pos = stream.Position; + stream.Position = 0; + + // 首先检查BOM + var boms = new Byte[stream.Length > 4 ? 4 : stream.Length]; + stream.Read(boms, 0, boms.Length); + + var encoding = DetectBOM(boms); + if (encoding != null) + { + stream.Position = pos; + return encoding; + } + + // BOM检测失败,开始启发式探测 + // 抽查一段字节数组 + var data = new Byte[sampleSize > stream.Length ? stream.Length : sampleSize]; + Array.Copy(boms, data, boms.Length); + if (stream.Length > boms.Length) stream.Read(data, boms.Length, data.Length - boms.Length); + stream.Position = pos; + + return DetectInternal(data); + } + + /// 检测字节数组编码 + /// 字节数组 + /// + public static Encoding Detect(this Byte[] data) + { + // 探测BOM头 + var encoding = DetectBOM(data); + if (encoding != null) return encoding; + + return DetectInternal(data); + } + + private static Encoding DetectInternal(Byte[] data) + { + Encoding encoding = null; + // 最笨的办法尝试 + var encs = new Encoding[] { + // 常用 + Encoding.UTF8, + // 用户界面选择语言编码 + Encoding.GetEncoding(CultureInfo.CurrentUICulture.TextInfo.ANSICodePage), + // 本地默认编码 + Encoding.UTF8 + }; + encs = encs.Where(s => s != null).GroupBy(s => s.CodePage).Select(s => s.First()).ToArray(); + + // 如果有单字节编码,优先第一个非单字节的编码 + foreach (var enc in encs) + { + if (IsMatch(data, enc)) + { + if (!enc.IsSingleByte) return enc; + + if (encoding == null) encoding = enc; + } + } + if (encoding != null) return encoding; + + // 探测Unicode编码 + encoding = DetectUnicode(data); + if (encoding != null) return encoding; + + // 简单方法探测ASCII + encoding = DetectASCII(data); + if (encoding != null) return encoding; + + return null; + } + + /// 检测BOM字节序 + /// + /// + public static Encoding DetectBOM(this Byte[] boms) + { + if (boms.Length < 2) return null; + + if (boms[0] == 0xff && boms[1] == 0xfe && (boms.Length < 4 || boms[2] != 0 || boms[3] != 0)) return Encoding.Unicode; + + if (boms[0] == 0xfe && boms[1] == 0xff) return Encoding.BigEndianUnicode; + + if (boms.Length < 3) return null; + + if (boms[0] == 0xef && boms[1] == 0xbb && boms[2] == 0xbf) return Encoding.UTF8; + +#if !NETCOREAPP + if (boms[0] == 0x2b && boms[1] == 0x2f && boms[2] == 0x76) return Encoding.UTF7; +#endif + + if (boms.Length < 4) return null; + + if (boms[0] == 0xff && boms[1] == 0xfe && boms[2] == 0 && boms[3] == 0) return Encoding.UTF32; + + if (boms[0] == 0 && boms[1] == 0 && boms[2] == 0xfe && boms[3] == 0xff) return Encoding.GetEncoding(12001); + + return null; + } + + /// 检测是否ASCII + /// + /// + private static Encoding DetectASCII(Byte[] data) + { + // 如果所有字节都小于128,则可以使用ASCII编码 + for (var i = 0; i < data.Length; i++) + { + if (data[i] >= 128) return null; + } + + return Encoding.ASCII; + } + + private static Boolean IsMatch(Byte[] data, Encoding encoding) + { + if (encoding == null) encoding = Encoding.UTF8; + + try + { + var str = encoding.GetString(data); + var buf = encoding.GetBytes(str); + + // 考虑到噪声干扰,只要0.9 + var score = buf.Length * 9 / 10; + var match = 0; + for (var i = 0; i < buf.Length; i++) + { + if (data[i] == buf[i]) + { + match++; + if (match >= score) return true; + } + } + //if (match >= buf.Length * 0.9) + // return true; + + //return data.CompareTo(buf) == 0; + } + catch { } + + return false; + } + + /// 启发式探测Unicode编码 + /// + /// + private static Encoding DetectUnicode(Byte[] data) + { + Int64 oddBinaryNullsInSample = 0; + Int64 evenBinaryNullsInSample = 0; + Int64 suspiciousUTF8SequenceCount = 0; + Int64 suspiciousUTF8BytesTotal = 0; + Int64 likelyUSASCIIBytesInSample = 0; + + // Cycle through, keeping count of binary null positions, possible UTF-8 + // sequences from upper ranges of Windows-1252, and probable US-ASCII + // character counts. + + Int64 pos = 0; + var skipUTF8Bytes = 0; + + while (pos < data.Length) + { + // 二进制空分布 + if (data[pos] == 0) + { + if (pos % 2 == 0) + evenBinaryNullsInSample++; + else + oddBinaryNullsInSample++; + } + + // 可见 ASCII 字符 + if (IsCommonASCII(data[pos])) + likelyUSASCIIBytesInSample++; + + // 类似UTF-8的可疑序列 + if (skipUTF8Bytes == 0) + { + var len = DetectSuspiciousUTF8SequenceLength(data, pos); + if (len > 0) + { + suspiciousUTF8SequenceCount++; + suspiciousUTF8BytesTotal += len; + skipUTF8Bytes = len - 1; + } + } + else + { + skipUTF8Bytes--; + } + + pos++; + } + + // UTF-16 + // LE 小端 在英语或欧洲环境,经常使用奇数个0(以0开始),而很少用偶数个0 + // BE 大端 在英语或欧洲环境,经常使用偶数个0(以0开始),而很少用奇数个0 + if (((evenBinaryNullsInSample * 2.0) / data.Length) < 0.2 + && ((oddBinaryNullsInSample * 2.0) / data.Length) > 0.6 + ) + return Encoding.Unicode; + + if (((oddBinaryNullsInSample * 2.0) / data.Length) < 0.2 + && ((evenBinaryNullsInSample * 2.0) / data.Length) > 0.6 + ) + return Encoding.BigEndianUnicode; + + // UTF-8 + // 使用正则检测,参考http://www.w3.org/International/questions/qa-forms-utf-8 + var potentiallyMangledString = Encoding.ASCII.GetString(data); + var reg = new Regex(@"\A(" + + @"[\x09\x0A\x0D\x20-\x7E]" // ASCII + + @"|[\xC2-\xDF][\x80-\xBF]" // 不太长的2字节 + + @"|\xE0[\xA0-\xBF][\x80-\xBF]" // 排除太长 + + @"|[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}" // 连续的3字节 + + @"|\xED[\x80-\x9F][\x80-\xBF]" // 排除代理 + + @"|\xF0[\x90-\xBF][\x80-\xBF]{2}" // 1~3 + + @"|[\xF1-\xF3][\x80-\xBF]{3}" // 4~15 + + @"|\xF4[\x80-\x8F][\x80-\xBF]{2}" // 16 + + @")*\z"); + if (reg.IsMatch(potentiallyMangledString)) + { + //Unfortunately, just the fact that it CAN be UTF-8 doesn't tell you much about probabilities. + //If all the characters are in the 0-127 range, no harm done, most western charsets are same as UTF-8 in these ranges. + //If some of the characters were in the upper range (western accented characters), however, they would likely be mangled to 2-Byte by the UTF-8 encoding process. + // So, we need to play stats. + + // The "Random" likelihood of any pair of randomly generated characters being one + // of these "suspicious" character sequences is: + // 128 / (256 * 256) = 0.2%. + // + // In western text data, that is SIGNIFICANTLY reduced - most text data stays in the <127 + // character range, so we assume that more than 1 in 500,000 of these character + // sequences indicates UTF-8. The number 500,000 is completely arbitrary - so sue me. + // + // We can only assume these character sequences will be rare if we ALSO assume that this + // IS in fact western text - in which case the bulk of the UTF-8 encoded data (that is + // not already suspicious sequences) should be plain US-ASCII bytes. This, I + // arbitrarily decided, should be 80% (a random distribution, eg binary data, would yield + // approx 40%, so the chances of hitting this threshold by accident in random data are + // VERY low). + + // 很不幸运,事实上,它仅仅可能是UTF-8。如果所有字符都在0~127范围,那是没有问题的,绝大部分西方字符在UTF-8都在这个范围。 + // 然而如果部分字符在大写区域(西方口语字符),用UTF-8编码处理可能造成误伤。所以我们需要继续分析。 + // 随机生成字符成为可疑序列的可能性是:128 / (256 * 256) = 0.2% + // 在西方文本数据,这要小得多,绝大部分文本数据停留在小于127的范围。所以我们假定在500000个字符中多余一个UTF-8字符 + + if ((suspiciousUTF8SequenceCount * 500000.0 / data.Length >= 1) // 可疑序列 + && ( + // 所有可疑情况,无法平率ASCII可能性 + data.Length - suspiciousUTF8BytesTotal == 0 + || + likelyUSASCIIBytesInSample * 1.0 / (data.Length - suspiciousUTF8BytesTotal) >= 0.8 + ) + ) + return Encoding.UTF8; + } + + return null; + } + + /// 是否可见ASCII + /// + /// + private static Boolean IsCommonASCII(Byte bt) + { + if (bt is 0x0A // 回车 + or 0x0D // 换行 + or 0x09 // 制表符 + or >= 0x20 and <= 0x2F or >= 0x30 and <= 0x39 or >= 0x3A and <= 0x40 or >= 0x41 and <= 0x5A or >= 0x5B and <= 0x60 or >= 0x61 and <= 0x7A or >= 0x7B and <= 0x7E) + return true; + else + return false; + } + + /// 检测可能的UTF8序列长度 + /// + /// + /// + private static Int32 DetectSuspiciousUTF8SequenceLength(Byte[] buf, Int64 pos) + { + if (buf.Length > pos + 1) + { + var first = buf[pos]; + var second = buf[pos + 1]; + if (first == 0xC2) + { + if (second is 0x81 or 0x8D or 0x8F or 0x90 or 0x9D or >= 0xA0 and <= 0xBF) + return 2; + } + else if (first == 0xC3) + { + if (second is >= 0x80 and <= 0xBF) return 2; + } + else if (first == 0xC5) + { + if (second is 0x92 or 0x93 or 0xA0 or 0xA1 or 0xB8 or 0xBD or 0xBE) + return 2; + } + else if (first == 0xC6) + { + if (second == 0x92) return 2; + } + else if (first == 0xCB) + { + if (second is 0x86 or 0x9C) return 2; + } + else if (buf.Length >= pos + 2 && first == 0xE2) + { + var three = buf[pos + 2]; + if (second == 0x80) + { + if (three is 0x93 or 0x94 or 0x98 or 0x99 or 0x9A) + return 3; + if (three is 0x9C or 0x9D or 0x9E) + return 3; + if (three is 0xA0 or 0xA1 or 0xA2) + return 3; + if (three is 0xA6 or 0xB0 or 0xB9 or 0xBA) + return 3; + } + else if (second == 0x82 && three == 0xAC || second == 0x84 && three == 0xA2) + return 3; + } + } + + return 0; + } + #endregion + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/IO/ExcelReader.cs b/src/Admin/ThingsGateway.NewLife.X/IO/ExcelReader.cs new file mode 100644 index 000000000..a770a1daf --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/IO/ExcelReader.cs @@ -0,0 +1,290 @@ +using System.IO.Compression; +using System.Text; +using System.Xml.Linq; + +namespace ThingsGateway.NewLife.IO; + +/// 轻量级Excel读取器,仅用于导入数据 +/// +/// 文档 https://newlifex.com/core/excel_reader +/// 仅支持xlsx格式,本质上是压缩包,内部xml。 +/// 可根据xml格式扩展读取自己想要的内容。 +/// +public class ExcelReader : DisposeBase +{ + #region 属性 + /// 文件名 + public String? FileName { get; } + + /// 工作表 + public ICollection? Sheets => _entries?.Keys; + + private ZipArchive _zip; + private String[]? _sharedStrings; + private String?[]? _styles; + private IDictionary? _entries; + #endregion + + #region 构造 + /// 实例化读取器 + /// + public ExcelReader(String fileName) + { + if (fileName.IsNullOrEmpty()) throw new ArgumentNullException(nameof(fileName)); + + FileName = fileName; + + //_zip = ZipFile.OpenRead(fileName.GetFullPath()); + // 共享访问,避免文件被其它进程打开时再次访问抛出异常 + var fs = new FileStream(fileName.GetFullPath(), FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + _zip = new ZipArchive(fs, ZipArchiveMode.Read, true); + + Parse(); + } + + /// 实例化读取器 + /// + /// + public ExcelReader(Stream stream, Encoding encoding) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + + if (stream is FileStream fs) FileName = fs.Name; + + _zip = new ZipArchive(stream, ZipArchiveMode.Read, true, encoding); + + Parse(); + } + + /// 销毁 + /// + protected override void Dispose(Boolean disposing) + { + base.Dispose(disposing); + + _entries?.Clear(); + _zip.TryDispose(); + } + #endregion + + #region 方法 + private void Parse() + { + // 读取共享字符串 + { + var entry = _zip.GetEntry("xl/sharedStrings.xml"); + if (entry != null) _sharedStrings = ReadStrings(entry.Open()); + } + + // 读取样式 + { + var entry = _zip.GetEntry("xl/styles.xml"); + if (entry != null) _styles = ReadStyles(entry.Open()); + } + + // 读取sheet + { + _entries = ReadSheets(_zip); + } + } + + private static DateTime _1900 = new(1900, 1, 1); + + /// 逐行读取数据,第一行很可能是表头 + /// 工作表名。一般是sheet1/sheet2/sheet3,默认空,使用第一个数据表 + /// + public IEnumerable ReadRows(String? sheet = null) + { + if (Sheets == null || _entries == null) yield break; + + if (sheet.IsNullOrEmpty()) sheet = Sheets.FirstOrDefault(); + if (sheet.IsNullOrEmpty()) throw new ArgumentNullException(nameof(sheet)); + + if (!_entries.TryGetValue(sheet, out var entry)) throw new ArgumentOutOfRangeException(nameof(sheet), "Unable to find worksheet"); + + var doc = XDocument.Load(entry.Open()); + if (doc.Root == null) yield break; + + var data = doc.Root.Elements().FirstOrDefault(e => e.Name.LocalName.EqualIgnoreCase("sheetData")); + if (data == null) yield break; + + // 加快样式判断速度 + var styles = _styles; + if (styles != null && styles.Length == 0) styles = null; + + foreach (var row in data.Elements()) + { + var vs = new List(); + var c = 'A'; + foreach (var col in row.Elements()) + { + // 值 + var val = col.Value; + + // 某些列没有数据,被跳过。r=CellReference + var r = col.Attribute("r"); + if (r != null) + { + // 按最后一个字母递增,最多支持25个空列 + var c2 = r.Value.Last(Char.IsLetter); + while (c2 != c) + { + vs.Add(null); + if (c == 'Z') + c = 'A'; + else + c++; + } + } + + // t=DataType, s=SharedString, b=Boolean, n=Number, d=Date + var t = col.Attribute("t"); + if (t != null && t.Value == "s") + { + val = _sharedStrings?[val.ToInt()]; + } + else if (styles != null) + { + // 特殊支持时间日期,s=StyleIndex + var s = col.Attribute("s"); + if (s != null) + { + var si = s.Value.ToInt(); + if (si < styles.Length) + { + var st = styles[si]; + if (st != null && st.StartsWith("yy")) + { + if (val.Contains('.')) + { + var ss = val.Split('.'); + var dt = _1900.AddDays(ss[0].ToInt() - 2); + dt = dt.AddSeconds(ss[1].ToLong() / 115740); + val = dt.ToFullString(); + } + else + { + val = _1900.AddDays(val.ToInt() - 2).ToString("yyyy-MM-dd"); + } + } + } + else + { + foreach (var colElement in col.Elements()) + { + if (colElement.Name.LocalName.Equals("v")) + { + val = colElement.Value; + } + } + } + } + } + + vs.Add(val); + + // 循环判断,用最简单的办法兼容超过26列的表格 + if (c == 'Z') + c = 'A'; + else + c++; + } + + yield return vs.ToArray(); + } + } + + private static String[]? ReadStrings(Stream ms) + { + var doc = XDocument.Load(ms); + if (doc?.Root == null) return null; + + var list = new List(); + foreach (var item in doc.Root.Elements()) + { + list.Add(item.Value); + } + + return list.ToArray(); + } + + private static String?[]? ReadStyles(Stream ms) + { + var doc = XDocument.Load(ms); + if (doc?.Root == null) return null; + + var fmts = new Dictionary(); + var numFmts = doc.Root.Elements().FirstOrDefault(e => e.Name.LocalName == "numFmts"); + if (numFmts != null) + { + foreach (var item in numFmts.Elements()) + { + var id = item.Attribute("numFmtId"); + var code = item.Attribute("formatCode"); + if (id != null) fmts.Add(id.Value.ToInt(), code?.Value); + } + } + + var list = new List(); + var xfs = doc.Root.Elements().FirstOrDefault(e => e.Name.LocalName == "cellXfs"); + if (xfs != null) + { + foreach (var item in xfs.Elements()) + { + var fid = item.Attribute("numFmtId"); + if (fid != null && fmts.TryGetValue(fid.Value.ToInt(), out var code)) + list.Add(code); + else + list.Add(null); + } + } + + return list.ToArray(); + } + + private Dictionary ReadSheets(ZipArchive zip) + { + var dic = new Dictionary(); + + var entry = _zip.GetEntry("xl/workbook.xml"); + if (entry != null) + { + var doc = XDocument.Load(entry.Open()); + if (doc?.Root != null) + { + //var list = new List(); + var sheets = doc.Root.Elements().FirstOrDefault(e => e.Name.LocalName == "sheets"); + if (sheets != null) + { + foreach (var item in sheets.Elements()) + { + var id = item.Attribute("sheetId"); + var name = item.Attribute("name"); + if (id != null) dic[id.Value] = name?.Value; + } + } + } + } + + //_entries = _zip.Entries.Where(e => + // e.FullName.StartsWithIgnoreCase("xl/worksheets/") && + // e.Name.EndsWithIgnoreCase(".xml")) + // .ToDictionary(e => e.Name.TrimEnd(".xml"), e => e); + + var dic2 = new Dictionary(); + foreach (var item in zip.Entries) + { + if (item.FullName.StartsWithIgnoreCase("xl/worksheets/") && item.Name.EndsWithIgnoreCase(".xml")) + { + var name = item.Name.TrimEnd(".xml"); + if (dic.TryGetValue(name.TrimStart("sheet"), out var str)) name = str; + name ??= String.Empty; + + dic2[name] = item; + } + } + + return dic2; + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/IO/FileSource.cs b/src/Admin/ThingsGateway.NewLife.X/IO/FileSource.cs new file mode 100644 index 000000000..bc8b92bd8 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/IO/FileSource.cs @@ -0,0 +1,137 @@ +using System.Reflection; + +namespace ThingsGateway.NewLife.IO +{ + /// 文件资源 + public static class FileSource + { + /// 释放文件 + /// + /// + /// + /// + public static void ReleaseFile(this Assembly asm, String fileName, String destFile = null, Boolean overWrite = false) + { + if (fileName.IsNullOrEmpty()) return; + + if (asm == null) asm = Assembly.GetCallingAssembly(); + var stream = GetFileResource(asm, fileName); + if (stream == null) throw new ArgumentException("filename", $"在程序集{asm.GetName().Name}中无法找到名为{fileName}的资源!"); + + if (destFile.IsNullOrEmpty()) destFile = fileName; + destFile = destFile.GetFullPath(); + + if (File.Exists(destFile) && !overWrite) return; + + //var path = Path.GetDirectoryName(dest); + //if (!path.IsNullOrWhiteSpace() && !Directory.Exists(path)) Directory.CreateDirectory(path); + destFile.EnsureDirectory(true); + try + { + if (File.Exists(destFile)) File.Delete(destFile); + + using var fs = File.Create(destFile); + //IOHelper.CopyTo(stream, fs); + stream.CopyTo(fs); + } + catch { } + finally { stream.Dispose(); } + } + + /// 释放文件夹 + /// + /// + /// + /// + public static void ReleaseFolder(this Assembly asm, String prefix, String dest, Boolean overWrite = false) + { + if (asm == null) asm = Assembly.GetCallingAssembly(); + ReleaseFolder(asm, prefix, dest, overWrite, null); + } + + /// 释放文件夹 + /// + /// + /// + /// + /// + public static void ReleaseFolder(this Assembly asm, String prefix, String dest, Boolean overWrite = false, Func filenameResolver = null) + { + if (asm == null) asm = Assembly.GetCallingAssembly(); + + // 找到符合条件的资源 + var names = asm.GetManifestResourceNames(); + if (names == null || names.Length <= 0) return; + IEnumerable ns = null; + if (prefix.IsNullOrWhiteSpace()) + ns = names.AsEnumerable(); + else + ns = names.Where(e => e.StartsWithIgnoreCase(prefix)); + + if (String.IsNullOrEmpty(dest)) dest = ".".GetFullPath(); + dest = dest.GetFullPath(); + + // 开始处理 + foreach (var item in ns) + { + var stream = asm.GetManifestResourceStream(item); + + // 计算filename + String filename = null; + // 去掉前缀 + if (filenameResolver != null) filename = filenameResolver(item); + + if (String.IsNullOrEmpty(filename)) + { + filename = item; + if (!String.IsNullOrEmpty(prefix)) filename = filename[prefix.Length..]; + if (filename[0] == '.') filename = filename[1..]; + + var ext = Path.GetExtension(item); + filename = filename[..^ext.Length]; + filename = filename.Replace(".", @"\") + ext; + filename = Path.Combine(dest, filename); + } + + if (File.Exists(filename) && !overWrite) return; + + //var path = Path.GetDirectoryName(filename); + //if (!path.IsNullOrWhiteSpace() && !Directory.Exists(path)) Directory.CreateDirectory(path); + filename.EnsureDirectory(true); + try + { + if (File.Exists(filename)) File.Delete(filename); + + using var fs = File.Create(filename); + //IOHelper.CopyTo(stream, fs); + stream.CopyTo(fs); + } + catch { } + finally { stream.Dispose(); } + } + } + + /// 获取文件资源 + /// + /// + /// + public static Stream GetFileResource(this Assembly asm, String filename) + { + if (String.IsNullOrEmpty(filename)) return null; + + var name = String.Empty; + if (asm == null) asm = Assembly.GetCallingAssembly(); + var ss = asm.GetManifestResourceNames(); + if (ss != null && ss.Length > 0) + { + //找到资源名 + name = ss.FirstOrDefault(e => e == filename); + if (String.IsNullOrEmpty(name)) name = ss.FirstOrDefault(e => e.EqualIgnoreCase(filename)); + if (String.IsNullOrEmpty(name)) name = ss.FirstOrDefault(e => e.EndsWith(filename)); + + if (!String.IsNullOrEmpty(name)) return asm.GetManifestResourceStream(name); + } + return null; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/IO/IOHelper.cs b/src/Admin/ThingsGateway.NewLife.X/IO/IOHelper.cs new file mode 100644 index 000000000..4691932af --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/IO/IOHelper.cs @@ -0,0 +1,1080 @@ +using System.Globalization; +using System.IO.Compression; +using System.Text; + +using ThingsGateway.NewLife.Collections; + +namespace ThingsGateway.NewLife; + +/// IO工具类 +/// +/// 文档 https://newlifex.com/core/io_helper +/// +public static class IOHelper +{ + #region 属性 + /// 最大安全数组大小。超过该大小时,读取数据操作将强制失败,默认1024*1024 + /// + /// 这是一个保护性设置,避免解码错误数据时读取了超大数组导致应用崩溃。 + /// 需要解码较大二进制数据时,可以适当放宽该阈值。 + /// + public static Int32 MaxSafeArraySize { get; set; } = 1024 * 1024; + #endregion + + #region 压缩/解压缩 数据 + /// 压缩数据流 + /// 输入流 + /// 输出流。如果不指定,则内部实例化一个内存流 + /// 返回输出流,注意此时指针位于末端 + public static Stream Compress(this Stream inStream, Stream? outStream = null) + { + var ms = outStream ?? new MemoryStream(); + + // 第三个参数为true,保持数据流打开,内部不应该干涉外部,不要关闭外部的数据流 + using (var stream = new DeflateStream(ms, CompressionLevel.Optimal, true)) + { + inStream.CopyTo(stream); + stream.Flush(); + } + + // 内部数据流需要把位置指向开头 + if (outStream == null) ms.Position = 0; + + return ms; + } + + /// 解压缩数据流 + /// Deflate算法,如果是ZLIB格式,则前面多两个字节,解压缩之前去掉,RocketMQ中有用到 + /// 输入流 + /// 输出流。如果不指定,则内部实例化一个内存流 + /// 返回输出流,注意此时指针位于末端 + public static Stream Decompress(this Stream inStream, Stream? outStream = null) + { + var ms = outStream ?? new MemoryStream(); + + // 第三个参数为true,保持数据流打开,内部不应该干涉外部,不要关闭外部的数据流 + using (var stream = new DeflateStream(inStream, CompressionMode.Decompress, true)) + { + stream.CopyTo(ms); + } + + // 内部数据流需要把位置指向开头 + if (outStream == null) ms.Position = 0; + + return ms; + } + + /// 压缩字节数组 + /// 字节数组 + /// + public static Byte[] Compress(this Byte[] data) + { + var ms = new MemoryStream(); + Compress(new MemoryStream(data), ms); + return ms.ToArray(); + } + + /// 解压缩字节数组 + /// Deflate算法,如果是ZLIB格式,则前面多两个字节,解压缩之前去掉,RocketMQ中有用到 + /// 字节数组 + /// + public static Byte[] Decompress(this Byte[] data) + { + var ms = new MemoryStream(); + Decompress(new MemoryStream(data), ms); + return ms.ToArray(); + } + + /// 压缩数据流 + /// 输入流 + /// 输出流。如果不指定,则内部实例化一个内存流 + /// 返回输出流,注意此时指针位于末端 + public static Stream CompressGZip(this Stream inStream, Stream? outStream = null) + { + var ms = outStream ?? new MemoryStream(); + + // 第三个参数为true,保持数据流打开,内部不应该干涉外部,不要关闭外部的数据流 + using (var stream = new GZipStream(ms, CompressionLevel.Optimal, true)) + { + inStream.CopyTo(stream); + stream.Flush(); + } + + // 内部数据流需要把位置指向开头 + if (outStream == null) ms.Position = 0; + + return ms; + } + + /// 解压缩数据流 + /// 输入流 + /// 输出流。如果不指定,则内部实例化一个内存流 + /// 返回输出流,注意此时指针位于末端 + public static Stream DecompressGZip(this Stream inStream, Stream? outStream = null) + { + var ms = outStream ?? new MemoryStream(); + + // 第三个参数为true,保持数据流打开,内部不应该干涉外部,不要关闭外部的数据流 + using (var stream = new GZipStream(inStream, CompressionMode.Decompress, true)) + { + stream.CopyTo(ms); + } + + // 内部数据流需要把位置指向开头 + if (outStream == null) ms.Position = 0; + + return ms; + } + + /// 压缩字节数组 + /// 字节数组 + /// + public static Byte[] CompressGZip(this Byte[] data) + { + var ms = new MemoryStream(); + CompressGZip(new MemoryStream(data), ms); + return ms.ToArray(); + } + + /// 解压缩字节数组 + /// Deflate算法,如果是ZLIB格式,则前面多两个字节,解压缩之前去掉,RocketMQ中有用到 + /// 字节数组 + /// + public static Byte[] DecompressGZip(this Byte[] data) + { + var ms = new MemoryStream(); + DecompressGZip(new MemoryStream(data), ms); + return ms.ToArray(); + } + #endregion + + #region 复制数据流 + /// 把一个字节数组写入到一个数据流 + /// 目的数据流 + /// 源数据流 + /// + public static Stream Write(this Stream des, params Byte[] src) + { + if (src != null && src.Length > 0) des.Write(src, 0, src.Length); + return des; + } + + /// 写入字节数组,先写入压缩整数表示的长度 + /// + /// + /// + public static Stream WriteArray(this Stream des, params Byte[] src) + { + if (src == null || src.Length == 0) + { + des.WriteByte(0); + return des; + } + + des.WriteEncodedInt(src.Length); + des.Write(src); + + return des; + } + + /// 读取字节数组,先读取压缩整数表示的长度 + /// + /// + public static Byte[] ReadArray(this Stream des) + { + var len = des.ReadEncodedInt(); + if (len <= 0) return []; + + // 避免数据错乱超长 + //if (des.CanSeek && len > des.Length - des.Position) len = (Int32)(des.Length - des.Position); + if (des.CanSeek && len > des.Length - des.Position) throw new XException("ReadArray error, variable length array length is {0}, but the available data for the data stream is only {1}", len, des.Length - des.Position); + + if (len > MaxSafeArraySize) throw new XException("Security required, reading large variable length arrays is not allowed {0:n0}>{1:n0}", len, MaxSafeArraySize); + + var buf = new Byte[len]; + des.ReadExactly(buf, 0, buf.Length); + + return buf; + } + + /// 写入Unix格式时间,1970年以来秒数,绝对时间,非UTC + /// + /// + /// + public static Stream WriteDateTime(this Stream stream, DateTime dt) + { + var seconds = dt.ToInt(); + stream.Write(seconds.GetBytes()); + + return stream; + } + + /// 读取Unix格式时间,1970年以来秒数,绝对时间,非UTC + /// + /// + public static DateTime ReadDateTime(this Stream stream) + { + _encodes ??= new Byte[16]; + stream.ReadExactly(_encodes, 0, 4); + var seconds = (Int32)_encodes.ToUInt32(); + + return seconds.ToDateTime(); + } + + /// 复制数组 + /// 源数组 + /// 起始位置。一般从0开始 + /// 复制字节数。用-1表示截取剩余所有数据 + /// 返回复制的总字节数 + public static Byte[] ReadBytes(this Byte[] src, Int32 offset, Int32 count) + { + if (count == 0) return []; + + // 即使是全部,也要复制一份,而不只是返回原数组,因为可能就是为了复制数组 + if (count < 0) count = src.Length - offset; + + var bts = new Byte[count]; + Buffer.BlockCopy(src, offset, bts, 0, bts.Length); + return bts; + } + + /// 向字节数组写入一片数据 + /// 目标数组 + /// 目标偏移 + /// 源数组 + /// 源数组偏移 + /// 数量 + /// 返回实际写入的字节个数 + public static Int32 Write(this Byte[] dst, Int32 dstOffset, Byte[] src, Int32 srcOffset = 0, Int32 count = -1) + { + if (count <= 0) count = src.Length - srcOffset; + if (dstOffset + count > dst.Length) count = dst.Length - dstOffset; + + Buffer.BlockCopy(src, srcOffset, dst, dstOffset, count); + return count; + } + #endregion + + #region 数据流转换 +#if !NET7_0_OR_GREATER + /// 从流中完全读取数据,直到指定大小或者到达流结束 + /// + /// 主要为了对抗net6开始对Stream.Read的微调。 + /// https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/6.0/partial-byte-reads-in-streams + /// + /// + /// + /// + /// + /// + public static Int32 ReadExactly(this Stream stream, Byte[] buffer, Int32 offset, Int32 count) + { + //if (count < 0) count = buffer.Length - offset; + + var totalRead = 0; + while (totalRead < count) + { + var bytesRead = stream.Read(buffer, offset + totalRead, count - totalRead); + if (bytesRead == 0) break; + totalRead += bytesRead; + } + + return totalRead; + } +#endif + + /// 数据流转为字节数组 + /// + /// 针对MemoryStream进行优化。内存流的Read实现是一个个字节复制,而ToArray是调用内部内存复制方法 + /// 如果要读完数据,又不支持定位,则采用内存流搬运 + /// 如果指定长度超过数据流长度,就让其报错,因为那是调用者所期望的值 + /// + /// 数据流 + /// 长度,-1表示读到结束 + /// + public static Byte[] ReadBytes(this Stream stream, Int64 length) + { + //if (stream == null) return null; + if (length == 0) return []; + + if (length > 0 && stream.CanSeek && stream.Length - stream.Position < length) + throw new XException("Unable to read {1} bytes of data from a data stream with a length of only {0}", stream.Length - stream.Position, length); + + // 如果指定长度超过数据流长度,就让其报错,因为那是调用者所期望的值 + if (length > 0) + { + //!!! Stream.Read 的官方设计从未承诺填满缓冲区,需要用户自己多次读取 + // https://docs.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/6.0/partial-byte-reads-in-streams + var buf = new Byte[length]; + stream.ReadExactly(buf, 0, buf.Length); + return buf; + } + + // 支持搜索 + if (stream.CanSeek) + { + // 如果指定长度超过数据流长度,就让其报错,因为那是调用者所期望的值 + length = (Int32)(stream.Length - stream.Position); + + var buf = new Byte[length]; + stream.ReadExactly(buf, 0, buf.Length); + return buf; + } + + // 如果要读完数据,又不支持定位,则采用内存流搬运 + var ms = Pool.MemoryStream.Get(); + stream.CopyTo(ms); + + return ms.Return(true); + } + + /// 流转换为字符串 + /// 目标流 + /// 编码格式 + /// + public static String ToStr(this Stream stream, Encoding? encoding = null) + { + if (stream == null) return String.Empty; + encoding ??= Encoding.UTF8; + + var buf = stream.ReadBytes(-1); + if (buf == null || buf.Length <= 0) return String.Empty; + + // 可能数据流前面有编码字节序列,需要先去掉 + var idx = 0; + var preamble = encoding.GetPreamble(); + if (preamble != null && preamble.Length > 0) + { + if (buf.Take(preamble.Length).SequenceEqual(preamble)) idx = preamble.Length; + } + + return encoding.GetString(buf, idx, buf.Length - idx); + } + + /// 字节数组转换为字符串 + /// 字节数组 + /// 编码格式 + /// 字节数组中的偏移 + /// 字节数组中的查找长度 + /// + public static String ToStr(this Byte[] buf, Encoding? encoding = null, Int32 offset = 0, Int32 count = -1) + { + if (buf == null || buf.Length <= 0 || offset >= buf.Length) return String.Empty; + encoding ??= Encoding.UTF8; + + var size = buf.Length - offset; + if (count < 0 || count > size) count = size; + + // 可能数据流前面有编码字节序列,需要先去掉 + var idx = 0; + var preamble = encoding.GetPreamble(); + if (preamble != null && preamble.Length > 0 && buf.Length >= offset + preamble.Length) + { + if (buf.Skip(offset).Take(preamble.Length).SequenceEqual(preamble)) idx = preamble.Length; + } + + return encoding.GetString(buf, offset + idx, count - idx); + } + #endregion + + #region 数据转整数 + /// 从字节数据指定位置读取一个无符号16位整数 + /// + /// 偏移 + /// 是否小端字节序 + /// + public static UInt16 ToUInt16(this Byte[] data, Int32 offset = 0, Boolean isLittleEndian = true) + { + if (isLittleEndian) + return (UInt16)((data[offset + 1] << 8) | data[offset]); + else + return (UInt16)((data[offset] << 8) | data[offset + 1]); + } + + /// 从字节数据指定位置读取一个无符号32位整数 + /// + /// 偏移 + /// 是否小端字节序 + /// + public static UInt32 ToUInt32(this Byte[] data, Int32 offset = 0, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.ToUInt32(data, offset); + + // BitConverter得到小端,如果不是小端字节顺序,则倒序 + if (offset > 0) data = data.ReadBytes(offset, 4); + if (isLittleEndian) + return (UInt32)(data[0] | data[1] << 8 | data[2] << 0x10 | data[3] << 0x18); + else + return (UInt32)(data[0] << 0x18 | data[1] << 0x10 | data[2] << 8 | data[3]); + } + + /// 从字节数据指定位置读取一个无符号64位整数 + /// + /// 偏移 + /// 是否小端字节序 + /// + public static UInt64 ToUInt64(this Byte[] data, Int32 offset = 0, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.ToUInt64(data, offset); + + if (offset > 0) data = data.ReadBytes(offset, 8); + if (isLittleEndian) + { + var num1 = data[0] | data[1] << 8 | data[2] << 0x10 | data[3] << 0x18; + var num2 = data[4] | data[5] << 8 | data[6] << 0x10 | data[7] << 0x18; + return (UInt32)num1 | (UInt64)num2 << 0x20; + } + else + { + var num3 = data[0] << 0x18 | data[1] << 0x10 | data[2] << 8 | data[3]; + var num4 = data[4] << 0x18 | data[5] << 0x10 | data[6] << 8 | data[7]; + return (UInt32)num4 | (UInt64)num3 << 0x20; + } + } + + /// 从字节数据指定位置读取一个单精度浮点数 + /// + /// 偏移 + /// 是否小端字节序 + /// + public static Single ToSingle(this Byte[] data, Int32 offset = 0, Boolean isLittleEndian = true) + { + // BitConverter得到小端,如果不是小端字节顺序,则倒序 + if (offset > 0) data = data.ReadBytes(offset, 4); + if (!isLittleEndian) + { + (data[3], data[2], data[1], data[0]) = (data[0], data[1], data[2], data[3]); + } + + return BitConverter.ToSingle(data, offset); + } + + /// 从字节数据指定位置读取一个双精度浮点数 + /// + /// 偏移 + /// 是否小端字节序 + /// + public static Double ToDouble(this Byte[] data, Int32 offset = 0, Boolean isLittleEndian = true) + { + if (offset > 0) data = data.ReadBytes(offset, 8); + if (!isLittleEndian) + { + (data[7], data[6], data[5], data[4], data[3], data[2], data[1], data[0]) = (data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7]); + } + + return BitConverter.ToDouble(data, offset); + } + + /// 向字节数组的指定位置写入一个无符号16位整数 + /// + /// 数字 + /// 偏移 + /// 是否小端字节序 + /// + public static Byte[] Write(this Byte[] data, UInt16 n, Int32 offset = 0, Boolean isLittleEndian = true) + { + // STM32单片机是小端 + // Modbus协议规定大端 + + if (isLittleEndian) + { + data[offset] = (Byte)(n & 0xFF); + data[offset + 1] = (Byte)(n >> 8); + } + else + { + data[offset] = (Byte)(n >> 8); + data[offset + 1] = (Byte)(n & 0xFF); + } + + return data; + } + + /// 向字节数组的指定位置写入一个无符号32位整数 + /// + /// 数字 + /// 偏移 + /// 是否小端字节序 + /// + public static Byte[] Write(this Byte[] data, UInt32 n, Int32 offset = 0, Boolean isLittleEndian = true) + { + if (isLittleEndian) + { + for (var i = 0; i < 4; i++) + { + data[offset++] = (Byte)n; + n >>= 8; + } + } + else + { + for (var i = 4 - 1; i >= 0; i--) + { + data[offset + i] = (Byte)n; + n >>= 8; + } + } + + return data; + } + + /// 向字节数组的指定位置写入一个无符号64位整数 + /// + /// 数字 + /// 偏移 + /// 是否小端字节序 + /// + public static Byte[] Write(this Byte[] data, UInt64 n, Int32 offset = 0, Boolean isLittleEndian = true) + { + if (isLittleEndian) + { + for (var i = 0; i < 8; i++) + { + data[offset++] = (Byte)n; + n >>= 8; + } + } + else + { + for (var i = 8 - 1; i >= 0; i--) + { + data[offset + i] = (Byte)n; + n >>= 8; + } + } + + return data; + } + + /// 向字节数组的指定位置写入一个单精度浮点数 + /// + /// 数字 + /// 偏移 + /// 是否小端字节序 + /// + public static Byte[] Write(this Byte[] data, Single n, Int32 offset = 0, Boolean isLittleEndian = true) + { + var buf = BitConverter.GetBytes(n); + if (isLittleEndian) + { + for (var i = 0; i < 4; i++) + { + data[offset++] = buf[i]; + } + } + else + { + for (var i = 4 - 1; i >= 0; i--) + { + data[offset + i] = buf[i]; + } + } + + return data; + } + + /// 向字节数组的指定位置写入一个双精度浮点数 + /// + /// 数字 + /// 偏移 + /// 是否小端字节序 + /// + public static Byte[] Write(this Byte[] data, Double n, Int32 offset = 0, Boolean isLittleEndian = true) + { + var buf = BitConverter.GetBytes(n); + if (isLittleEndian) + { + for (var i = 0; i < 8; i++) + { + data[offset++] = buf[i]; + } + } + else + { + for (var i = 8 - 1; i >= 0; i--) + { + data[offset + i] = buf[i]; + } + } + + return data; + } + + /// 整数转为字节数组,注意大小端字节序 + /// + /// + /// + public static Byte[] GetBytes(this UInt16 value, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.GetBytes(value); + + var buf = new Byte[2]; + return buf.Write(value, 0, isLittleEndian); + } + + /// 整数转为字节数组,注意大小端字节序 + /// + /// + /// + public static Byte[] GetBytes(this Int16 value, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.GetBytes(value); + + var buf = new Byte[2]; + return buf.Write((UInt16)value, 0, isLittleEndian); + } + + /// 整数转为字节数组,注意大小端字节序 + /// + /// + /// + public static Byte[] GetBytes(this UInt32 value, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.GetBytes(value); + + var buf = new Byte[4]; + return buf.Write(value, 0, isLittleEndian); + } + + /// 整数转为字节数组,注意大小端字节序 + /// + /// + /// + public static Byte[] GetBytes(this Int32 value, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.GetBytes(value); + + var buf = new Byte[4]; + return buf.Write((UInt32)value, 0, isLittleEndian); + } + + /// 整数转为字节数组,注意大小端字节序 + /// + /// + /// + public static Byte[] GetBytes(this UInt64 value, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.GetBytes(value); + + var buf = new Byte[8]; + return buf.Write(value, 0, isLittleEndian); + } + + /// 整数转为字节数组,注意大小端字节序 + /// + /// + /// + public static Byte[] GetBytes(this Int64 value, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.GetBytes(value); + + var buf = new Byte[8]; + return buf.Write((UInt64)value, 0, isLittleEndian); + } + + /// 单精度浮点数转为字节数组,注意大小端字节序 + /// + /// + /// + public static Byte[] GetBytes(this Single value, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.GetBytes(value); + + var buf = new Byte[4]; + return buf.Write(value, 0, isLittleEndian); + } + + /// 双精度浮点数转为字节数组,注意大小端字节序 + /// + /// + /// + public static Byte[] GetBytes(this Double value, Boolean isLittleEndian = true) + { + if (isLittleEndian) return BitConverter.GetBytes(value); + + var buf = new Byte[8]; + return buf.Write(value, 0, isLittleEndian); + } + + /// 字节翻转。支持双字节和四字节多批次翻转,主要用于大小端转换 + /// + /// + /// + /// + public static Byte[] Swap(this Byte[] data, Boolean swap16, Boolean swap32) + { + var buf = new Byte[data.Length]; + Buffer.BlockCopy(data, 0, buf, 0, data.Length); + if (swap16) + { + for (var i = 0; i < buf.Length - 1; i += 2) + { + (buf[i + 1], buf[i]) = (buf[i], buf[i + 1]); + } + } + + if (swap32) + { + for (var i = 0; i < buf.Length - 3; i += 4) + { + (buf[i + 2], buf[i + 3], buf[i], buf[i + 1]) = (buf[i], buf[i + 1], buf[i + 2], buf[i + 3]); + } + } + + return buf; + } + #endregion + + #region 7位压缩编码整数 + /// 以压缩格式读取32位整数 + /// 数据流 + /// + public static Int32 ReadEncodedInt(this Stream stream) + { + Byte b; + UInt32 rs = 0; + Byte n = 0; + while (true) + { + var bt = stream.ReadByte(); + if (bt < 0) throw new Exception($"The data stream is out of range! The integer read is {rs: n0}"); + b = (Byte)bt; + + // 必须转为Int32,否则可能溢出 + rs |= (UInt32)((b & 0x7f) << n); + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 32) throw new FormatException("The number value is too large to read in compressed format!"); + } + return (Int32)rs; + } + + /// 以压缩格式读取32位整数 + /// 数据流 + /// + public static UInt64 ReadEncodedInt64(this Stream stream) + { + Byte b; + UInt64 rs = 0; + Byte n = 0; + while (true) + { + var bt = stream.ReadByte(); + if (bt < 0) throw new Exception("The data stream is out of range!"); + b = (Byte)bt; + + // 必须转为Int32,否则可能溢出 + rs |= (UInt64)(b & 0x7f) << n; + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 64) throw new FormatException("The number value is too large to read in compressed format!"); + } + return rs; + } + + /// 尝试读取压缩编码整数 + /// + /// + /// + internal static Boolean TryReadEncodedInt(this Stream stream, out UInt32 value) + { + Byte b; + value = 0; + Byte n = 0; + while (true) + { + var bt = stream.ReadByte(); + if (bt < 0) return false; + b = (Byte)bt; + + // 必须转为Int32,否则可能溢出 + value += (UInt32)((b & 0x7f) << n); + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 32) throw new FormatException("The number value is too large to read in compressed format!"); + } + return true; + } + + [ThreadStatic] + private static Byte[]? _encodes; + /// + /// 以7位压缩格式写入32位整数,小于7位用1个字节,小于14位用2个字节。 + /// 由每次写入的一个字节的第一位标记后面的字节是否还是当前数据,所以每个字节实际可利用存储空间只有后7位。 + /// + /// 数据流 + /// 数值 + /// 实际写入字节数 + public static Stream WriteEncodedInt(this Stream stream, Int64 value) + { + _encodes ??= new Byte[16]; + + var count = 0; + var num = (UInt64)value; + while (num >= 0x80) + { + _encodes[count++] = (Byte)(num | 0x80); + num >>= 7; + } + _encodes[count++] = (Byte)num; + + stream.Write(_encodes, 0, count); + + return stream; + } + + /// 获取压缩编码整数 + /// + /// + public static Byte[] GetEncodedInt(Int64 value) + { + _encodes ??= new Byte[16]; + + var count = 0; + var num = (UInt64)value; + while (num >= 0x80) + { + _encodes[count++] = (Byte)(num | 0x80); + num >>= 7; + } + _encodes[count++] = (Byte)num; + + return _encodes.ReadBytes(0, count); + } + #endregion + + #region 十六进制编码 + /// 把字节数组编码为十六进制字符串 + /// 字节数组 + /// 偏移 + /// 数量。超过实际数量时,使用实际数量 + /// + public static String ToHex(this Byte[]? data, Int32 offset = 0, Int32 count = -1) + { + if (data == null || data.Length <= 0) return ""; + + if (count < 0) + count = data.Length - offset; + else if (offset + count > data.Length) + count = data.Length - offset; + if (count == 0) return ""; + + //return BitConverter.ToString(data).Replace("-", null); + // 上面的方法要替换-,效率太低 + var cs = new Char[count * 2]; + // 两个索引一起用,避免乘除带来的性能损耗 + for (Int32 i = 0, j = 0; i < count; i++, j += 2) + { + var b = data[offset + i]; + cs[j] = GetHexValue(b >> 4); + cs[j + 1] = GetHexValue(b & 0x0F); + } + return new String(cs); + } + + /// 把字节数组编码为十六进制字符串,带有分隔符和分组功能 + /// 字节数组 + /// 分隔符 + /// 分组大小,为0时对每个字节应用分隔符,否则对每个分组使用 + /// 最大显示多少个字节。默认-1显示全部 + /// + public static String ToHex(this Byte[]? data, String? separate, Int32 groupSize = 0, Int32 maxLength = -1) + { + if (data == null || data.Length <= 0) return ""; + + if (groupSize < 0) groupSize = 0; + + var count = data.Length; + if (maxLength > 0 && maxLength < count) count = maxLength; + + if (groupSize == 0 && count == data.Length) + { + // 没有分隔符 + if (String.IsNullOrEmpty(separate)) return data.ToHex(); + + // 特殊处理 + if (separate == "-") return BitConverter.ToString(data, 0, count); + } + + var len = count * 2; + if (!separate.IsNullOrEmpty()) len += (count - 1) * separate.Length; + if (groupSize > 0) + { + // 计算分组个数 + var g = (count - 1) / groupSize; + len += g * 2; + // 扣除间隔 + if (!separate.IsNullOrEmpty()) _ = g * separate.Length; + } + var sb = Pool.StringBuilder.Get(); + for (var i = 0; i < count; i++) + { + if (sb.Length > 0) + { + if (groupSize <= 0 || i % groupSize == 0) + sb.Append(separate); + //else + // sb.AppendLine(); + } + + var b = data[i]; + sb.Append(GetHexValue(b >> 4)); + sb.Append(GetHexValue(b & 0x0F)); + } + + return sb.Return(true) ?? String.Empty; + } + + /// 1个字节转为2个16进制字符 + /// + /// + public static String ToHex(this Byte b) + { + //Convert.ToString(b, 16); + var cs = new Char[2]; + var ch = b >> 4; + var cl = b & 0x0F; + cs[0] = (Char)(ch >= 0x0A ? ('A' + ch - 0x0A) : ('0' + ch)); + cs[1] = (Char)(cl >= 0x0A ? ('A' + cl - 0x0A) : ('0' + cl)); + + return new String(cs); + } + + private static Char GetHexValue(Int32 i) => i < 10 ? (Char)(i + '0') : (Char)(i - 10 + 'A'); + + /// 解密 + /// Hex编码的字符串 + /// 起始位置 + /// 长度 + /// + public static Byte[] ToHex(this String? data, Int32 startIndex = 0, Int32 length = -1) + { + if (data.IsNullOrEmpty()) return []; + + // 过滤特殊字符 + data = data.Trim() + .Replace("-", null) + .Replace("0x", null) + .Replace("0X", null) + .Replace(" ", null) + .Replace("\r", null) + .Replace("\n", null) + .Replace(",", null); + + if (length <= 0) length = data.Length - startIndex; + + var bts = new Byte[length / 2]; + for (var i = 0; i < bts.Length; i++) + { + bts[i] = Byte.Parse(data.Substring(startIndex + 2 * i, 2), NumberStyles.HexNumber); + } + return bts; + } + #endregion + + #region BASE64编码 + /// 字节数组转为Base64编码 + /// + /// + /// + /// 是否换行显示 + /// + public static String ToBase64(this Byte[] data, Int32 offset = 0, Int32 count = -1, Boolean lineBreak = false) + { + if (data == null || data.Length <= 0) return String.Empty; + + if (count <= 0) + count = data.Length - offset; + else if (offset + count > data.Length) + count = data.Length - offset; + + return Convert.ToBase64String(data, offset, count, lineBreak ? Base64FormattingOptions.InsertLineBreaks : Base64FormattingOptions.None); + } + + /// 字节数组转为Url改进型Base64编码 + /// + /// + /// + /// + public static String ToUrlBase64(this Byte[] data, Int32 offset = 0, Int32 count = -1) + { + var str = ToBase64(data, offset, count, false); + str = str.TrimEnd('='); + str = str.Replace('+', '-').Replace('/', '_'); + return str; + } + + /// Base64字符串转为字节数组 + /// + /// + public static Byte[] ToBase64(this String? data) + { + if (data.IsNullOrWhiteSpace()) return []; + + data = data.Trim(); + if (data[^1] != '=') + { + // 如果不是4的整数倍,后面补上等号 + var n = data.Length % 4; + if (n > 0) data += new String('=', 4 - n); + } + + // 针对Url特殊处理 + data = data.Replace('-', '+').Replace('_', '/'); + + return Convert.FromBase64String(data); + } + #endregion + + #region 搜索 + /// Boyer Moore 字符串搜索算法,比KMP更快,常用于IDE工具的查找 + /// + /// + /// + /// + /// + public static Int32 IndexOf(this Byte[] source, Byte[] pattern, Int32 offset = 0, Int32 count = -1) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (pattern == null) throw new ArgumentNullException(nameof(pattern)); + + var total = source.Length; + var length = pattern.Length; + + if (count > 0 && total > offset + count) total = offset + count; + if (total == 0 || length == 0 || length > total) return -1; + + // 初始化坏字符,即不匹配字符 + var bads = new Int32[256]; + for (var i = 0; i < 256; i++) + { + bads[i] = length; + } + + // 搜索词每个字母在坏字符中的最小位置 + var last = length - 1; + for (var i = 0; i < last; i++) + { + bads[pattern[i]] = last - i; + } + + var index = offset; + while (index <= total - length) + { + // 尾部开始比较 + for (var i = last; source[index + i] == pattern[i]; i--) + { + if (i == 0) return index; + } + + // 坏字符规则:后移位数 = 坏字符的位置 - 搜索词中的上一次出现位置 + index += bads[source[index + last]]; + } + + return -1; + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/IO/PathHelper.cs b/src/Admin/ThingsGateway.NewLife.X/IO/PathHelper.cs new file mode 100644 index 000000000..b4fd4bf7a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/IO/PathHelper.cs @@ -0,0 +1,550 @@ +using System.IO.Compression; + +using ThingsGateway.NewLife; + +namespace System.IO; + +/// 路径操作帮助 +/// +/// 文档 https://newlifex.com/core/path_helper +/// +/// GetBasePath 依赖BasePath,支持参数和环境变量设置,主要用于存放X组件自身配置和日志等目录。 +/// GetFullPath 依赖BaseDirectory,默认为应用程序域基础目录,支持参数和环境变量设置,此时跟GetBasePath保持一致。 +/// +/// GetFullPath更多用于表示当前工作目录,不可以轻易修改为Environment.CurrentDirectory。 +/// 在vs运行应用时,Environment.CurrentDirectory是源码文件所在目录,而不是可执行文件目录。 +/// 在StarAgent运行应用时,BasePath和Environment.CurrentDirectory都被修改为工作目录。 +/// +public static class PathHelper +{ + #region 属性 + /// 基础目录。GetBasePath依赖于此,默认为当前应用程序域基础目录。用于X组件内部各目录,专门为函数计算而定制 + /// + /// 为了适应函数计算,该路径将支持从命令行参数和环境变量读取 + /// + public static String? BasePath { get; set; } + + /// 基准目录。GetFullPath依赖于此,默认为当前应用程序域基础目录。支持BasePath参数修改 + /// + /// 为了适应函数计算,该路径将支持从命令行参数和环境变量读取 + /// + public static String? BaseDirectory { get; set; } + #endregion + + #region 静态构造 + static PathHelper() + { + var dir = ""; + // 命令参数 + var args = Environment.GetCommandLineArgs(); + for (var i = 0; i < args.Length; i++) + { + if (args[i].EqualIgnoreCase("-BasePath", "--BasePath") && i + 1 < args.Length) + { + dir = args[i + 1]; + break; + } + } + + // 环境变量 + if (dir.IsNullOrEmpty()) dir = ThingsGateway.NewLife.Runtime.GetEnvironmentVariable("BasePath"); + + if (!dir.IsNullOrEmpty()) BaseDirectory = dir; + + // 最终取应用程序域。Linux下编译为单文件时,应用程序释放到临时目录,应用程序域基路径不对,当前目录也不一定正确,唯有进程路径正确 + if (dir.IsNullOrEmpty()) dir = AppDomain.CurrentDomain.BaseDirectory; + if (dir.IsNullOrEmpty()) dir = Environment.CurrentDirectory; + + // Xamarin 在 Android 上无法使用应用所在目录写入各种文件,改用临时目录 + //if (dir.IsNullOrEmpty() || dir == "/") + //{ + // if (args != null && args.Length > 0) dir = Path.GetDirectoryName(args[0]); + //} + if (dir.IsNullOrEmpty() || dir == "/") + { + dir = Path.GetTempPath(); + } + + if (!dir.IsNullOrEmpty()) BasePath = GetPath(dir, 1); + } + #endregion + + #region 路径操作辅助 + private static String GetPath(String path, Int32 mode) + { + // 处理路径分隔符,兼容Windows和Linux + var sep = Path.DirectorySeparatorChar; + var sep2 = sep == '/' ? '\\' : '/'; + path = path.Replace(sep2, sep); + + var dir = mode switch + { + 1 => BaseDirectory ?? AppDomain.CurrentDomain.BaseDirectory ?? BasePath, + 2 => BasePath, + 3 => Environment.CurrentDirectory, + _ => "", + }; + if (dir.IsNullOrEmpty()) return Path.GetFullPath(path); + + // 处理网络路径 + if (path.StartsWith(@"\\", StringComparison.Ordinal)) return Path.GetFullPath(path); + + // 考虑兼容Linux + if (!ThingsGateway.NewLife.Runtime.Mono) + { + //if (!Path.IsPathRooted(path)) + //!!! 注意:不能直接依赖于Path.IsPathRooted判断,/和\开头的路径虽然是绝对路径,但是它们不是驱动器级别的绝对路径 + if (/*path[0] == sep ||*/ path[0] == sep2 || !Path.IsPathRooted(path)) + { + path = path.TrimStart('~'); + + path = path.TrimStart(sep); + path = Path.Combine(dir, path); + } + } + else + { + if (path[0] == sep2 || !Path.IsPathRooted(path)) + { + path = path.TrimStart(sep); + path = Path.Combine(dir, path); + } + } + + return Path.GetFullPath(path); + } + + /// 获取文件或目录基于应用程序域基目录的全路径,过滤相对目录 + /// 不确保目录后面一定有分隔符,是否有分隔符由原始路径末尾决定 + /// 文件或目录 + /// + public static String GetFullPath(this String path) + { + if (String.IsNullOrEmpty(path)) return path; + + return GetPath(path, 1); + } + + /// 获取文件或目录的全路径,过滤相对目录。用于X组件内部各目录,专门为函数计算而定制 + /// 不确保目录后面一定有分隔符,是否有分隔符由原始路径末尾决定 + /// 文件或目录 + /// + public static String GetBasePath(this String path) + { + if (String.IsNullOrEmpty(path)) return path; + + return GetPath(path, 2); + } + + /// 获取文件或目录基于当前目录的全路径,过滤相对目录 + /// 不确保目录后面一定有分隔符,是否有分隔符由原始路径末尾决定 + /// 文件或目录 + /// + public static String GetCurrentPath(this String path) + { + if (String.IsNullOrEmpty(path)) return path; + + return GetPath(path, 3); + } + + /// 确保目录存在,若不存在则创建 + /// + /// 斜杠结尾的路径一定是目录,无视第二参数; + /// 默认是文件,这样子只需要确保上一层目录存在即可,否则如果把文件当成了目录,目录的创建会导致文件无法创建。 + /// + /// 文件路径或目录路径,斜杠结尾的路径一定是目录,无视第二参数 + /// 该路径是否是否文件路径。文件路径需要取目录部分 + /// + public static String EnsureDirectory(this String path, Boolean isfile = true) + { + if (String.IsNullOrEmpty(path)) return path; + + path = path.GetFullPath(); + if (File.Exists(path) || Directory.Exists(path)) return path; + + var dir = path; + // 斜杠结尾的路径一定是目录,无视第二参数 + if (dir[^1] == Path.DirectorySeparatorChar) + dir = Path.GetDirectoryName(path); + else if (isfile) + dir = Path.GetDirectoryName(path); + + /*!!! 基础类库的用法应该有明确的用途,而不是通过某些小伎俩去让人猜测 !!!*/ + + //// 如果有圆点说明可能是文件 + //var p1 = dir.LastIndexOf('.'); + //if (p1 >= 0) + //{ + // // 要么没有斜杠,要么圆点必须在最后一个斜杠后面 + // var p2 = dir.LastIndexOf('\\'); + // if (p2 < 0 || p2 < p1) dir = Path.GetDirectoryName(path); + //} + + if (!String.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir); + + return path; + } + + /// 合并多段路径 + /// + /// + /// + public static String CombinePath(this String? path, params String[] ps) + { + path ??= String.Empty; + if (ps == null || ps.Length <= 0) return path; + + //return Path.Combine(path, path2); + foreach (var item in ps) + { + if (!item.IsNullOrEmpty()) path = Path.Combine(path, item); + } + return path; + } + #endregion + + #region 文件扩展 + /// 文件路径作为文件信息 + /// + /// + public static FileInfo AsFile(this String file) => new(file.GetFullPath()); + + /// 从文件中读取数据 + /// + /// + /// + /// + public static Byte[] ReadBytes(this FileInfo file, Int32 offset = 0, Int32 count = -1) + { + using var fs = file.OpenRead(); + fs.Position = offset; + + if (count < 0) count = (Int32)(fs.Length - offset); + + var buf = new Byte[count]; + fs.ReadExactly(buf, 0, buf.Length); + return buf; + } + + /// 把数据写入文件指定位置 + /// + /// + /// + /// + public static FileInfo WriteBytes(this FileInfo file, Byte[] data, Int32 offset = 0) + { + using (var fs = file.OpenWrite()) + { + fs.Position = offset; + + fs.Write(data, offset, data.Length); + } + + return file; + } + + ///// 读取所有文本,自动检测编码 + ///// 性能较File.ReadAllText略慢,可通过提前检测BOM编码来优化 + ///// + ///// + ///// + //public static String ReadText(this FileInfo file, Encoding encoding = null) + //{ + // using var fs = file.OpenRead(); + // if (encoding == null) encoding = fs.Detect() ?? Encoding.UTF8; + // using var reader = new StreamReader(fs, encoding); + // return reader.ReadToEnd(); + //} + + ///// 把文本写入文件,自动检测编码 + ///// + ///// + ///// + ///// + //public static FileInfo WriteText(this FileInfo file, String text, Encoding encoding = null) + //{ + // using var fs = file.OpenWrite(); + // if (encoding == null) encoding = fs.Detect() ?? Encoding.UTF8; + // using var writer = new StreamWriter(fs, encoding); + // writer.Write(text); + + // return file; + //} + + /// 复制到目标文件,目标文件必须已存在,且源文件较新 + /// 源文件 + /// 目标文件 + /// + public static Boolean CopyToIfNewer(this FileInfo fi, String destFileName) + { + // 源文件必须存在 + if (fi == null || !fi.Exists) return false; + + var dest = destFileName.AsFile(); + // 目标文件必须存在且源文件较新 + if (dest.Exists && fi.LastWriteTime > dest.LastWriteTime) + { + fi.CopyTo(destFileName, true); + return true; + } + + return false; + } + + /// 打开并读取 + /// 文件信息 + /// 是否压缩 + /// 要对文件流操作的委托 + /// + public static Int64 OpenRead(this FileInfo file, Boolean compressed, Action func) + { + if (compressed) + { + using var fs = file.OpenRead(); + using var gs = new GZipStream(fs, CompressionMode.Decompress, true); + using var bs = new BufferedStream(gs); + func(bs); + return fs.Position; + } + else + { + using var fs = file.OpenRead(); + func(fs); + return fs.Position; + } + } + + /// 打开并写入 + /// 文件信息 + /// 是否压缩 + /// 要对文件流操作的委托 + /// + public static Int64 OpenWrite(this FileInfo file, Boolean compressed, Action func) + { + file.FullName.EnsureDirectory(true); + + using var fs = file.OpenWrite(); + if (compressed) + { + using var gs = new GZipStream(fs, CompressionLevel.Optimal, true); + func(gs); + } + else + { + func(fs); + } + + fs.SetLength(fs.Position); + fs.Flush(); + return fs.Position; + } + + /// 解压缩 + /// + /// + /// 是否覆盖目标同名文件 + public static void Extract(this FileInfo fi, String destDir, Boolean overwrite = false) + { + if (destDir.IsNullOrEmpty()) destDir = Path.GetDirectoryName(fi.FullName).CombinePath(fi.Name); + + destDir = destDir.GetFullPath(); + //ZipFile.ExtractToDirectory(fi.FullName, destDir); + + if (fi.Name.EndsWithIgnoreCase(".zip")) + { +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP + ZipFile.ExtractToDirectory(fi.FullName, destDir, overwrite); +#else + using var zip = ZipFile.Open(fi.FullName, ZipArchiveMode.Read, null); + var di = Directory.CreateDirectory(destDir); + var fullName = di.FullName; + foreach (var item in zip.Entries) + { + var fullPath = Path.GetFullPath(Path.Combine(fullName, item.FullName)); + if (!fullPath.StartsWith(fullName, StringComparison.OrdinalIgnoreCase)) + throw new IOException("IO_ExtractingResultsInOutside"); + + if (Path.GetFileName(fullPath).Length == 0) + { + if (item.Length != 0L) throw new IOException("IO_DirectoryNameWithData"); + + Directory.CreateDirectory(fullPath); + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); + try + { + item.ExtractToFile(fullPath, overwrite); + } + catch { } + } + } +#endif + } + else + { + throw new NotSupportedException(); + //new SevenZip().Extract(fi.FullName, destDir); + } + } + + /// 压缩文件 + /// + /// + public static void Compress(this FileInfo fi, String destFile) + { + if (destFile.IsNullOrEmpty()) destFile = fi.Name + ".zip"; + + destFile = destFile.GetFullPath(); + if (File.Exists(destFile)) File.Delete(destFile); + + if (destFile.EndsWithIgnoreCase(".zip")) + { + using var zip = ZipFile.Open(destFile, ZipArchiveMode.Create); + zip.CreateEntryFromFile(fi.FullName, fi.Name, CompressionLevel.Optimal); + } + else + { + throw new NotSupportedException(); + //new SevenZip().Compress(fi.FullName, destFile); + } + } + #endregion + + #region 目录扩展 + /// 路径作为目录信息 + /// + /// + public static DirectoryInfo AsDirectory(this String dir) => new(dir.GetFullPath()); + + /// 获取目录内所有符合条件的文件,支持多文件扩展匹配 + /// 目录 + /// 文件扩展列表。比如*.exe;*.dll;*.config + /// 是否包含所有子孙目录文件 + /// + public static IEnumerable GetAllFiles(this DirectoryInfo di, String? exts = null, Boolean allSub = false) + { + if (di == null || !di.Exists) yield break; + + if (String.IsNullOrEmpty(exts)) exts = "*"; + var opt = allSub ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly; + + foreach (var pattern in exts.Split(";", "|", ",")) + { + foreach (var item in di.GetFiles(pattern, opt)) + { + yield return item; + } + } + } + + /// 复制目录中的文件 + /// 源目录 + /// 目标目录 + /// 文件扩展列表。比如*.exe;*.dll;*.config + /// 是否包含所有子孙目录文件 + /// 复制每一个文件之前的回调 + /// + public static String[] CopyTo(this DirectoryInfo di, String destDirName, String? exts = null, Boolean allSub = false, Action? callback = null) + { + if (!di.Exists) return []; + + var list = new List(); + + // 来源目录根,用于截断 + var root = di.FullName.EnsureEnd(Path.DirectorySeparatorChar.ToString()); + foreach (var item in di.GetAllFiles(exts, allSub)) + { + var name = item.FullName.TrimStart(root); + var dst = destDirName.CombinePath(name); + callback?.Invoke(name); + item.CopyTo(dst.EnsureDirectory(true), true); + + list.Add(dst); + } + + return list.ToArray(); + } + + /// 对比源目录和目标目录,复制双方都存在且源目录较新的文件 + /// 源目录 + /// 目标目录 + /// 文件扩展列表。比如*.exe;*.dll;*.config + /// 是否包含所有子孙目录文件 + /// 复制每一个文件之前的回调 + /// + public static String[] CopyToIfNewer(this DirectoryInfo di, String destDirName, String? exts = null, Boolean allSub = false, Action? callback = null) + { + var dest = destDirName.AsDirectory(); + if (!dest.Exists) return []; + + var list = new List(); + + // 目标目录根,用于截断 + var root = dest.FullName.EnsureEnd(Path.DirectorySeparatorChar.ToString()); + // 遍历目标目录,拷贝同名文件 + foreach (var item in dest.GetAllFiles(exts, allSub)) + { + var name = item.FullName.TrimStart(root); + var fi = di.FullName.CombinePath(name).AsFile(); + //fi.CopyToIfNewer(item.FullName); + if (fi.Exists && item.Exists && fi.LastWriteTime > item.LastWriteTime) + { + callback?.Invoke(name); + fi.CopyTo(item.FullName, true); + list.Add(fi.FullName); + } + } + + return list.ToArray(); + } + + /// 从多个目标目录复制较新文件到当前目录 + /// 当前目录 + /// 多个目标目录 + /// 文件扩展列表。比如*.exe;*.dll;*.config + /// 是否包含所有子孙目录文件 + /// + public static String[] CopyIfNewer(this DirectoryInfo di, String[] source, String? exts = null, Boolean allSub = false) + { + var list = new List(); + var cur = di.FullName; + foreach (var item in source) + { + // 跳过当前目录 + if (item.GetFullPath().EqualIgnoreCase(cur)) continue; + + Console.WriteLine("复制 {0} => {1}", item, cur); + + try + { + var rs = item.AsDirectory().CopyToIfNewer(cur, exts, allSub, name => + { + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("\t{1}\t{0}", name, item.CombinePath(name).AsFile().LastWriteTime.ToFullString()); + Console.ResetColor(); + }); + if (rs != null && rs.Length > 0) list.AddRange(rs); + } + catch (Exception ex) { Console.WriteLine(" " + ex.Message); } + } + + return list.ToArray(); + } + + /// 压缩 + public static void Compress(this DirectoryInfo di, String? destFile = null, bool includeBaseDirectory = true) + { + if (destFile.IsNullOrEmpty()) destFile = di.Name + ".zip"; + + if (File.Exists(destFile)) File.Delete(destFile); + + if (destFile.EndsWithIgnoreCase(".zip")) + ZipFile.CreateFromDirectory(di.FullName, destFile, CompressionLevel.Optimal, includeBaseDirectory); + else + //new SevenZip().Compress(di.FullName, destFile); + throw new NotSupportedException(); + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/ActionLog.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/ActionLog.cs new file mode 100644 index 000000000..1e7f6af98 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/ActionLog.cs @@ -0,0 +1,22 @@ +namespace ThingsGateway.NewLife.Log; + +/// 依托于动作的日志类 +public class ActionLog : Logger +{ + /// 方法 + public Action Method { get; set; } + + /// 使用指定方法否则动作日志 + /// + public ActionLog(Action action) => Method = action; + + /// 写日志 + /// + /// + /// + protected override void OnWrite(LogLevel level, String format, params Object?[] args) => Method?.Invoke(format, args); + + /// 已重载 + /// + public override String ToString() => Method + ""; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/CompositeLog.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/CompositeLog.cs new file mode 100644 index 000000000..99f8f97ea --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/CompositeLog.cs @@ -0,0 +1,106 @@ +using System.Text; + +namespace ThingsGateway.NewLife.Log; + +/// 复合日志提供者,多种方式输出 +public class CompositeLog : Logger +{ + /// 日志提供者集合 + public List Logs { get; set; } = new List(); + + /// 日志等级,只输出大于等于该级别的日志,默认Info,打开ThingsGateway.NewLife.Debug时默认为最低的Debug + public override LogLevel Level + { + get => base.Level; set + { + base.Level = value; + + foreach (var item in Logs) + { + // 使用外层层级 + item.Level = Level; + } + } + } + + /// 实例化 + public CompositeLog() { } + + /// 实例化 + /// + public CompositeLog(ILog log) { Logs.Add(log); Level = log.Level; } + + /// 实例化 + /// + /// + public CompositeLog(ILog log1, ILog log2) + { + Add(log1).Add(log2); + Level = log1.Level; + if (Level > log2.Level) Level = log2.Level; + } + + /// 添加一个日志提供者 + /// + /// + public CompositeLog Add(ILog log) { Logs.Add(log); return this; } + + /// 删除日志提供者 + /// + /// + public CompositeLog Remove(ILog log) { Logs.Remove(log); return this; } + + /// 写日志 + /// + /// + /// + protected override void OnWrite(LogLevel level, String format, params Object?[] args) + { + if (Logs != null) + { + foreach (var item in Logs) + { + item.Write(level, format, args); + } + } + } + + /// 从复合日志提供者中提取指定类型的日志提供者 + /// + /// + public TLog? Get() where TLog : class + { + foreach (var item in Logs) + { + if (item != null) + { + if (item is TLog) return item as TLog; + + // 递归获取内层日志 + if (item is CompositeLog cmp) + { + var log = cmp.Get(); + if (log != null) return log; + } + } + } + + return null; + } + + /// 已重载。 + /// + public override String ToString() + { + var sb = new StringBuilder(); + sb.Append(GetType().Name); + + foreach (var item in Logs) + { + sb.Append(' '); + sb.Append(item + ""); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/ConsoleLog.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/ConsoleLog.cs new file mode 100644 index 000000000..6bd2e9f93 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/ConsoleLog.cs @@ -0,0 +1,60 @@ +using System.Collections.Concurrent; + +namespace ThingsGateway.NewLife.Log; + +/// 控制台输出日志 +public class ConsoleLog : Logger +{ + /// 是否使用多种颜色,默认使用 + public Boolean UseColor { get; set; } = true; + + /// 写日志 + /// + /// + /// + protected override void OnWrite(LogLevel level, String format, params Object?[] args) + { + // 吃掉异常,避免应用崩溃 + try + { + var e = WriteLogEventArgs.Current.Set(level).Set(Format(format, args), null); + + if (!UseColor) + { + Console.WriteLine(e.GetAndReset()); + return; + } + + lock (this) + { + var cc = Console.ForegroundColor; + cc = level switch + { + LogLevel.Warn => ConsoleColor.Yellow, + LogLevel.Error or LogLevel.Fatal => ConsoleColor.Red, + _ => GetColor(e.ThreadID), + }; + var old = Console.ForegroundColor; + Console.ForegroundColor = cc; + Console.WriteLine(e.GetAndReset()); + Console.ForegroundColor = old; + } + } + catch { } + } + + private static readonly ConcurrentDictionary dic = new(); + private static readonly ConsoleColor[] colors = [ + ConsoleColor.Green, ConsoleColor.Cyan, ConsoleColor.Magenta, ConsoleColor.White, ConsoleColor.Yellow, + ConsoleColor.DarkGreen, ConsoleColor.DarkCyan, ConsoleColor.DarkMagenta, ConsoleColor.DarkRed, ConsoleColor.DarkYellow ]; + private static ConsoleColor GetColor(Int32 threadid) + { + if (threadid == 1) return ConsoleColor.Gray; + + return dic.GetOrAdd(threadid, k => colors[k % colors.Length]); + } + + /// 已重载。 + /// + public override String ToString() => $"{GetType().Name} UseColor={UseColor}"; +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/ILog.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/ILog.cs new file mode 100644 index 000000000..057a11269 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/ILog.cs @@ -0,0 +1,47 @@ +#nullable enable +namespace ThingsGateway.NewLife.Log; + +/// 日志接口 +/// +/// 文档 https://newlifex.com/core/log +/// +public interface ILog +{ + /// 写日志 + /// 日志级别 + /// 格式化字符串 + /// 格式化参数 + void Write(LogLevel level, String format, params Object?[] args); + + /// 调试日志 + /// 格式化字符串 + /// 格式化参数 + void Debug(String format, params Object?[] args); + + /// 信息日志 + /// 格式化字符串 + /// 格式化参数 + void Info(String format, params Object?[] args); + + /// 警告日志 + /// 格式化字符串 + /// 格式化参数 + void Warn(String format, params Object?[] args); + + /// 错误日志 + /// 格式化字符串 + /// 格式化参数 + void Error(String format, params Object?[] args); + + /// 严重错误日志 + /// 格式化字符串 + /// 格式化参数 + void Fatal(String format, params Object?[] args); + + /// 是否启用日志 + Boolean Enable { get; set; } + + /// 日志等级,只输出大于等于该级别的日志,默认Info + LogLevel Level { get; set; } +} +#nullable restore \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/ILogFeature.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/ILogFeature.cs new file mode 100644 index 000000000..b40f1493e --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/ILogFeature.cs @@ -0,0 +1,18 @@ +namespace ThingsGateway.NewLife.Log; + +/// 日志功能接口 +public interface ILogFeature +{ + /// 日志。非空,默认为Logger.Null + ILog Log { get; set; } +} + +/// 日志功能扩展 +public static class LogFeatureExtensions +{ + /// 写日志 + /// 日志功能 + /// 格式化字符串 + /// 格式化参数,特殊处理时间日期和异常对象 + public static void WriteLog(ILogFeature logFeature, String format, params Object?[] args) => logFeature.Log?.Info(format, args); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/LevelLog.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/LevelLog.cs new file mode 100644 index 000000000..20ad2e659 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/LevelLog.cs @@ -0,0 +1,30 @@ +namespace ThingsGateway.NewLife.Log; + +/// 等级日志提供者,不同等级分不同日志输出 +public class LevelLog : Logger +{ + private Dictionary _logs = new Dictionary(); + + /// 通过指定路径和文件格式来实例化等级日志,每个等级使用自己的日志输出 + /// + /// + public LevelLog(String logPath, String fileFormat) + { + foreach (var item in Enum.GetValues(typeof(LogLevel))) + { + if (item is LogLevel level && level is > LogLevel.All and < LogLevel.Off) + { + _logs[level] = new TextFileLog(logPath, false, fileFormat) { Level = level }; + } + } + } + + /// 写日志 + /// + /// + /// + protected override void OnWrite(LogLevel level, String format, params Object?[] args) + { + if (_logs.TryGetValue(level, out var log)) log.Write(level, format, args); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/LogEventListener.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/LogEventListener.cs new file mode 100644 index 000000000..7c6dcdb4c --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/LogEventListener.cs @@ -0,0 +1,94 @@ +using System.Diagnostics.Tracing; + +namespace ThingsGateway.NewLife.Log; + +/// 日志事件监听器。用于监听内置事件并写入日志 +public class LogEventListener : EventListener +{ + private readonly HashSet _hash = new(); + private readonly HashSet _hash2 = new(); + + /// 实例化 + /// + public LogEventListener(String[] sources) + { + foreach (var item in sources) + { + _hash.Add(item); + } + } + + /// 创建事件源。此时决定要不要跟踪 + /// + protected override void OnEventSourceCreated(EventSource eventSource) + { + if (_hash.Contains(eventSource.Name)) + { + var log = XTrace.Log; + + var level = log.Level switch + { + LogLevel.All => EventLevel.LogAlways, + LogLevel.Debug => EventLevel.Verbose, + LogLevel.Info => EventLevel.Informational, + LogLevel.Warn => EventLevel.Warning, + LogLevel.Error => EventLevel.Error, + LogLevel.Fatal => EventLevel.Critical, + LogLevel.Off => throw new NotImplementedException(), + _ => EventLevel.Informational, + }; + + EnableEvents(eventSource, level); + } + else if (!_hash2.Contains(eventSource.Name)) + { + _hash2.Add(eventSource.Name); + + XTrace.WriteLine($"Source={eventSource.Name}"); + } + } + + /// 写入事件。监听器拦截,并写入日志 + /// + protected override void OnEventWritten(EventWrittenEventArgs eventData) + { + var log = XTrace.Log; + + var level = eventData.Level switch + { + EventLevel.Informational => LogLevel.Info, + EventLevel.LogAlways => LogLevel.All, + EventLevel.Critical => LogLevel.Fatal, + EventLevel.Error => LogLevel.Error, + EventLevel.Warning => LogLevel.Warn, + EventLevel.Verbose => LogLevel.Debug, + _ => LogLevel.Info, + }; + +#if NET452 + XTrace.WriteLine($"#{eventData.EventSource?.Name} ID = {eventData.EventId}"); + for (var i = 0; i < eventData.Payload.Count; i++) + { + XTrace.WriteLine($"\tValue = \"{eventData.Payload[i]}\""); + } +#elif NETFRAMEWORK || NETSTANDARD2_0 + XTrace.WriteLine($"#{eventData.EventSource?.Name} ID = {eventData.EventId} Name = {eventData.EventName}"); + for (var i = 0; i < eventData.Payload.Count; i++) + { + XTrace.WriteLine($"\tName = \"{eventData.PayloadNames[i]}\" Value = \"{eventData.Payload[i]}\""); + } +#else + XTrace.WriteLine($"#{eventData.EventSource?.Name} ThreadID = {eventData.OSThreadId} ID = {eventData.EventId} Name = {eventData.EventName}"); + var names = eventData.PayloadNames; + if (eventData.Payload != null && names != null) + { + for (var i = 0; i < eventData.Payload.Count && i < names.Count; i++) + { + XTrace.WriteLine($"\tName = \"{names[i]}\" Value = \"{eventData.Payload[i]}\""); + } + } +#endif + + if (!eventData.Message.IsNullOrEmpty()) log.Write(level, eventData.Message); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/LogLevel.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/LogLevel.cs new file mode 100644 index 000000000..4ccc38314 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/LogLevel.cs @@ -0,0 +1,28 @@ + +namespace ThingsGateway.NewLife.Log +{ + /// 日志等级 + public enum LogLevel : System.Byte + { + /// 打开所有日志记录 + All = 0, + + /// 最低调试。细粒度信息事件对调试应用程序非常有帮助 + Debug, + + /// 普通消息。在粗粒度级别上突出强调应用程序的运行过程 + Info, + + /// 警告 + Warn, + + /// 错误 + Error, + + /// 严重错误 + Fatal, + + /// 关闭所有日志记录 + Off = 0xFF + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/Logger.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/Logger.cs new file mode 100644 index 000000000..069f326cc --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/Logger.cs @@ -0,0 +1,267 @@ +using System.ComponentModel; +using System.Reflection; +using System.Runtime; +using System.Runtime.InteropServices; +using System.Text; + +namespace ThingsGateway.NewLife.Log; + +/// 日志基类。提供日志的基本实现 +[EditorBrowsable(EditorBrowsableState.Advanced)] +public abstract class Logger : ILog +{ + #region 主方法 + /// 调试日志 + /// 格式化字符串 + /// 格式化参数 + public virtual void Debug(String format, params Object?[] args) => Write(LogLevel.Debug, format, args); + + /// 信息日志 + /// 格式化字符串 + /// 格式化参数 + public virtual void Info(String format, params Object?[] args) => Write(LogLevel.Info, format, args); + + /// 警告日志 + /// 格式化字符串 + /// 格式化参数 + public virtual void Warn(String format, params Object?[] args) => Write(LogLevel.Warn, format, args); + + /// 错误日志 + /// 格式化字符串 + /// 格式化参数 + public virtual void Error(String format, params Object?[] args) => Write(LogLevel.Error, format, args); + + /// 严重错误日志 + /// 格式化字符串 + /// 格式化参数 + public virtual void Fatal(String format, params Object?[] args) => Write(LogLevel.Fatal, format, args); + #endregion + + #region 核心方法 + /// 写日志 + /// + /// + /// + public virtual void Write(LogLevel level, String format, params Object?[] args) + { + if (Enable && level >= Level) OnWrite(level, format, args); + } + + /// 写日志 + /// + /// + /// + protected abstract void OnWrite(LogLevel level, String format, params Object?[] args); + #endregion + + #region 辅助方法 + /// 格式化参数,特殊处理异常和时间 + /// + /// + /// + protected virtual String Format(String format, Object?[]? args) + { + //处理时间的格式化 + if (args != null && args.Length > 0) + { + // 特殊处理异常 + if (args.Length == 1 && args[0] is Exception ex && (format.IsNullOrEmpty() || format == "{0}")) + return ex.GetMessage(); + + for (var i = 0; i < args.Length; i++) + { + if (args[i] != null && args[i] is DateTime dt) + { + // 根据时间值的精确度选择不同的格式化输出 + //var dt = (DateTime)args[i]; + // todo: 解决系统使用utc时间时,日志文件被跨天 + dt = dt.AddHours(Setting.Current.UtcIntervalHours); + if (dt.Millisecond > 0) + args[i] = dt.ToString("yyyy-MM-dd HH:mm:ss.fff"); + else if (dt.Hour > 0 || dt.Minute > 0 || dt.Second > 0) + args[i] = dt.ToString("yyyy-MM-dd HH:mm:ss"); + else + args[i] = dt.ToString("yyyy-MM-dd"); + } + } + } + if (args == null || args.Length <= 0) return format; + + //format = format.Replace("{", "{{").Replace("}", "}}"); + + return String.Format(format, args); + } + #endregion + + #region 属性 + /// 是否启用日志。默认true + public virtual Boolean Enable { get; set; } = true; + + private LogLevel? _Level; + /// 日志等级,只输出大于等于该级别的日志,默认Info + public virtual LogLevel Level + { + get + { + if (_Level != null) return _Level.Value; + + return Setting.Current.LogLevel; + } + set { _Level = value; } + } + #endregion + + #region 静态空实现 + /// 空日志实现 + public static ILog Null { get; } = new NullLogger(); + + private sealed class NullLogger : Logger + { + public override Boolean Enable { get => false; set { } } + + protected override void OnWrite(LogLevel level, String format, params Object?[] args) { } + } + #endregion + + #region 日志头 + /// 输出日志头,包含所有环境信息 + protected static String GetHead() + { + var process = System.Diagnostics.Process.GetCurrentProcess(); + var name = String.Empty; + var ver = Environment.Version + ""; + var target = ""; + var asm = Assembly.GetEntryAssembly(); + if (asm != null) + { + if (String.IsNullOrEmpty(name)) + { + var att = asm.GetCustomAttribute(); + if (att != null) name = att.Title; + } + + if (String.IsNullOrEmpty(name)) + { + var att = asm.GetCustomAttribute(); + if (att != null) name = att.Product; + } + + if (String.IsNullOrEmpty(name)) + { + var att = asm.GetCustomAttribute(); + if (att != null) name = att.Description; + } + + var tar = asm.GetCustomAttribute(); + if (tar != null) target = !tar.FrameworkDisplayName.IsNullOrEmpty() ? tar.FrameworkDisplayName : tar.FrameworkName; + } +#if !NETFRAMEWORK + target = RuntimeInformation.FrameworkDescription; +#endif + + if (String.IsNullOrEmpty(name)) + { + try + { + name = process.ProcessName; + } + catch { } + } + var sb = new StringBuilder(); + sb.AppendFormat("#Software: {0}\r\n", name); + sb.AppendFormat("#ProcessID: {0}{1}\r\n", process.Id, Environment.Is64BitProcess ? " x64" : ""); + sb.AppendFormat("#AppDomain: {0}\r\n", AppDomain.CurrentDomain.FriendlyName); + + var fileName = String.Empty; + // MonoAndroid无法识别MainModule,致命异常 + try + { + fileName = process.MainModule?.FileName; + } + catch { } + if (fileName.IsNullOrEmpty() || fileName.EndsWithIgnoreCase("dotnet", "dotnet.exe")) + { + try + { + fileName = process.StartInfo.FileName; + } + catch { } + } + if (!fileName.IsNullOrEmpty()) sb.AppendFormat("#FileName: {0}\r\n", fileName); + + // 应用域目录 + var baseDir = AppDomain.CurrentDomain.BaseDirectory; + sb.AppendFormat("#BaseDirectory: {0}\r\n", baseDir); + + // 当前目录。如果由别的进程启动,默认的当前目录就是父级进程的当前目录 + var curDir = Environment.CurrentDirectory; + //if (!curDir.EqualIC(baseDir) && !(curDir + "\\").EqualIC(baseDir)) + if (!baseDir.EqualIgnoreCase(curDir, curDir + "\\", curDir + "/")) + sb.AppendFormat("#CurrentDirectory: {0}\r\n", curDir); + + var basePath = PathHelper.BasePath; + if (basePath != baseDir) + sb.AppendFormat("#BasePath: {0}\r\n", basePath); + + // 临时目录 + sb.AppendFormat("#TempPath: {0}\r\n", Path.GetTempPath()); + + // 命令行不为空,也不是文件名时,才输出 + // 当使用cmd启动程序时,这里就是用户输入的整个命令行,所以可能包含空格和各种符号 + var line = Environment.CommandLine; + if (!line.IsNullOrEmpty()) + sb.AppendFormat("#CommandLine: {0}\r\n", line); + + var apptype = ""; + if (Runtime.IsWeb) + apptype = "Web"; + else if (!Environment.UserInteractive) + apptype = "Service"; + else if (Runtime.IsConsole) + apptype = "Console"; + else + apptype = "WinForm"; + + if (Runtime.Container) apptype += "(Container)"; + + sb.AppendFormat("#ApplicationType: {0}\r\n", apptype); + sb.AppendFormat("#CLR: {0}, {1}\r\n", ver, target); + + var os = ""; + // 获取丰富的机器信息,需要提注册 MachineInfo.RegisterAsync + var mi = MachineInfo.Current; + if (mi != null) + { + os = mi.OSName + " " + mi.OSVersion; + } + else + { + // 特别识别Linux发行版 + os = Environment.OSVersion + ""; + if (Runtime.Linux) os = MachineInfo.GetLinuxName(); + } + + sb.AppendFormat("#OS: {0}, {1}/{2}\r\n", os, Environment.MachineName, Environment.UserName); + sb.AppendFormat("#CPU: {0}\r\n", Environment.ProcessorCount); + if (mi != null) + { + sb.AppendFormat("#Memory: {0:n0}M/{1:n0}M\r\n", mi.AvailableMemory / 1024 / 1024, mi.Memory / 1024 / 1024); + sb.AppendFormat("#Processor: {0}\r\n", mi.Processor); + if (!mi.Product.IsNullOrEmpty()) sb.AppendFormat("#Product: {0} / {1}\r\n", mi.Product, mi.Vendor); + if (mi.Temperature > 0) sb.AppendFormat("#Temperature: {0}\r\n", mi.Temperature); + } + sb.AppendFormat("#GC: IsServerGC={0}, LatencyMode={1}\r\n", GCSettings.IsServerGC, GCSettings.LatencyMode); + + ThreadPool.GetMinThreads(out var minWorker, out var minIO); + ThreadPool.GetMaxThreads(out var maxWorker, out var maxIO); + ThreadPool.GetAvailableThreads(out var avaWorker, out var avaIO); + sb.AppendFormat("#ThreadPool: Min={0}/{1}, Max={2}/{3}, Available={4}/{5}\r\n", minWorker, minIO, maxWorker, maxIO, avaWorker, avaIO); + + sb.AppendFormat("#SystemStarted: {0}\r\n", TimeSpan.FromMilliseconds(Runtime.TickCount64)); + sb.AppendFormat("#Date: {0:yyyy-MM-dd}\r\n", DateTime.Now.AddHours(Setting.Current.UtcIntervalHours)); + sb.AppendFormat("#Fields: Time ThreadID Kind Name Message\r\n"); + + return sb.ToString(); + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/Readme.md b/src/Admin/ThingsGateway.NewLife.X/Logger/Readme.md new file mode 100644 index 000000000..4f63a0f6b --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/Readme.md @@ -0,0 +1,2 @@ +## 日志 +统一ILog接口,内置控制台、文本文件、WinForm控件和网络日志等实现 diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/TextFileLog.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/TextFileLog.cs new file mode 100644 index 000000000..404bc291c --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/TextFileLog.cs @@ -0,0 +1,371 @@ +using System.Collections.Concurrent; +using System.Text; + +using ThingsGateway.NewLife.Threading; + +namespace ThingsGateway.NewLife.Log; + +/// 文本文件日志类。提供向文本文件写日志的能力 +/// +/// 两大用法: +/// 1,Create(path, fileFormat) 指定日志目录和文件名格式 +/// 2,CreateFile(path) 指定文件,一直往里面写 +/// +/// 2015-06-01 为了继承TextFileLog,增加了无参构造函数,修改了异步写日志方法为虚方法,可以进行重载 +/// +public class TextFileLog : Logger, IDisposable +{ + #region 属性 + /// 日志目录 + public String LogPath { get; set; } + + /// 日志文件格式。默认{0:yyyy_MM_dd}.log + public String FileFormat { get; set; } + + /// 日志文件上限。超过上限后拆分新日志文件,默认10MB,0表示不限制大小 + public Int32 MaxBytes { get; set; } = 10; + + /// 日志文件备份。超过备份数后,最旧的文件将被删除,默认100,0表示不限制个数 + public Int32 Backups { get; set; } = 100; + + private readonly Boolean _isFile = false; + + /// 是否当前进程的第一次写日志 + private Boolean _isFirst = false; + + /// 头部信息写入 + protected Boolean _headEnable = true; + #endregion + + #region 构造 + + internal protected TextFileLog(String path, Boolean isfile, String? fileFormat = null) + { + LogPath = path; + _isFile = isfile; + + var set = Setting.Current; + if (!fileFormat.IsNullOrEmpty()) + FileFormat = fileFormat; + else + FileFormat = set.LogFileFormat; + + MaxBytes = set.LogFileMaxBytes; + Backups = set.LogFileBackups; + + _Timer = new TimerX(DoWriteAndClose, null, 0_000, 5_000) { Async = true }; + } + + private static readonly Caching.MemoryCache cache = new Caching.MemoryCache(); + + /// 每个目录的日志实例应该只有一个,所以采用静态创建 + /// 日志目录或日志文件路径 + /// + /// + public static TextFileLog Create(String path, String? fileFormat = null) + { + //if (path.IsNullOrEmpty()) path = XTrace.LogPath; + if (path.IsNullOrEmpty()) path = "Log"; + + var key = (path + fileFormat).ToLower(); + return cache.GetOrAdd(key, k => new TextFileLog(path, false, fileFormat)); + } + + /// 每个目录的日志实例应该只有一个,所以采用静态创建 + /// 日志目录或日志文件路径 + /// + public static TextFileLog CreateFile(String path) + { + if (path.IsNullOrEmpty()) throw new ArgumentNullException(nameof(path)); + + return cache.GetOrAdd(path, k => new TextFileLog(k, true)); + } + + /// 销毁 + public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + + /// 销毁 + /// + protected virtual void Dispose(Boolean disposing) + { + _Timer.TryDispose(); + + // 销毁前把队列日志输出 + if (Interlocked.CompareExchange(ref _writing, 1, 0) == 0) WriteAndClose(DateTime.MinValue); + } + #endregion + + #region 内部方法 + private StreamWriter? LogWriter; + private String? CurrentLogFile; + private Int32 _logFileError; + + /// 初始化日志记录文件 + private StreamWriter? InitLog(String logfile) + { + try + { + logfile.EnsureDirectory(true); + + var stream = new FileStream(logfile, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + var writer = new StreamWriter(stream, Encoding.UTF8); + + // 写日志头 + if (!_isFirst) + { + _isFirst = true; + if (_headEnable) + { + // 因为指定了编码,比如UTF8,开头就会写入3个字节,所以这里不能拿长度跟0比较 + if (writer.BaseStream.Length > 10) writer.WriteLine(); + + writer.Write(GetHead()); + } + } + + _logFileError = 0; + return LogWriter = writer; + } + catch (Exception ex) + { + _logFileError++; + Console.WriteLine("创建日志文件失败:{0}", ex.Message); + return null; + } + } + + /// 获取日志文件路径 + /// + private String? GetLogFile() + { + // 单日志文件 + if (_isFile) return LogPath.GetBasePath(); + + // 目录多日志文件 + var logfile = LogPath.CombinePath(String.Format(FileFormat, TimerX.Now.AddHours(Setting.Current.UtcIntervalHours), Level)).GetBasePath(); + + // 是否限制文件大小 + if (MaxBytes == 0) return logfile; + + // 找到今天第一个未达到最大上限的文件 + var max = MaxBytes * 1024L * 1024L; + var ext = Path.GetExtension(logfile); + var name = logfile.TrimEnd(ext); + for (var i = 1; i < 1024; i++) + { + if (i > 1) logfile = $"{name}_{i}{ext}"; + + var fi = logfile.AsFile(); + if (!fi.Exists || fi.Length < max) return logfile; + } + + return null; + } + #endregion + + #region 异步写日志 + private readonly TimerX? _Timer; + private readonly ConcurrentQueue _Logs = new(); + private volatile Int32 _logCount; + private Int32 _writing; + private DateTime _NextClose; + + /// 写文件 + protected virtual void WriteFile() + { + var writer = LogWriter; + + var now = TimerX.Now; + var logFile = GetLogFile(); + if (logFile.IsNullOrEmpty()) return; + + if (!_isFile && logFile != CurrentLogFile) + { + writer.TryDispose(); + writer = null; + + CurrentLogFile = logFile; + _logFileError = 0; + } + + // 错误过多时不再尝试创建日志文件。下一天更换日志文件名后,将会再次尝试 + if (writer == null && _logFileError >= 3) return; + + // 初始化日志读写器 + writer ??= InitLog(logFile); + if (writer == null) return; + + // 依次把队列日志写入文件 + while (_Logs.TryDequeue(out var str)) + { + Interlocked.Decrement(ref _logCount); + + // 写日志。TextWriter.WriteLine内需要拷贝,浪费资源 + //writer.WriteLine(str); + writer.Write(str); + } + + // 写完一批后,刷一次磁盘 + writer?.Flush(); + + // 连续5秒没日志,就关闭 + _NextClose = now; + } + + /// 关闭文件 + private void DoWriteAndClose(Object? state) + { + // 同步写日志 + if (Interlocked.CompareExchange(ref _writing, 1, 0) == 0) WriteAndClose(_NextClose); + + // 检查文件是否超过上限 + if (!_isFile && Backups > 0) + { + // 判断日志目录是否已存在 + var di = LogPath.GetBasePath().AsDirectory(); + if (di.Exists) + { + // 删除*.del + try + { + var dels = di.GetFiles("*.del"); + if (dels != null && dels.Length > 0) + { + foreach (var item in dels) + { + item.Delete(); + } + } + } + catch { } + + var ext = Path.GetExtension(FileFormat); + var fis = di.GetFiles("*" + ext); + if (fis != null && fis.Length > Backups) + { + // 删除最旧的文件 + var retain = fis.Length - Backups; + fis = fis.OrderBy(e => e.CreationTime).Take(retain).ToArray(); + foreach (var item in fis) + { + OnWrite(LogLevel.Info, "The log file has reached the maximum limit of {0}, delete {1}, size {2: n0} Byte", Backups, item.Name, item.Length); + try + { + item.Delete(); + } + catch + { + try + { + item.MoveTo(item.FullName + ".del"); + } + catch + { + + } + } + } + } + } + } + } + + /// 写入队列日志并关闭文件 + protected virtual void WriteAndClose(DateTime closeTime) + { + try + { + // 处理残余 + var writer = LogWriter; + if (!_Logs.IsEmpty) WriteFile(); + + // 连续5秒没日志,就关闭 + if (writer != null && closeTime < TimerX.Now) + { + writer.TryDispose(); + LogWriter = null; + } + } + finally + { + _writing = 0; + } + } + #endregion + + #region 写日志 + /// 写日志 + /// + /// + /// + protected override void OnWrite(LogLevel level, String format, params Object?[] args) + { + if (!Check()) return; + + var e = WriteLogEventArgs.Current.Set(level); + // 特殊处理异常对象 + if (args != null && args.Length == 1 && args[0] is Exception ex && (format.IsNullOrEmpty() || format == "{0}")) + e = e.Set(null, ex); + else + e = e.Set(Format(format, args), null); + + // 推入队列 + Enqueue($"{e.GetAndReset()}{Environment.NewLine}"); + + WriteLog(); + } + + protected bool Check() + { + // 据@夏玉龙反馈,如果不给Log目录写入权限,日志队列积压将会导致内存暴增 + if (_logCount > 100) return false; + return true; + } + + protected void Enqueue(string data) + { + _Logs.Enqueue(data); + Interlocked.Increment(ref _logCount); + } + protected void WriteLog() + { + // 异步写日志,实时。即使这里错误,定时器那边仍然会补上 + if (Interlocked.CompareExchange(ref _writing, 1, 0) == 0) + { + // 调试级别 或 致命错误 同步写日志 + if (Setting.Current.LogLevel <= LogLevel.Debug || Level >= LogLevel.Error) + { + try + { + WriteFile(); + } + finally + { + _writing = 0; + } + } + else + { + ThreadPool.UnsafeQueueUserWorkItem(s => + { + try + { + WriteFile(); + } + catch { } + finally + { + _writing = 0; + } + }, null); + } + } + } + #endregion + + #region 辅助 + /// 已重载。 + /// + public override String ToString() => $"{GetType().Name} {LogPath}"; + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/TimeCost.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/TimeCost.cs new file mode 100644 index 000000000..d4d328d07 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/TimeCost.cs @@ -0,0 +1,76 @@ +using System.Diagnostics; + +namespace ThingsGateway.NewLife.Log +{ + /// 统计代码的时间消耗 + public class TimeCost : DisposeBase + { + #region 属性 + private Stopwatch? _sw; + + /// 名称 + public String Name { get; set; } + + /// 最大时间。毫秒 + public Int32 Max { get; set; } + + /// 日志输出 + public ILog Log { get; set; } + #endregion + + #region 构造 + /// 指定最大执行时间来构造一个代码时间统计 + /// + /// + public TimeCost(String name, Int32 msMax = 0) + { + Name = name; + Max = msMax; + Log = XTrace.Log; + + if (msMax >= 0) Start(); + } + + /// 析构 + /// + protected override void Dispose(Boolean disposing) + { + Stop(); + + base.Dispose(disposing); + } + #endregion + + #region 方法 + /// 开始 + public void Start() + { + if (_sw == null) + _sw = Stopwatch.StartNew(); + else if (!_sw.IsRunning) + _sw.Start(); + } + + /// 停止 + public void Stop() + { + if (_sw != null) + { + _sw.Stop(); + + if (Log != null && Log != Logger.Null && Log.Enable) + { + var ms = _sw.ElapsedMilliseconds; + if (ms > Max) + { + if (Max > 0) + Log.Warn("{0}执行过长警告 {1:n0}ms > {2:n0}ms", Name, ms, Max); + else + Log.Warn("{0}执行 {1:n0}ms", Name, ms); + } + } + } + } + #endregion + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/TraceStream.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/TraceStream.cs new file mode 100644 index 000000000..11b8c720c --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/TraceStream.cs @@ -0,0 +1,445 @@ +using System.Text; + +namespace ThingsGateway.NewLife.Log +{ + /// 跟踪流。包装一个基础数据流,主要用于重写Read/Write等行为,跟踪程序操作数据流的过程 + public class TraceStream : Stream + { + #region 属性 + /// 基础流 + public Stream BaseStream { get; set; } + + /// 跟踪的成员 + public ICollection TraceMembers { get; set; } + + /// 是否小端字节序。x86系列则采用Little-Endian方式存储数据;网络协议都是Big-Endian; + /// + /// 网络协议都是Big-Endian; + /// Java编译的都是Big-Endian; + /// Motorola的PowerPC是Big-Endian; + /// x86系列则采用Little-Endian方式存储数据; + /// ARM同时支持 big和little,实际应用中通常使用Little-Endian。 + /// + public Boolean IsLittleEndian { get; set; } + + private static readonly String[] DefaultTraceMembers = new String[] { "Write", "WriteByte", "Read", "ReadByte", "BeginRead", "BeginWrite", "EndRead", "EndWrite", "Seek", "Close", "Flush", "SetLength", "SetPosition" }; + + /// 显示位置的步长,位移超过此长度后输出位置。默认16,设为0不输出位置 + public Int32 ShowPositionStep { get; set; } + #endregion + + #region 基本读写方法 + /// 写入 + /// 缓冲区 + /// 偏移 + /// 数量 + public override void Write(Byte[] buffer, Int32 offset, Int32 count) + { + RaiseAction("Write", buffer, offset, count); + + BaseStream.Write(buffer, offset, count); + } + + /// 写入一个字节 + /// 数值 + public override void WriteByte(Byte value) + { + RaiseAction("WriteByte", value); + + BaseStream.WriteByte(value); + } + + /// 读取 + /// 缓冲区 + /// 偏移 + /// 数量 + /// + public override Int32 Read(Byte[] buffer, Int32 offset, Int32 count) + { + var n = BaseStream.Read(buffer, offset, count); + + RaiseAction("Read", buffer, offset, count, n); + + return n; + } + + /// 读取一个字节 + /// + public override Int32 ReadByte() + { + var n = BaseStream.ReadByte(); + + RaiseAction("ReadByte", n); + + return n; + } + #endregion + + #region 异步读写方法 + /// 异步开始读 + /// 缓冲区 + /// 偏移 + /// 数量 + /// + /// + /// + public override IAsyncResult BeginRead(Byte[] buffer, Int32 offset, Int32 count, AsyncCallback? callback, Object? state) + { + RaiseAction("BeginRead", offset, count); + + return BaseStream.BeginRead(buffer, offset, count, callback, state); + } + + /// 异步开始写 + /// 缓冲区 + /// 偏移 + /// 数量 + /// + /// + /// + public override IAsyncResult BeginWrite(Byte[] buffer, Int32 offset, Int32 count, AsyncCallback? callback, Object? state) + { + RaiseAction("BeginWrite", offset, count); + + return BaseStream.BeginWrite(buffer, offset, count, callback, state); + } + + /// 异步读结束 + /// + /// + public override Int32 EndRead(IAsyncResult asyncResult) + { + RaiseAction("EndRead"); + + return BaseStream.EndRead(asyncResult); + } + + /// 异步写结束 + /// + public override void EndWrite(IAsyncResult asyncResult) + { + RaiseAction("EndWrite"); + + BaseStream.EndWrite(asyncResult); + } + #endregion + + #region 其它方法 + /// 设置流位置 + /// 偏移 + /// + /// + public override Int64 Seek(Int64 offset, SeekOrigin origin) + { + RaiseAction("Seek", offset, origin); + + return BaseStream.Seek(offset, origin); + } + + /// 关闭数据流 + public override void Close() + { + RaiseAction("Close"); + + BaseStream.Close(); + } + + /// 刷新缓冲区 + public override void Flush() + { + RaiseAction("Flush"); + + BaseStream.Flush(); + } + + /// 设置长度 + /// 数值 + public override void SetLength(Int64 value) + { + RaiseAction("SetLength", value); + + BaseStream.SetLength(value); + } + #endregion + + #region 属性 + /// 可读 + public override Boolean CanRead { get { return BaseStream.CanRead; } } + + /// 可搜索 + public override Boolean CanSeek { get { return BaseStream.CanSeek; } } + + /// 可超时 + public override Boolean CanTimeout { get { return BaseStream.CanTimeout; } } + + /// 可写 + public override Boolean CanWrite { get { return BaseStream.CanWrite; } } + + /// 可读 + public override Int32 ReadTimeout { get { return BaseStream.ReadTimeout; } set { BaseStream.ReadTimeout = value; } } + + /// 读写超时 + public override Int32 WriteTimeout { get { return base.WriteTimeout; } set { base.WriteTimeout = value; } } + + /// 长度 + public override Int64 Length { get { return BaseStream.Length; } } + + /// 位置 + public override Int64 Position + { + get { return BaseStream.Position; } + set + { + RaiseAction("SetPosition", value); + + BaseStream.Position = value; + } + } + #endregion + + #region 构造 + /// 实例化跟踪流 + public TraceStream() : this(null) { } + + /// 实例化跟踪流 + /// + public TraceStream(Stream? stream) + { + TraceMembers = new HashSet(DefaultTraceMembers, StringComparer.OrdinalIgnoreCase); + IsLittleEndian = true; + ShowPositionStep = 16; + Encoding = Encoding.UTF8; + + if (stream == null) stream = new MemoryStream(); + BaseStream = stream; + UseConsole = true; + + if (!UseConsole) OnAction += XTrace_OnAction; + } + #endregion + + #region 事件 + /// 操作时触发 + public event EventHandler>? OnAction; + + private Int64 lastPosition = -1; + + private void RaiseAction(String action, params Object[] args) + { + if (OnAction != null) + { + if (!TraceMembers.Contains(action)) return; + + if (ShowPositionStep > 0) + { + var cp = Position; + if (lastPosition < 0) + { + lastPosition = cp; + OnAction(this, new EventArgs("BeginPosition", new Object[] { lastPosition })); + } + + if (cp > lastPosition + ShowPositionStep) + { + lastPosition = cp; + OnAction(this, new EventArgs("Position", new Object[] { lastPosition })); + } + } + + OnAction(this, new EventArgs(action, args)); + } + } + #endregion + + #region 控制台 + private Boolean _UseConsole; + /// 是否使用控制台 + public Boolean UseConsole + { + get { return _UseConsole; } + set + { + if (value && !Runtime.IsConsole) return; + if (value == _UseConsole) return; + + if (value) + OnAction += TraceStream_OnAction; + else + OnAction -= TraceStream_OnAction; + + _UseConsole = value; + } + } + + /// 编码 + public Encoding Encoding { get; set; } + + private void TraceStream_OnAction(Object? sender, EventArgs e) + { + var color = Console.ForegroundColor; + + // 红色动作 + Console.ForegroundColor = ConsoleColor.Red; + var act = e.Arg1; + if (act.Length < 8) act += "\t"; + Console.Write(act); + + // 白色十六进制 + Console.ForegroundColor = ConsoleColor.White; + Console.Write("\t"); + + Byte[]? buffer = null; + var offset = 0; + var count = 0; + if (e.Arg2.Length > 1) + { + if (e.Arg2[0] is Byte[]) buffer = (Byte[])e.Arg2[0]; + offset = (Int32)e.Arg2[1]; + count = (Int32)e.Arg2[^1]; + } + + if (e.Arg2.Length == 1) + { + var n = Convert.ToInt32(e.Arg2[0]); + // 大于10才显示十进制 + if (n >= 10) + Console.Write("{0:X2} ({0})", n); + else + Console.Write("{0:X2}", n); + } + else if (buffer != null) + { + if (count == 1) + { + var n = Convert.ToInt32(buffer[0]); + // 大于10才显示十进制 + if (n >= 10) + Console.Write("{0:X2} ({0})", n); + else + Console.Write("{0:X2}", n); + } + else + Console.Write(BitConverter.ToString(buffer, offset, count <= 50 ? count : 50) + (count <= 50 ? "" : "...(共" + count + ")")); + } + // 黄色内容 + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("\t"); + if (e.Arg2.Length == 1) + { + if (e.Arg2[0] != null) + { + var tc = Type.GetTypeCode(e.Arg2[0].GetType()); + if (tc != TypeCode.Object) Console.Write(e.Arg2[0]); + } + } + else if (buffer != null) + { + if (count == 1) + { + // 只显示可见字符 + if (buffer[0] >= '0') Console.Write("{0} ({1})", Convert.ToChar(buffer[0]), Convert.ToInt32(buffer[0])); + } + else if (count == 2) + Console.Write(BitConverter.ToInt16(Format(buffer), offset)); + else if (count == 4) + Console.Write(BitConverter.ToInt32(Format(buffer), offset)); + else if (count < 50) + Console.Write(Encoding.GetString(buffer, offset, count)); + } + Console.ForegroundColor = color; + Console.WriteLine(); + } + + private Byte[] Format(Byte[] buffer) + { + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); + if (buffer.Length <= 0) return buffer; + + if (IsLittleEndian) return buffer; + + // 不要改变原来的数组 + var bts = new Byte[buffer.Length]; + Buffer.BlockCopy(buffer, 0, bts, 0, bts.Length); + Array.Reverse(bts); + + return bts; + } + #endregion + + #region 日志 + private void XTrace_OnAction(Object? sender, EventArgs e) + { + var sb = new StringBuilder(); + + // 红色动作 + var act = e.Arg1; + if (act.Length < 8) act += "\t"; + sb.AppendFormat(act); + + // 白色十六进制 + sb.AppendFormat("\t"); + + Byte[]? buffer = null; + var offset = 0; + var count = 0; + if (e.Arg2.Length > 1) + { + if (e.Arg2[0] is Byte[]) buffer = (Byte[])e.Arg2[0]; + offset = (Int32)e.Arg2[1]; + count = (Int32)e.Arg2[^1]; + } + + if (e.Arg2.Length == 1) + { + var n = Convert.ToInt32(e.Arg2[0]); + // 大于10才显示十进制 + if (n >= 10) + sb.AppendFormat("{0:X2} ({0})", n); + else + sb.AppendFormat("{0:X2}", n); + } + else if (buffer != null) + { + if (count == 1) + { + var n = Convert.ToInt32(buffer[0]); + // 大于10才显示十进制 + if (n >= 10) + sb.AppendFormat("{0:X2} ({0})", n); + else + sb.AppendFormat("{0:X2}", n); + } + else + sb.AppendFormat(BitConverter.ToString(buffer, offset, count <= 50 ? count : 50) + (count <= 50 ? "" : "...(共" + count + ")")); + } + + // 黄色内容 + sb.AppendFormat("\t"); + if (e.Arg2.Length == 1) + { + if (e.Arg2[0] != null) + { + var tc = Type.GetTypeCode(e.Arg2[0].GetType()); + if (tc != TypeCode.Object) sb.AppendFormat(e.Arg2[0] + ""); + } + } + else if (buffer != null) + { + if (count == 1) + { + // 只显示可见字符 + if (buffer[0] >= '0') sb.AppendFormat("{0} ({1})", Convert.ToChar(buffer[0]), Convert.ToInt32(buffer[0])); + } + else if (count == 2) + sb.AppendFormat(BitConverter.ToInt16(Format(buffer), offset) + ""); + else if (count == 4) + sb.AppendFormat(BitConverter.ToInt32(Format(buffer), offset) + ""); + else if (count < 50) + sb.AppendFormat(Encoding.GetString(buffer, offset, count)); + } + + XTrace.WriteLine(sb.ToString()); + } + #endregion + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/WriteLogEventArgs.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/WriteLogEventArgs.cs new file mode 100644 index 000000000..570f5f655 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/WriteLogEventArgs.cs @@ -0,0 +1,138 @@ +namespace ThingsGateway.NewLife.Log; + +/// 写日志事件参数 +public class WriteLogEventArgs : EventArgs +{ + #region 属性 + /// 日志等级 + public LogLevel Level { get; set; } + + /// 日志信息 + public String? Message { get; set; } + + /// 异常 + public Exception? Exception { get; set; } + + /// 时间 + public DateTime Time { get; set; } + + /// 线程编号 + public Int32 ThreadID { get; set; } + + /// 是否线程池线程 + public Boolean IsPool { get; set; } + + /// 是否Web线程 + public Boolean IsWeb { get; set; } + + /// 线程名 + public String? ThreadName { get; set; } + + /// 任务编号 + public Int32 TaskID { get; set; } + #endregion + + #region 构造 + /// 实例化一个日志事件参数 + internal WriteLogEventArgs() { } + #endregion + + #region 线程专有实例 + /*2015-06-01 @宁波-小董 + * 将Current以及Set方法组从internal修改为Public + * 原因是 Logger在进行扩展时,重载OnWrite需要用到该静态属性以及方法,internal无法满足扩展要求 + * */ + [ThreadStatic] + private static WriteLogEventArgs? _Current; + /// 线程专有实例。线程静态,每个线程只用一个,避免GC浪费 + public static WriteLogEventArgs Current => _Current ??= new WriteLogEventArgs(); + #endregion + + #region 方法 + /// 初始化为新日志 + /// 日志等级 + /// 返回自身,链式写法 + public WriteLogEventArgs Set(LogLevel level) + { + Level = level; + + return this; + } + + /// 初始化为新日志 + /// 日志 + /// 异常 + /// 返回自身,链式写法 + public WriteLogEventArgs Set(String? message, Exception? exception) + { + Message = message; + Exception = exception; + + Init(); + + return this; + } + + private void Init() + { + // todo: 如果系统使用utc时间,可以把日志时间转换为本地时间 + Time = DateTime.Now.AddHours(Setting.Current.UtcIntervalHours); + var thread = Thread.CurrentThread; + ThreadID = thread.ManagedThreadId; + IsPool = thread.IsThreadPoolThread; + ThreadName = CurrentThreadName ?? thread.Name; + + var tid = Task.CurrentId; + TaskID = tid != null ? tid.Value : -1; + + //IsWeb = System.Web.HttpContext.Current != null; + } + + /// 重置日志事件对象,释放内存 + public void Reset() + { + Level = LogLevel.Info; + Message = null; + Exception = null; + Time = default; + ThreadID = 0; + IsPool = false; + IsWeb = false; + ThreadName = null; + TaskID = 0; + } + + /// 获取日志全文,并重置对象释放内存 + /// + public String GetAndReset() + { + var msg = ToString(); + Reset(); + + return msg; + } + + /// 已重载。 + /// + public override String ToString() + { + if (Exception != null) Message += Exception.GetMessage(); + + var name = ThreadName; + if (name.IsNullOrEmpty()) name = TaskID >= 0 ? TaskID + "" : "-"; + if (name.EqualIgnoreCase("Threadpool worker", ".NET ThreadPool Worker")) name = TaskID >= 0 ? TaskID + "" : "P"; + if (name.EqualIgnoreCase("IO Threadpool worker")) name = "IO"; + if (name.EqualIgnoreCase(".NET Long Running Task")) name = "LongTask"; + if (name.EqualIgnoreCase(".NET TP Worker")) name = "TP"; + + return $"{Time:HH:mm:ss.fff} {ThreadID,2} {(IsPool ? (IsWeb ? 'W' : 'Y') : 'N')} {name} {Message}"; + } + #endregion + + #region 日志线程名 + [ThreadStatic] + private static String? _threadName; + /// 设置当前线程输出日志时的线程名 + public static String? CurrentThreadName { get => _threadName; set => _threadName = value; } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Logger/XTrace.cs b/src/Admin/ThingsGateway.NewLife.X/Logger/XTrace.cs new file mode 100644 index 000000000..191c668b8 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Logger/XTrace.cs @@ -0,0 +1,354 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +#if WIN + +using System.Windows.Forms; + +#endif + +using ThingsGateway.NewLife.Reflection; +using ThingsGateway.NewLife.Threading; +using ThingsGateway.NewLife.Windows; + +#nullable enable + +namespace ThingsGateway.NewLife.Log; + +/// 日志类,包含跟踪调试功能 +/// +/// 文档 https://newlifex.com/core/log +/// +/// 该静态类包括写日志、写调用栈和Dump进程内存等调试功能。 +/// +/// 默认写日志到文本文件,可通过修改属性来增加日志输出方式。 +/// 对于控制台工程,可以直接通过UseConsole方法,把日志输出重定向为控制台输出,并且可以为不同线程使用不同颜色。 +/// +public static class XTrace +{ + #region 写日志 + + /// 文本文件日志 + private static ILog _Log = Logger.Null; + + /// 日志提供者,默认使用文本文件日志 + public static ILog Log { get { InitLog(); return _Log; } set { _Log = value; } } + + /// 输出日志 + /// 信息 + public static void WriteLine(String msg) + { + if (!InitLog()) return; + + WriteVersion(); + + Log.Info(msg); + } + + /// 写日志 + /// + /// + public static void WriteLine(String format, params Object?[] args) + { + if (!InitLog()) return; + + WriteVersion(); + + Log.Info(format, args); + } + + ///// 异步写日志 + ///// + ///// + //public static void WriteLineAsync(String format, params Object[] args) + //{ + // ThreadPool.QueueUserWorkItem(s => WriteLine(format, args)); + //} + + /// 输出异常日志 + /// 异常信息 + public static void WriteException(Exception ex) + { + if (!InitLog()) return; + + WriteVersion(); + + Log.Error("{0}", ex); + } + + #endregion 写日志 + + #region 构造 + + static XTrace() + { + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; + AppDomain.CurrentDomain.ProcessExit += OnProcessExit; +#if NETCOREAPP + System.Runtime.Loader.AssemblyLoadContext.Default.Unloading += ctx => OnProcessExit(null, EventArgs.Empty); +#endif + + ThreadPoolX.Init(); + + } + + private static void CurrentDomain_UnhandledException(Object sender, UnhandledExceptionEventArgs e) + { + if (e.ExceptionObject is Exception ex) + { + WriteException(ex); + } + if (e.IsTerminating) + { + Log.Fatal("异常退出!"); + + OnProcessExit(null, EventArgs.Empty); + } + } + + private static void TaskScheduler_UnobservedTaskException(Object? sender, UnobservedTaskExceptionEventArgs e) + { + if (!e.Observed && e.Exception != null) + { + //WriteException(e.Exception); + foreach (var ex in e.Exception.Flatten().InnerExceptions) + { + // 全局异常埋点 + WriteException(ex); + } + e.SetObserved(); + } + } + + private static void OnProcessExit(Object? sender, EventArgs e) + { + if (Log is CompositeLog compositeLog) + { + var log = compositeLog.Get(); + log.TryDispose(); + } + else + { + Log.TryDispose(); + } + } + + private static readonly Object _lock = new(); + private static Int32 _initing = 0; + + /// + /// 2012.11.05 修正初次调用的时候,由于同步BUG,导致Log为空的问题。 + /// + private static Boolean InitLog() + { + /* + * 日志初始化可能会触发配置模块,其内部又写日志导致死循环。 + * 1,外部写日志引发初始化 + * 2,标识日志初始化正在进行中 + * 3,初始化日志提供者 + * 4,此时如果再次引发写入日志,发现正在进行中,放弃写入的日志 + * 5,标识日志初始化已完成 + * 6,正常写入日志 + */ + + if (_Log != null && _Log != Logger.Null) return true; + if (_initing > 0 && _initing == Environment.CurrentManagedThreadId) return false; + + lock (_lock) + { + if (_Log != null && _Log != Logger.Null) return true; + + _initing = Environment.CurrentManagedThreadId; + + var set = Setting.Current; + if (set.LogFileFormat.Contains("{1}")) + _Log = new LevelLog(set.LogPath, set.LogFileFormat); + else + _Log = TextFileLog.Create(set.LogPath); + + + _initing = 0; + } + + //WriteVersion(); + + return true; + } + + #endregion 构造 + + #region 使用控制台输出 + + private static Boolean _useConsole; + + /// 使用控制台输出日志,只能调用一次 + /// 是否使用颜色,默认使用 + /// 是否同时使用文件日志,默认使用 + public static void UseConsole(Boolean useColor = true, Boolean useFileLog = true) + { + if (_useConsole) return; + _useConsole = true; + + //if (!Runtime.IsConsole) return; + Runtime.IsConsole = true; + + // 适当加大控制台窗口 + try + { +#if !NETFRAMEWORK + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + if (Console.WindowWidth <= 80) Console.WindowWidth = Console.WindowWidth * 3 / 2; + if (Console.WindowHeight <= 25) Console.WindowHeight = Console.WindowHeight * 3 / 2; + } +#else + if (Console.WindowWidth <= 80) Console.WindowWidth = Console.WindowWidth * 3 / 2; + if (Console.WindowHeight <= 25) Console.WindowHeight = Console.WindowHeight * 3 / 2; +#endif + } + catch { } + + var clg = new ConsoleLog { UseColor = useColor }; + if (useFileLog) + _Log = new CompositeLog(clg, Log); + else + _Log = clg; + } + + #endregion 使用控制台输出 + + #region 控制台禁用快捷编辑 + + /// + /// 禁用控制台快捷编辑,在UseConsole方法之后调用 + /// + public static void DisableConsoleEdit() + { + if (!_useConsole) return; + try + { + if (Runtime.Windows) + { + ConsoleHelper.DisableQuickEditMode(); + } + } + catch { } + } + + /// + /// 禁用控制台关闭按钮 + /// + /// 控制台程序名称,可使用Console.Title动态设置的值 + public static void DisableConsoleCloseButton(String consoleTitle) + { + try + { + if (Runtime.Windows) + { + ConsoleHelper.DisableCloseButton(consoleTitle); + } + } + catch { } + } + + #endregion 控制台禁用关闭按钮 + + #region 拦截WinForm异常 + +#if WIN + private static Int32 initWF = 0; + private static Boolean _ShowErrorMessage; + //private static String _Title; + + /// 拦截WinForm异常并记录日志,可指定是否用显示。 + /// 发为捕获异常时,是否显示提示,默认显示 + public static void UseWinForm(Boolean showErrorMessage = true) + { + Runtime.IsConsole = false; + + _ShowErrorMessage = showErrorMessage; + + if (initWF > 0 || Interlocked.CompareExchange(ref initWF, 1, 0) != 0) return; + //if (!Application.MessageLoop) return; + + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException2; + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + Application.ThreadException += Application_ThreadException; + } + + private static void CurrentDomain_UnhandledException2(Object sender, UnhandledExceptionEventArgs e) + { + var show = _ShowErrorMessage && Application.MessageLoop; + var ex = e.ExceptionObject as Exception; + var title = e.IsTerminating ? "异常退出" : "出错"; + if (show) MessageBox.Show(ex?.Message, title, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + + private static void Application_ThreadException(Object sender, ThreadExceptionEventArgs e) + { + WriteException(e.Exception); + + var show = _ShowErrorMessage && Application.MessageLoop; + if (show) MessageBox.Show(e.Exception == null ? "" : e.Exception.Message, "出错", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + + +#endif + + #endregion 拦截WinForm异常 + + #region 属性 + + /// 是否调试。 + public static Boolean Debug => Setting.Current.Debug; + + ///// 临时目录 + //public static String TempPath { get; set; } = Setting.Current.TempPath; + + #endregion 属性 + + #region 版本信息 + + private static Int32 _writeVersion; + + /// 输出核心库和启动程序的版本号 + public static void WriteVersion() + { + if (_writeVersion > 0 || Interlocked.CompareExchange(ref _writeVersion, 1, 0) != 0) return; + + var asm = Assembly.GetExecutingAssembly(); + WriteVersion(asm); + + var asm2 = Assembly.GetEntryAssembly(); + if (asm2 != null && asm2 != asm) WriteVersion(asm2); + } + + /// 输出程序集版本 + /// + public static void WriteVersion(this Assembly asm) + { + if (asm == null) return; + + var asmx = AssemblyX.Create(asm); + if (asmx != null) + { + var ver = ""; + var tar = asm.GetCustomAttribute(); + if (tar != null) + { + ver = tar.FrameworkDisplayName; + if (ver.IsNullOrEmpty()) ver = tar.FrameworkName; + } + + WriteLine("{0} v{1} Build {2:yyyy-MM-dd HH:mm:ss} {3}", asmx.Name, asmx.FileVersion, asmx.Compile, ver); + var att = asmx.Asm.GetCustomAttribute(); + WriteLine("{0} {1}", asmx.Title, att?.Copyright); + } + } + + #endregion 版本信息 +} + +#nullable restore \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Net/IDnsResolver.cs b/src/Admin/ThingsGateway.NewLife.X/Net/IDnsResolver.cs new file mode 100644 index 000000000..7bcc594f8 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Net/IDnsResolver.cs @@ -0,0 +1,97 @@ +using System.Collections.Concurrent; +using System.Net; + +namespace ThingsGateway.NewLife.Net; + +/// DNS解析器 +public interface IDnsResolver +{ + /// 解析域名 + /// + /// + IPAddress[]? Resolve(String host); +} + +/// DNS解析器,带有缓存,解析失败时使用旧数据 +public class DnsResolver : IDnsResolver +{ + /// 静态实例 + public static DnsResolver Instance { get; set; } = new(); + + /// 缓存超时时间 + public TimeSpan Expire { set; get; } = TimeSpan.FromMinutes(5); + + private readonly ConcurrentDictionary _cache = new(); + + /// 解析域名 + /// + /// + public IPAddress[]? Resolve(String host) + { + if (_cache.TryGetValue(host, out var item)) + { + // 超时数据,异步更新,不影响当前请求 + if (item.UpdateTime.Add(Expire) <= DateTime.Now) + _ = Task.Run(() => ResolveCore(host, item, false)); + } + else + item = ResolveCore(host, item, true); + + return item?.Addresses; + } + + private DnsItem? ResolveCore(String host, DnsItem? item, Boolean throwError) + { + try + { + // 执行DNS解析 +#if NET6_0_OR_GREATER + using var source = new CancellationTokenSource(5000); + var task = Dns.GetHostAddressesAsync(host, source.Token); + var addrs = task.ConfigureAwait(false).GetAwaiter().GetResult(); +#else + var task = Dns.GetHostAddressesAsync(host); + if (!task.Wait(5000)) throw new TaskCanceledException(); + var addrs = task.ConfigureAwait(false).GetAwaiter().GetResult(); +#endif + if (addrs != null && addrs.Length > 0) + { + + // 更新缓存数据 + if (item == null) + { + _cache[host] = item = new DnsItem + { + Host = host, + Addresses = addrs, + CreateTime = DateTime.Now, + UpdateTime = DateTime.Now + }; + } + else + { + item.Addresses = addrs; + item.UpdateTime = DateTime.Now; + + } + } + } + catch (Exception ex) + { + if (throwError) throw ex; + } + + return item; + } + + private sealed class DnsItem + { + public String Host { get; set; } = null!; + + public IPAddress[]? Addresses { get; set; } + + public DateTime CreateTime { get; set; } + + public DateTime UpdateTime { get; set; } + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Net/IIPResolver.cs b/src/Admin/ThingsGateway.NewLife.X/Net/IIPResolver.cs new file mode 100644 index 000000000..411c738a1 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Net/IIPResolver.cs @@ -0,0 +1,12 @@ +using System.Net; + +namespace ThingsGateway.NewLife.Net; + +/// IP地址提供者 +public interface IIPResolver +{ + /// 获取IP地址的物理地址位置 + /// + /// + String GetAddress(IPAddress addr); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Net/NetHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Net/NetHelper.cs new file mode 100644 index 000000000..caf21e95f --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Net/NetHelper.cs @@ -0,0 +1,657 @@ +using System.Globalization; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +using ThingsGateway.NewLife.Caching; +using ThingsGateway.NewLife.Collections; +using ThingsGateway.NewLife.Net; + +namespace ThingsGateway.NewLife; + +/// 网络工具类 +public static class NetHelper +{ + #region 属性 + private static readonly ICache _Cache = MemoryCache.Instance; + #endregion + + #region 构造 + static NetHelper() + { + // 网络有变化时,清空所有缓存 + NetworkChange.NetworkAddressChanged += NetworkChange_NetworkAddressChanged; + NetworkChange.NetworkAvailabilityChanged += NetworkChange_NetworkAvailabilityChanged; + } + + private static void NetworkChange_NetworkAvailabilityChanged(Object? sender, NetworkAvailabilityEventArgs e) => _Cache.Clear(); + + private static void NetworkChange_NetworkAddressChanged(Object? sender, EventArgs e) => _Cache.Clear(); + #endregion + + #region 辅助函数 + /// 设置超时检测时间和检测间隔 + /// + /// 一次对server服务大量积压异常TCP ESTABLISHED链接的排查笔记 https://www.jianshu.com/p/a1c3aba4af96 + /// 查看连接创建时间: sudo ls /proc/128260/fd -l|grep socket ,可发现大量连接的创建时间在很久之前。 + /// 查看连接是否有启用keepalive: ss -aoen|grep ESTAB|grep timer ,带有timer的socket表示启用了keepalive。 + /// + /// 要设置的Socket对象 + /// 是否启用Keep-Alive + /// 多长时间后开始第一次探测(单位:秒) + /// 探测时间间隔(单位:秒) + public static void SetTcpKeepAlive(this Socket socket, Boolean isKeepAlive, Int32 startTime, Int32 interval) + { + if (socket == null) return; + +#if !NETFRAMEWORK + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) +#else + if (Runtime.Windows) +#endif + { + UInt32 dummy = 0; + var inOptionValues = Pool.Shared.Rent(Marshal.SizeOf(dummy) * 3); + + // 是否启用Keep-Alive + BitConverter.GetBytes((UInt32)(isKeepAlive ? 1 : 0)).CopyTo(inOptionValues, 0); + // 第一次开始发送探测包时间间隔 + BitConverter.GetBytes((UInt32)startTime * 1000).CopyTo(inOptionValues, Marshal.SizeOf(dummy)); + // 连续发送探测包时间间隔 + BitConverter.GetBytes((UInt32)interval * 1000).CopyTo(inOptionValues, Marshal.SizeOf(dummy) * 2); + + socket.IOControl(IOControlCode.KeepAliveValues, inOptionValues, null); + + Pool.Shared.Return(inOptionValues); + + return; + } + + { + // 开启keepalive + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, isKeepAlive); +#if NETCOREAPP + // 开始首次keepalive探测前的TCP空闲时间 + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, startTime); + // 两次keepalive探测之间的时间间隔 + socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, interval); +#endif + } + } + + /// 分析地址,根据IP或者域名得到IP地址,缓存60秒,异步更新 + /// + /// + public static IPAddress? ParseAddress(this String hostname) + { + if (hostname.IsNullOrEmpty()) return null; + + var key = $"NetHelper:ParseAddress:{hostname}"; + if (_Cache.TryGetValue(key, out var address)) return address; + + address = NetUri.ParseAddress(hostname)?.FirstOrDefault(); + + _Cache.Set(key, address, 60); + + return address; + } + + /// 分析网络终结点 + /// 地址,可以不带端口 + /// 地址不带端口时指定的默认端口 + /// + public static IPEndPoint? ParseEndPoint(String address, Int32 defaultPort = 0) + { + if (String.IsNullOrEmpty(address)) return null; + + var p = address.IndexOf("://"); + if (p >= 0) address = address[(p + 3)..]; + + var port = 0; + p = address.LastIndexOf(':'); + IPAddress? addr = null; + if (p > 0) + { + addr = address[..p].ParseAddress(); + port = Int32.Parse(address[(p + 1)..]); + } + else + { + addr = address.ParseAddress(); + port = defaultPort; + } + if (addr == null) return null; + + return new IPEndPoint(addr, port); + } + + /// 针对IPv4和IPv6获取合适的Any地址 + /// 除了Any地址以为,其它地址不具备等效性 + /// + /// + /// + public static IPAddress GetRightAny(this IPAddress address, AddressFamily family) + { + if (address.AddressFamily == family) return address; + + switch (family) + { + case AddressFamily.InterNetwork: + if (address.Equals(IPAddress.IPv6Any)) return IPAddress.Any; + break; + case AddressFamily.InterNetworkV6: + if (address.Equals(IPAddress.Any)) return IPAddress.IPv6Any; + break; + default: + break; + } + //return null; + + //throw new InvalidDataException($"Not Found {family}"); + + return address; + } + + /// 是否Any地址,同时处理IPv4和IPv6 + /// + /// + public static Boolean IsAny(this IPAddress address) => IPAddress.Any.Equals(address) || IPAddress.IPv6Any.Equals(address); + + /// 是否Any结点 + /// + /// + public static Boolean IsAny(this EndPoint endpoint) => endpoint is IPEndPoint ep && (ep.Port == 0 || ep.Address.IsAny()); + + /// 是否IPv4地址 + /// + /// + public static Boolean IsIPv4(this IPAddress address) => address.AddressFamily == AddressFamily.InterNetwork; + + /// 是否本地地址 + /// + /// + public static Boolean IsLocal(this IPAddress address) => IPAddress.IsLoopback(address) || GetIPsWithCache().Any(ip => ip.Equals(address)); + + /// 获取相对于指定远程地址的本地地址 + /// + /// + /// + public static IPAddress? GetRelativeAddress(this IPAddress address, IPAddress remote) + { + // 如果不是任意地址,直接返回 + var addr = address; + if (addr == null || !addr.IsAny()) return addr; + + // 如果是本地环回地址,返回环回地址 + if (IPAddress.IsLoopback(remote)) return addr.IsIPv4() ? IPAddress.Loopback : IPAddress.IPv6Loopback; + + // 否则返回本地第一个IP地址 + foreach (var item in GetIPsWithCache()) + { + if (item.AddressFamily == addr.AddressFamily) return item; + } + + return null; + } + + /// 获取相对于指定远程地址的本地地址 + /// + /// + /// + public static IPEndPoint? GetRelativeEndPoint(this IPEndPoint local, IPAddress remote) + { + if (local == null || remote == null) return local; + + var addr = local.Address.GetRelativeAddress(remote); + return addr == null ? local : new IPEndPoint(addr, local.Port); + } + + /// 指定地址的指定端口是否已被使用,似乎没办法判断IPv6地址 + /// + /// + /// + /// + public static Boolean CheckPort(this IPAddress address, NetType protocol, Int32 port) + { + //if (ThingsGateway.NewLife.Runtime.Mono) return false; + if (!Runtime.Windows) return false; + + try + { + // 某些情况下检查端口占用会抛出异常,原因未知 + var gp = IPGlobalProperties.GetIPGlobalProperties(); + + IPEndPoint[]? eps = null; + switch (protocol) + { + case NetType.Tcp: + eps = gp.GetActiveTcpListeners(); + break; + case NetType.Udp: + eps = gp.GetActiveUdpListeners(); + break; + default: + return false; + } + + foreach (var item in eps) + // 先比较端口,性能更好 + if (item.Port == port && item.Address.Equals(address)) return true; + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + + return false; + } + + /// 检查该协议的地址端口是否已经被使用 + /// + /// + public static Boolean CheckPort(this NetUri uri) => uri.Address.CheckPort(uri.Type, uri.Port); + + + /// 获取所有Tcp连接,带进程Id + /// + public static TcpConnectionInformation2[] GetAllTcpConnections(Int32 processId = -1) + { + var rs = !Runtime.Windows ? + TcpConnectionInformation2.GetLinuxTcpConnections(processId) : + TcpConnectionInformation2.GetWindowsTcpConnections(); + + if (processId <= 0) return rs; + + return rs.Where(e => e.ProcessId == processId).ToArray(); + } + #endregion + + #region 本机信息 + /// 获取活动的接口信息 + /// + public static IEnumerable GetActiveInterfaces() + { + foreach (var item in NetworkInterface.GetAllNetworkInterfaces()) + { + if (item.OperationalStatus != OperationalStatus.Up) continue; + if (item.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel or NetworkInterfaceType.Unknown) continue; + + var ip = item.GetIPProperties(); + if (ip != null) yield return ip; + } + } + + /// 获取可用的DHCP地址 + /// + public static IEnumerable GetDhcps() + { + var list = new List(); + foreach (var item in GetActiveInterfaces()) + { +#if NET5_0_OR_GREATER + if (item != null && !OperatingSystem.IsMacOS() && item.DhcpServerAddresses.Count > 0) + { + foreach (var elm in item.DhcpServerAddresses) + { + if (list.Contains(elm)) continue; + list.Add(elm); + + yield return elm; + } + } +#else + if (item != null && item.DhcpServerAddresses.Count > 0) + { + foreach (var elm in item.DhcpServerAddresses) + { + if (list.Contains(elm)) continue; + list.Add(elm); + + yield return elm; + } + } +#endif + } + } + + /// 获取可用的DNS地址 + /// + public static IEnumerable GetDns() + { + var list = new List(); + foreach (var item in GetActiveInterfaces()) + { + if (item != null && item.DnsAddresses.Count > 0) + { + foreach (var elm in item.DnsAddresses) + { + if (list.Contains(elm)) continue; + list.Add(elm); + + yield return elm; + } + } + } + } + + /// 获取可用的网关地址 + /// + public static IEnumerable GetGateways() + { + var list = new List(); + foreach (var item in GetActiveInterfaces()) + { + if (item != null && item.GatewayAddresses.Count > 0) + { + foreach (var elm in item.GatewayAddresses) + { + if (list.Contains(elm.Address)) continue; + list.Add(elm.Address); + + yield return elm.Address; + } + } + } + } + + /// 获取可用的IP地址 + /// + public static IEnumerable GetIPs() + { + var dic = new Dictionary(); + foreach (var item in NetworkInterface.GetAllNetworkInterfaces()) + { + if (item.OperationalStatus != OperationalStatus.Up) continue; + if (item.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel or NetworkInterfaceType.Unknown) continue; + + var ipp = item.GetIPProperties(); + if (ipp != null && ipp.UnicastAddresses.Count > 0) + { + var gw = 0; + +#if NET5_0_OR_GREATER + if (!OperatingSystem.IsAndroid()) + { + gw = ipp.GatewayAddresses.Count; + } +#else + gw = ipp.GatewayAddresses.Count; +#endif + + // 引入权重因子,优先返回网关所在网卡的地址,优先IPv4,IPv6优先公网单播地址 + foreach (var elm in ipp.UnicastAddresses) + { + var factor = gw * 10 + 5; + var addr = elm.Address; + if (addr.IsIPv4()) + { + factor++; + if (addr.GetAddressBytes()[0] == 169) factor--; + } + else + { + + if (addr.IsIPv4MappedToIPv6) continue; + if (addr.IsIPv6LinkLocal) factor--; + if (addr.IsIPv6Multicast) continue; + if (addr.IsIPv6SiteLocal) continue; + //if (addr.IsIPv6Teredo) continue; +#if NET6_0_OR_GREATER + if (addr.IsIPv6UniqueLocal) factor -= 2; +#endif + } + +#if NET5_0_OR_GREATER + try + { + if (OperatingSystem.IsWindows() && + elm.DuplicateAddressDetectionState != DuplicateAddressDetectionState.Preferred) + continue; + } + catch { } +#endif + + dic.Add(elm, factor); + } + } + } + + // 带网关的接口地址很重要,优先返回 + // Linux下不支持PrefixOrigin + var ips = dic.OrderByDescending(e => e.Value) + //.ThenByDescending(e => e.Key.PrefixOrigin == PrefixOrigin.Dhcp || e.Key.PrefixOrigin == PrefixOrigin.Manual) + .Select(e => e.Key.Address).ToList(); + + return ips; + } + + /// 获取本机可用IP地址,缓存60秒,异步更新 + /// + public static IPAddress[] GetIPsWithCache() + { + var key = $"NetHelper:GetIPsWithCache"; + if (_Cache.TryGetValue(key, out var addrs) && addrs != null) return addrs; + + addrs = GetIPs().ToArray(); + + _Cache.Set(key, addrs, 60); + + return addrs; + } + + /// 获取可用的多播地址 + /// + public static IEnumerable GetMulticasts() + { + var list = new List(); + foreach (var item in GetActiveInterfaces()) + { + if (item != null && item.MulticastAddresses.Count > 0) + { + foreach (var elm in item.MulticastAddresses) + { + if (list.Contains(elm.Address)) continue; + list.Add(elm.Address); + + yield return elm.Address; + } + } + } + } + + private static readonly String[] _Excludes = ["Loopback", "VMware", "VBox", "Virtual", "Teredo", "Microsoft", "VPN", "VNIC", "IEEE"]; + /// 获取所有物理网卡MAC地址。包括未启用网卡,剔除本地和隧道 + /// + public static IEnumerable GetMacs() + { + foreach (var item in NetworkInterface.GetAllNetworkInterfaces()) + { + // 只要物理网卡 + if (item.NetworkInterfaceType is NetworkInterfaceType.Loopback or NetworkInterfaceType.Tunnel or NetworkInterfaceType.Unknown) continue; + if (_Excludes.Any(e => item.Description.Contains(e))) continue; + if (Runtime.Windows && item.Speed < 1_000_000) continue; + + // 物理网卡在禁用时没有IP,如果有IP,则不能是环回 + var ips = item.GetIPProperties(); + var addrs = ips.UnicastAddresses + .Where(e => e.Address.AddressFamily == AddressFamily.InterNetwork) + .Select(e => e.Address) + .ToArray(); + if (addrs.Length > 0 && addrs.All(e => IPAddress.IsLoopback(e))) continue; + + var mac = item.GetPhysicalAddress()?.GetAddressBytes(); + if (mac != null && mac.Length == 6) yield return mac; + } + } + + /// 获取网卡MAC地址(网关所在网卡) + /// + public static Byte[]? GetMac() + { + foreach (var item in NetworkInterface.GetAllNetworkInterfaces()) + { + if (_Excludes.Any(e => item.Description.Contains(e))) continue; + if (Runtime.Windows && item.Speed < 1_000_000) continue; + + var ips = item.GetIPProperties(); + var addrs = ips.UnicastAddresses + .Where(e => e.Address.AddressFamily == AddressFamily.InterNetwork) + .Select(e => e.Address) + .ToArray(); + if (addrs.All(e => IPAddress.IsLoopback(e))) continue; + + // 网关 + addrs = ips.GatewayAddresses + .Where(e => e.Address.AddressFamily == AddressFamily.InterNetwork) + .Select(e => e.Address) + .ToArray(); + if (addrs.Length == 0) continue; + + var mac = item.GetPhysicalAddress()?.GetAddressBytes(); + if (mac != null && mac.Length == 6) return mac; + } + + return null; + } + + /// 获取本地第一个IPv4地址。一般是网关所在网卡的IP地址 + /// + public static IPAddress? MyIP() => GetIPsWithCache().FirstOrDefault(ip => ip.IsIPv4() && !IPAddress.IsLoopback(ip) && ip.GetAddressBytes()[0] != 169); + + /// 获取本地第一个IPv6地址 + /// + public static IPAddress? MyIPv6() => GetIPsWithCache().FirstOrDefault(ip => !ip.IsIPv4() && !IPAddress.IsLoopback(ip)); + #endregion + + #region 远程开机 + /// 唤醒指定MAC地址的计算机 + /// + public static void Wake(params String[] macs) + { + if (macs == null || macs.Length <= 0) return; + + foreach (var item in macs) + Wake(item); + } + + private static void Wake(String mac) + { + mac = mac.Replace("-", null).Replace(":", null); + var buffer = Pool.Shared.Rent(mac.Length / 2); + for (var i = 0; i < buffer.Length; i++) + buffer[i] = Byte.Parse(mac.Substring(i * 2, 2), NumberStyles.HexNumber); + + var bts = Pool.Shared.Rent(6 + 16 * buffer.Length); + for (var i = 0; i < 6; i++) + bts[i] = 0xFF; + for (Int32 i = 6, k = 0; i < bts.Length; i++, k++) + { + if (k >= buffer.Length) k = 0; + + bts[i] = buffer[k]; + } + + var client = new UdpClient + { + EnableBroadcast = true + }; + client.Send(bts, bts.Length, new IPEndPoint(IPAddress.Broadcast, 7)); + client.Close(); + //client.SendAsync(bts, bts.Length, new IPEndPoint(IPAddress.Broadcast, 7)); + + Pool.Shared.Return(bts); + Pool.Shared.Return(buffer); + } + #endregion + + #region MAC获取/ARP协议 + [DllImport("Iphlpapi.dll")] + private static extern Int32 SendARP(UInt32 destip, UInt32 srcip, Byte[] mac, ref Int32 length); + + /// 根据IP地址获取MAC地址 + /// + /// + public static Byte[]? GetMac(this IPAddress ip) + { + // 考虑到IPv6是16字节,不确定SendARP是否支持IPv6 + var len = 16; + var buf = new Byte[16]; + + if (Runtime.Windows) + { + var rs = SendARP(ip.GetAddressBytes().ToUInt32(), 0, buf, ref len); + if (rs != 0 || len <= 0) return null; + if (len != buf.Length) buf = buf.ReadBytes(0, len); + } + else + { + var networkInterfaces = NetworkInterface.GetAllNetworkInterfaces(); + foreach (var item in networkInterfaces) + { + if (_Excludes.Any(e => item.Description.Contains(e))) continue; + if (Runtime.Windows && item.Speed < 1_000_000) continue; + + var ips = item.GetIPProperties(); + var addrs = ips.UnicastAddresses + .Where(e => e.Address.AddressFamily == AddressFamily.InterNetwork) + .Select(e => e.Address) + .ToArray(); + if (addrs.All(e => IPAddress.IsLoopback(e))) continue; + + foreach (var ipInfo in ips.UnicastAddresses) + { + if (!ipInfo.Address.Equals(ip)) continue; + buf = item.GetPhysicalAddress()?.GetAddressBytes(); + } + + if (buf != null && buf.Length == 6) return buf; + } + } + + return buf; + } + #endregion + + #region IP地理位置 + /// IP地址提供者 + public static IIPResolver? IpResolver { get; set; } + + /// 获取IP地址的物理地址位置 + /// + /// + public static String? GetAddress(this IPAddress addr) + { + if (addr.IsAny()) return "任意地址"; + if (IPAddress.IsLoopback(addr)) return "本地环回"; + if (addr.IsLocal()) return "本机地址"; + + //if (IpProvider == null) IpProvider = new MyIpProvider(); + + return IpResolver?.GetAddress(addr); + } + + /// 根据字符串形式IP地址转为物理地址 + /// + /// + public static String IPToAddress(this String addr) + { + if (addr.IsNullOrEmpty()) return String.Empty; + + // 有可能是NetUri + var p = addr.IndexOf("://"); + if (p >= 0) addr = addr[(p + 3)..]; + + // 有可能是多个IP地址 + p = addr.IndexOf(','); + if (p >= 0) addr = addr.Split(',').First(); + + // 过滤IPv4/IPv6端口 + if (addr.Replace("::", "").Contains(':')) addr = addr[..addr.LastIndexOf(':')]; + + return !IPAddress.TryParse(addr, out var ip) ? String.Empty : (ip.GetAddress() ?? String.Empty); + } + #endregion + + +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Net/NetUri.cs b/src/Admin/ThingsGateway.NewLife.X/Net/NetUri.cs new file mode 100644 index 000000000..615784509 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Net/NetUri.cs @@ -0,0 +1,283 @@ +using System.Net; +using System.Net.Sockets; +using System.Runtime.Serialization; +using System.Xml.Serialization; + +namespace ThingsGateway.NewLife.Net; + +/// 协议类型 +public enum NetType : Byte +{ + /// 未知协议 + Unknown = 0, + + /// 传输控制协议 + Tcp = 6, + + /// 用户数据报协议 + Udp = 17, + + /// Http协议 + Http = 80, + + /// Https协议 + Https = 43, + + /// WebSocket协议 + WebSocket = 81 +} + +/// 网络资源标识,指定协议、地址、端口、地址族(IPv4/IPv6) +/// +/// 仅序列化,其它均是配角! +/// 有可能代表主机域名,而指定主机IP地址。 +/// +public class NetUri +{ + #region 属性 + /// 协议类型 + public NetType Type { get; set; } + + /// 主机或域名 + /// 可能对应多个IP地址 + public String? Host { get; set; } + + /// 地址 + /// + /// 域名多地址时的第一个。 + /// 设置地址后,反向覆盖Host。 + /// + [XmlIgnore, IgnoreDataMember] + public IPAddress Address { get { return EndPoint.Address; } set { EndPoint.Address = value; } } + + /// 端口 + public Int32 Port { get { return EndPoint.Port; } set { EndPoint.Port = value; } } + + [NonSerialized] + private IPEndPoint? _EndPoint; + /// 终结点 + /// + /// 域名多地址时的第一个。 + /// 设置地址后,反向覆盖Host。 + /// + [XmlIgnore, IgnoreDataMember] + public IPEndPoint EndPoint + { + get + { + var ep = _EndPoint; + ep ??= _EndPoint = new IPEndPoint(IPAddress.Any, 0); + if ((ep.Address == null || ep.Address.IsAny()) && !Host.IsNullOrEmpty()) ep.Address = ParseAddress(Host)?.FirstOrDefault() ?? IPAddress.Any; + + return ep; + } + set + { + var ep = _EndPoint = value; + Host = ep?.Address?.ToString(); + } + } + #endregion + + #region 扩展属性 + /// 是否Tcp协议 + [XmlIgnore, IgnoreDataMember] + public Boolean IsTcp => Type == NetType.Tcp; + + /// 是否Udp协议 + [XmlIgnore, IgnoreDataMember] + public Boolean IsUdp => Type == NetType.Udp; + #endregion + + #region 构造 + /// 实例化 + public NetUri() { } + + /// 实例化 + /// + public NetUri(String uri) => Parse(uri); + + /// 实例化 + /// + /// + public NetUri(NetType protocol, IPEndPoint endpoint) + { + Type = protocol; + _EndPoint = endpoint; + } + + /// 实例化 + /// + /// + /// + public NetUri(NetType protocol, IPAddress address, Int32 port) + { + Type = protocol; + Address = address; + Port = port; + } + + /// 实例化 + /// + /// + /// + public NetUri(NetType protocol, String host, Int32 port) + { + Type = protocol; + Host = host; + Port = port; + } + #endregion + + #region 方法 + private const String Sep = "://"; + + /// 分析 + /// + public NetUri Parse(String uri) + { + if (uri.IsNullOrWhiteSpace()) return this; + + // 分析协议 + var protocol = ""; + var array = uri.Split(Sep); + if (array.Length >= 2) + { + protocol = array[0]?.Trim() + ""; + Type = ParseType(protocol); + uri = array[1]?.Trim() + ""; + } + + if (uri.IsNullOrWhiteSpace()) return this; + + Host = null; + _EndPoint = null; + + // 特殊协议端口 + switch (protocol.ToLower()) + { + case "http": + case "ws": + Port = 80; + break; + case "https": + case "wss": + Port = 443; + break; + } + + // 这个可能是一个Uri,去掉尾部 + var p = uri.IndexOf('/'); + if (p < 0) p = uri.IndexOf('\\'); + if (p < 0) p = uri.IndexOf('?'); + if (p >= 0) uri = uri[..p]?.Trim() + ""; + + // 分析端口,冒号前一个不能是冒号 + p = uri.LastIndexOf(':'); + if (p >= 0 && (p < 1 || uri[p - 1] != ':')) + { + var pt = uri[(p + 1)..]; + if (Int32.TryParse(pt, out var port)) + { + Port = port; + uri = uri[..p]?.Trim() + ""; + } + } + + if (IPAddress.TryParse(uri, out var address)) + Address = address; + else + Host = uri; + + return this; + } + + private static NetType ParseType(String? value) + { + if (value.IsNullOrEmpty()) return NetType.Unknown; + + try + { + if (value.EqualIgnoreCase("Http", "Https")) return NetType.Http; + if (value.EqualIgnoreCase("ws", "wss")) return NetType.WebSocket; + + return (NetType)(Int32)Enum.Parse(typeof(ProtocolType), value, true); + } + catch { return NetType.Unknown; } + } + + /// 获取该域名下所有IP地址 + /// + public IPAddress[] GetAddresses() => ParseAddress(Host) ?? [Address]; + + /// 获取该域名下所有IP节点(含端口) + /// + public IPEndPoint[] GetEndPoints() => GetAddresses().Select(e => new IPEndPoint(e, Port)).ToArray(); + + + /// 克隆 + /// + public NetUri Clone() => new() { Type = Type, Host = Host, Port = Port, Address = Address }; + #endregion + + #region 辅助 + /// 分析地址 + /// 主机地址 + /// + public static IPAddress[]? ParseAddress(String? hostname) + { + if (hostname.IsNullOrEmpty()) return null; + if (hostname == "*") return null; + + try + { + if (IPAddress.TryParse(hostname, out var addr)) return [addr]; + + var hostAddresses = DnsResolver.Instance.Resolve(hostname); + if (hostAddresses == null || hostAddresses.Length <= 0) return null; + + return hostAddresses.Where(d => d.AddressFamily is AddressFamily.InterNetwork or AddressFamily.InterNetworkV6).ToArray(); + } + catch (SocketException ex) + { + throw new XException($"Failed to resolve the address of host {hostname}!{ex.Message}", ex); + } + } + + /// 已重载。 + /// + public override String ToString() + { + var protocol = Type.ToString().ToLower(); + switch (Type) + { + case NetType.Unknown: + protocol = ""; + break; + case NetType.WebSocket: + protocol = Port == 443 ? "wss" : "ws"; + break; + } + var host = Host; + if (host.IsNullOrEmpty()) + { + if (Address.AddressFamily == AddressFamily.InterNetworkV6 && Port > 0) + host = $"[{Address}]"; + else + host = Address + ""; + } + + if (Port > 0) + return $"{protocol}://{host}:{Port}"; + else + return $"{protocol}://{host}"; + } + #endregion + + #region 重载运算符 + /// 重载类型转换,字符串直接转为NetUri对象 + /// + /// + public static implicit operator NetUri(String value) => new(value); + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Net/TcpConnectionInformation2.cs b/src/Admin/ThingsGateway.NewLife.X/Net/TcpConnectionInformation2.cs new file mode 100644 index 000000000..daa6ab60f --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Net/TcpConnectionInformation2.cs @@ -0,0 +1,374 @@ +using System.Globalization; +using System.Net; +using System.Net.NetworkInformation; +using System.Runtime.InteropServices; + +namespace ThingsGateway.NewLife.Net; + +/// Tcp连接信息 +public class TcpConnectionInformation2 : TcpConnectionInformation +{ + /// 本地结点 + public override IPEndPoint LocalEndPoint { get; } + + /// 远程结点 + public override IPEndPoint RemoteEndPoint { get; } + + /// Tcp状态 + public override TcpState State { get; } + + /// 进程标识 + public Int32 ProcessId { get; set; } + + /// inode标识 + public String? Node { get; set; } + + /// 实例化Tcp连接信息 + /// + /// + /// + /// + public TcpConnectionInformation2(IPEndPoint local, IPEndPoint remote, TcpState state, Int32 processId) + { + LocalEndPoint = local; + RemoteEndPoint = remote; + State = state; + ProcessId = processId; + } + + private TcpConnectionInformation2(MIB_TCPROW_OWNER_PID row) + { + State = (TcpState)row.state; + var port = (row.localPort1 << 8) | row.localPort2; + var port2 = (State != TcpState.Listen) ? ((row.remotePort1 << 8) | row.remotePort2) : 0; + LocalEndPoint = new IPEndPoint(row.localAddr, port); + RemoteEndPoint = new IPEndPoint(row.remoteAddr, port2); + ProcessId = row.owningPid; + } + + /// 已重载。 + /// + public override String ToString() => $"{LocalEndPoint}<=>{RemoteEndPoint} {State} {ProcessId}"; + + #region Windows连接信息 + private enum TCP_TABLE_CLASS : Int32 + { + TCP_TABLE_BASIC_LISTENER, + TCP_TABLE_BASIC_CONNECTIONS, + TCP_TABLE_BASIC_ALL, + TCP_TABLE_OWNER_PID_LISTENER, + TCP_TABLE_OWNER_PID_CONNECTIONS, + TCP_TABLE_OWNER_PID_ALL, + TCP_TABLE_OWNER_MODULE_LISTENER, + TCP_TABLE_OWNER_MODULE_CONNECTIONS, + TCP_TABLE_OWNER_MODULE_ALL + } + + [StructLayout(LayoutKind.Sequential)] + private struct MIB_TCPROW_OWNER_PID + { + public UInt32 state; + public UInt32 localAddr; + public Byte localPort1; + public Byte localPort2; + public Byte localPort3; + public Byte localPort4; + public UInt32 remoteAddr; + public Byte remotePort1; + public Byte remotePort2; + public Byte remotePort3; + public Byte remotePort4; + public Int32 owningPid; + + public UInt16 LocalPort => BitConverter.ToUInt16([localPort2, localPort1], 0); + + public UInt16 RemotePort => BitConverter.ToUInt16([remotePort2, remotePort1], 0); + } + + [StructLayout(LayoutKind.Sequential)] + private struct MIB_TCPTABLE_OWNER_PID + { + public UInt32 dwNumEntries; + private MIB_TCPROW_OWNER_PID table; + } + + [DllImport("iphlpapi.dll", SetLastError = true)] + private static extern UInt32 GetExtendedTcpTable(IntPtr pTcpTable, + ref Int32 dwOutBufLen, + Boolean sort, + Int32 ipVersion, + TCP_TABLE_CLASS tblClass, + Int32 reserved); + + /// 获取所有Tcp连接 + /// + public static TcpConnectionInformation2[] GetWindowsTcpConnections() + { + //MIB_TCPROW_OWNER_PID[] tTable; + var AF_INET = 2; // IP_v4 + var buffSize = 0; + + // how much memory do we need? + var ret = GetExtendedTcpTable(IntPtr.Zero, + ref buffSize, + true, + AF_INET, + TCP_TABLE_CLASS.TCP_TABLE_OWNER_PID_ALL, + 0); + if (ret is not 0 and not 122) // 122 insufficient buffer size + throw new Exception("bad ret on check " + ret); + var buffTable = Marshal.AllocHGlobal(buffSize); + + var list = new List(); + try + { + ret = GetExtendedTcpTable(buffTable, + ref buffSize, + true, + AF_INET, + TCP_TABLE_CLASS.TCP_TABLE_OWNER_PID_ALL, + 0); + if (ret != 0) + throw new Exception("bad ret " + ret); + + // get the number of entries in the table + var tab = + (MIB_TCPTABLE_OWNER_PID)Marshal.PtrToStructure( + buffTable, + typeof(MIB_TCPTABLE_OWNER_PID))!; + var rowPtr = (nint)((Int64)buffTable + + Marshal.SizeOf(tab.dwNumEntries)); + //tTable = new MIB_TCPROW_OWNER_PID[tab.dwNumEntries]; + + for (var i = 0; i < tab.dwNumEntries; i++) + { + var tcpRow = (MIB_TCPROW_OWNER_PID)Marshal + .PtrToStructure(rowPtr, typeof(MIB_TCPROW_OWNER_PID))!; + //tTable[i] = tcpRow; + list.Add(new TcpConnectionInformation2(tcpRow)); + + // next entry + rowPtr = (nint)((Int64)rowPtr + Marshal.SizeOf(tcpRow)); + } + } + finally + { + // Free the Memory + Marshal.FreeHGlobal(buffTable); + } + //return tTable; + return list.ToArray(); + } + #endregion + + #region Linux连接信息 + /// 获取指定进程的Tcp连接 + /// 目标进程。默认-1未指定,获取所有进程的Tcp连接 + /// + public static TcpConnectionInformation2[] GetLinuxTcpConnections(Int32 processId = -1) + { + var list = new List(); + + String[]? nodes = null; + if (processId > 0) + { + // 获取指定进程的所有inode + nodes = GetNodes(processId); + if (nodes == null || nodes.Length == 0) return list.ToArray(); + } + + // 各个进程底下的/net/tcp,实际上是所有进程的连接 + var rs = ParseTcpsFromFile(processId > 0 ? $"/proc/{processId}/net/tcp" : "/proc/net/tcp"); + if (rs != null && rs.Count > 0) list.AddRange(rs); + + var rs2 = ParseTcpsFromFile(processId > 0 ? $"/proc/{processId}/net/tcp6" : "/proc/net/tcp6"); + if (rs2 != null && rs2.Count > 0) list.AddRange(rs2); + //XTrace.WriteLine("tcps: {0} nodes: {1}", list.Count, nodes?.Length); + + // 过滤指定进程的连接 + if (processId > 0 && nodes != null) + { + var list2 = new List(); + foreach (var item in list) + { + if (nodes.Contains(item.Node)) + { + item.ProcessId = processId; + list2.Add(item); + } + } + list = list2; + } + //XTrace.WriteLine("tcps2: {0}", list.Count); + + return list.ToArray(); + } + + private static IList ParseTcpsFromFile(String file) + { +#if DEBUG + //XTrace.WriteLine("ParseTcpsFromFile {0}", file); + //DefaultSpan.Current?.AppendTag($"ParseTcpsFromFile {file}"); +#endif + + var text = File.ReadAllText(file); + + return ParseTcps(text); + } + private static readonly char[] SpaceChars = new Char[] { ' ' }; + /// 分析Tcp连接信息 + /// + /// + public static IList ParseTcps(String text) + { + var list = new List(); + + if (text.IsNullOrEmpty()) return list; + + // 逐行读取TCP连接信息 + foreach (var line in text.Split(new[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries)) + { + var parts = line.Split(SpaceChars, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 4 || parts[1].IndexOf(':') < 0) continue; + + //// 提取连接信息 + //var ps1 = parts[1].Split(':'); + //var ps2 = parts[2].Split(':'); + //if (ps1.Length < 2 || ps2.Length < 2) continue; + + var state = GetState(parts[3]); + + //// IPv4 和 IPv6 解析方式不同 + //if (ps1[0].Length <= 8) + //{ + // var localAddress = new IPAddress(ps1[0].ToHex().Reverse().ToArray()); + // var local = new IPEndPoint(localAddress, Int32.Parse(ps1[1], NumberStyles.HexNumber)); + // var remoteAddress = new IPAddress(ps2[0].ToHex().Reverse().ToArray()); + // var remote = new IPEndPoint(remoteAddress, Int32.Parse(ps2[1], NumberStyles.HexNumber)); + // var info = new TcpConnectionInformation2(local, remote, state, 0); + + // list.Add(info); + //} + //else + //{ + // var localAddress = GetIPv6(ps1[0]); + // var local = new IPEndPoint(localAddress, Int32.Parse(ps1[1], NumberStyles.HexNumber)); + + // var remoteAddress = GetIPv6(ps2[0]); + // var remote = new IPEndPoint(remoteAddress, Int32.Parse(ps2[1], NumberStyles.HexNumber)); + + // var info = new TcpConnectionInformation2(local, remote, state, 0); + + // list.Add(info); + //} + + var local = ParseAddressAndPort(parts[1]); + var remote = ParseAddressAndPort(parts[2]); + //var pid = GetProcessIdFromInode(parts[9]); + var info = new TcpConnectionInformation2(local, remote, state, 0); + info.Node = parts[9]; + + list.Add(info); + } + + return list; + } + + private static String[] GetNodes(Int32 processId) + { + var path = $"/proc/{processId}/fd".AsDirectory(); + if (!path.Exists) return Array.Empty(); + + var files = new List(); + foreach (var fi in path.GetFiles()) + { + var name = fi.Name; +#if NET6_0_OR_GREATER + if (fi.Attributes.HasFlag(FileAttributes.ReparsePoint)) + name = fi.ResolveLinkTarget(true)?.Name; +#endif + + if (!name.IsNullOrEmpty()) files.Add(name); + } + + return ParseNodes(files); + } + + /// 分析Socket的inode + /// + /// + public static String[] ParseNodes(IList files) + { + var list = new List(); + foreach (var item in files) + { + var node = item.Substring("socket:[", "]"); + if (!node.IsNullOrEmpty()) list.Add(node); + } + + return list.ToArray(); + } + + private static IPEndPoint ParseAddressAndPort(String colonSeparatedAddress) + { + var num = colonSeparatedAddress.IndexOf(':'); + if (num == -1) throw new NetworkInformationException(); + + var address = ParseHexIPAddress(colonSeparatedAddress.Substring(0, num)); + if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); + var s = colonSeparatedAddress.Substring(num + 1); + return !Int32.TryParse(s, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var result) + ? throw new NetworkInformationException() + : new IPEndPoint(address, result); + } + + internal static IPAddress ParseHexIPAddress(String remoteAddressString) + { + if (remoteAddressString.Length <= 8) return ParseIPv4HexString(remoteAddressString); + + if (remoteAddressString.Length == 32) return ParseIPv6HexString(remoteAddressString); + + throw new NetworkInformationException(); + } + + private static IPAddress ParseIPv4HexString(String hexAddress) + { + return !Int64.TryParse(hexAddress, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var result) + ? throw new NetworkInformationException() + : new IPAddress(result); + } + + private static IPAddress ParseIPv6HexString(String hexAddress, Boolean isNetworkOrder = false) + { + var span = hexAddress.ToHex(); + if (!isNetworkOrder && BitConverter.IsLittleEndian) + { + for (var j = 0; j < 4; j++) + { + Array.Reverse(span, j * 4, 4); + } + } + + return new IPAddress(span); + } + + private static TcpState GetState(String hexState) + { + return hexState switch + { + "01" => TcpState.Established, + "02" => TcpState.SynSent, + "03" => TcpState.SynReceived, + "04" => TcpState.FinWait1, + "05" => TcpState.FinWait2, + "06" => TcpState.TimeWait, + "07" => TcpState.Closed, + "08" => TcpState.CloseWait, + "09" => TcpState.LastAck, + "0A" => TcpState.Listen, + "0B" => TcpState.Closing, + _ => TcpState.Unknown, + }; + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/README.md b/src/Admin/ThingsGateway.NewLife.X/README.md new file mode 100644 index 000000000..e75644206 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/README.md @@ -0,0 +1,5 @@ +# NewLife.Core 通用类库 + +网站:https://newlifex.com + +代码由新生命开发团队完全开源,采用最松散的MIT开源协议,您可以随意使用甚至商用,但需要保留版权信息。 \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/README.zh-CN.md b/src/Admin/ThingsGateway.NewLife.X/README.zh-CN.md new file mode 100644 index 000000000..e75644206 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/README.zh-CN.md @@ -0,0 +1,5 @@ +# NewLife.Core 通用类库 + +网站:https://newlifex.com + +代码由新生命开发团队完全开源,采用最松散的MIT开源协议,您可以随意使用甚至商用,但需要保留版权信息。 \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Reflection/AssemblyX.cs b/src/Admin/ThingsGateway.NewLife.X/Reflection/AssemblyX.cs new file mode 100644 index 000000000..c9628db56 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Reflection/AssemblyX.cs @@ -0,0 +1,833 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using ThingsGateway.NewLife.Collections; +using ThingsGateway.NewLife.Log; + +namespace ThingsGateway.NewLife.Reflection; + +/// 程序集辅助类。使用Create创建,保证每个程序集只有一个辅助类 +public class AssemblyX +{ + #region 属性 + /// 程序集 + public Assembly Asm { get; } + + private String? _Name; + /// 名称 + public String Name => _Name ??= "" + Asm.GetName().Name; + + private String? _Version; + /// 程序集版本 + public String Version => _Version ??= "" + Asm.GetName().Version; + + private String? _Title; + /// 程序集标题 + public String Title => _Title ??= "" + Asm.GetCustomAttributeValue(); + + private String? _FileVersion; + /// 文件版本 + public String FileVersion + { + get + { + if (_FileVersion == null) + { + var ver = Asm.GetCustomAttributeValue(); + if (!ver.IsNullOrEmpty()) + { + var p = ver.IndexOf('+'); + if (p > 0) ver = ver[..p]; + } + _FileVersion = ver; + } + + _FileVersion ??= Asm.GetCustomAttributeValue(); + + _FileVersion ??= ""; + + return _FileVersion; + } + } + + private DateTime? _Compile; + /// 编译时间 + public DateTime Compile + { + get + { + if (_Compile == null) + { + var time = GetCompileTime(Version); + if (time == time.Date && FileVersion.Contains("-beta")) time = GetCompileTime(FileVersion); + + _Compile = time; + } + return _Compile.Value; + } + } + + private String? _Company; + /// 公司名称 + public String Company => _Company ??= "" + Asm.GetCustomAttributeValue(); + + private String? _Description; + /// 说明 + public String Description => _Description ??= "" + Asm.GetCustomAttributeValue(); + + /// 获取包含清单的已加载文件的路径或 UNC 位置。 + public String? Location + { + get + { + try + { + return Asm == null || Asm.IsDynamic ? null : Asm.Location; + } + catch { return null; } + } + } + #endregion + + #region 构造 + private AssemblyX(Assembly asm) => Asm = asm; + + private static readonly ConcurrentDictionary cache = new(); + /// 创建程序集辅助对象 + /// + /// + public static AssemblyX? Create(Assembly? asm) + { + if (asm == null) return null; + + return cache.GetOrAdd(asm, key => new AssemblyX(key)); + } + + static AssemblyX() + { + //AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += OnReflectionOnlyAssemblyResolve; + AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve; + } + + private static Assembly? OnAssemblyResolve(Object? sender, ResolveEventArgs args) + { + var flag = XTrace.Log.Level <= LogLevel.Debug; + if (flag) XTrace.WriteLine("[{0}]请求加载[{1}]", args.RequestingAssembly?.FullName, args.Name); + //if (!flag) return null; + + try + { + // 尝试在请求者所在目录加载 + var file = args.RequestingAssembly?.Location; + if (!file.IsNullOrEmpty() && !args.Name.IsNullOrEmpty()) + { + var name = args.Name; + var p = name.IndexOf(','); + if (p > 0) name = name[..p]; + + file = Path.GetDirectoryName(file).CombinePath(name + ".dll"); + if (File.Exists(file)) return Assembly.LoadFrom(file); + } + //取消 Diego + //// 辅助解析程序集。程序集加载过程中,被依赖程序集未能解析时,是否协助解析,默认false + //if (Setting.Current.AssemblyResolve && !args.Name.IsNullOrEmpty()) + // return OnResolve(args.Name); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + + return null; + } + #endregion + + #region 扩展属性 + //private IEnumerable _Types; + /// 类型集合,当前程序集的所有类型,包括私有和内嵌,非内嵌请直接调用Asm.GetTypes() + public IEnumerable Types + { + get + { + Type?[]? ts; + try + { + ts = Asm.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + if (ex.LoaderExceptions != null && XTrace.Log.Level == LogLevel.Debug) + { + XTrace.WriteLine("加载[{0}]{1}的类型时发生个{2}错误!", this, Location, ex.LoaderExceptions.Length); + foreach (var le in ex.LoaderExceptions) + { + if (le != null) XTrace.WriteException(le); + } + } + ts = ex.Types; + } + if (ts == null || ts.Length <= 0) yield break; + + // 先遍历一次ts,避免取内嵌类型带来不必要的性能损耗 + foreach (var item in ts) + { + if (item != null) yield return item; + } + + var queue = new Queue(); + foreach (var item in ts) + { + if (item != null) queue.Enqueue(item); + } + while (queue.Count > 0) + { + var item = queue.Dequeue(); + if (item == null) continue; + + var ts2 = item.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance); + if (ts2 != null && ts2.Length > 0) + { + // 从下一个元素开始插入,让内嵌类紧挨着主类 + //Int32 k = i + 1; + foreach (var elm in ts2) + { + //if (!list.Contains(item)) list.Insert(k++, item); + // Insert将会导致大量的数组复制 + queue.Enqueue(elm); + + yield return elm; + } + } + } + } + } + + /// 是否系统程序集 + public Boolean IsSystemAssembly => CheckSystem(Asm); + + private static Boolean CheckSystem(Assembly asm) + { + if (asm == null) return false; + + var name = asm.FullName; + if (name.IsNullOrEmpty()) return false; + + if (name.EndsWith("PublicKeyToken=b77a5c561934e089")) return true; + if (name.EndsWith("PublicKeyToken=b03f5f7f11d50a3a")) return true; + if (name.EndsWith("PublicKeyToken=89845dcd8080cc91")) return true; + if (name.EndsWith("PublicKeyToken=31bf3856ad364e35")) return true; + + return false; + } + #endregion + + #region 静态属性 + /// 入口程序集 + public static AssemblyX? Entry { get; set; } = Create(Assembly.GetEntryAssembly()); + + /// + /// 加载过滤器,如果返回 false 表示跳过加载。 + /// + public static Func? ResolveFilter { get; set; } + #endregion + + #region 方法 + private readonly ConcurrentDictionary typeCache2 = new(); + /// 从程序集中查找指定名称的类型 + /// + /// + public Type? GetType(String typeName) + { + if (String.IsNullOrEmpty(typeName)) throw new ArgumentNullException(nameof(typeName)); + + return typeCache2.GetOrAdd(typeName, GetTypeInternal); + } + + /// 在程序集中查找类型 + /// + /// + private Type? GetTypeInternal(String typeName) + { + var type = Asm.GetType(typeName); + if (type != null) return type; + + // 如果没有包含圆点,说明其不是FullName + if (!typeName.Contains('.')) + { + //try + //{ + // var types = Asm.GetTypes(); + // if (types != null && types.Length > 0) + // { + // foreach (var item in types) + // { + // if (item.Name == typeName) return item; + // } + // } + //} + //catch (ReflectionTypeLoadException ex) + //{ + // if (XTrace.Debug) + // { + // //NewLife.Log.XTrace.WriteException(ex); + // XTrace.WriteLine("加载[{0}]{1}的类型时发生个{2}错误!", this, Location, ex.LoaderExceptions.Length); + + // foreach (var item in ex.LoaderExceptions) + // { + // XTrace.WriteException(item); + // } + // } + + // return null; + //} + //catch (Exception ex) + //{ + // if (XTrace.Debug) NewLife.Log.XTrace.WriteException(ex); + + // return null; + //} + + // 遍历所有类型,包括内嵌类型 + foreach (var item in Types) + { + if (item.Name == typeName) return item; + } + } + + return null; + } + #endregion + + #region 插件 + private readonly ConcurrentDictionary> _plugins = new(); + /// 查找插件,带缓存 + /// 类型 + /// + [EditorBrowsable(EditorBrowsableState.Advanced)] + public IList FindPlugins(Type baseType) + { + // 如果type是null,则返回所有类型 + if (_plugins.TryGetValue(baseType, out var list)) return list; + + Type?[]? types = null; + list = []; + try + { + types = Asm.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + // 即使抛出加载异常,也有一部分类型可以用 + types = ex.Types; + NewLife.Log.XTrace.WriteException(ex); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + + if (types != null) + { + foreach (var item in types) + { + if (item != null && !item.IsInterface && !item.IsAbstract && !item.IsGenericType && item != baseType && item.As(baseType)) list.Add(item); + } + } + + _plugins.TryAdd(baseType, list); + + return list; + } + + /// 查找所有非系统程序集中的所有插件 + /// 继承类所在的程序集会引用baseType所在的程序集,利用这一点可以做一定程度的性能优化。 + /// + /// 是否从未加载程序集中获取类型。使用仅反射的方法检查目标类型,如果存在,则进行常规加载 + /// 指示是否应检查来自所有引用程序集的类型。如果为 false,则检查来自所有引用程序集的类型。 否则,只检查来自非全局程序集缓存 (GAC) 引用的程序集的类型。 + /// + public static IEnumerable FindAllPlugins(Type baseType, Boolean isLoadAssembly = false, Boolean excludeGlobalTypes = true) + { + var baseAssemblyName = baseType.Assembly.GetName().Name; + + // 如果基类所在程序集没有强命名,则搜索时跳过所有强命名程序集 + // 因为继承类程序集的强命名要求基类程序集必须强命名 + var signs = baseType.Assembly.GetName().GetPublicKey(); + var hasNotSign = signs == null || signs.Length <= 0; + + var list = new List(); + foreach (var item in GetAssemblies()) + { + signs = item.Asm.GetName().GetPublicKey(); + if (hasNotSign && signs != null && signs.Length > 0) continue; + + //// 如果excludeGlobalTypes为true,则指检查来自非GAC引用的程序集 + //if (excludeGlobalTypes && item.Asm.GlobalAssemblyCache) continue; + + // 不搜索系统程序集,不搜索未引用基类所在程序集的程序集,优化性能 + if (item.IsSystemAssembly || !IsReferencedFrom(item.Asm, baseAssemblyName)) continue; + + var ts = item.FindPlugins(baseType); + foreach (var elm in ts) + { + if (!list.Contains(elm)) + { + list.Add(elm); + yield return elm; + } + } + } + if (!isLoadAssembly) yield break; + + foreach (var item in ReflectionOnlyGetAssemblies()) + { + //// 如果excludeGlobalTypes为true,则指检查来自非GAC引用的程序集 + //if (excludeGlobalTypes && item.Asm.GlobalAssemblyCache) continue; + + // 不搜索系统程序集,不搜索未引用基类所在程序集的程序集,优化性能 + if (item.IsSystemAssembly || !IsReferencedFrom(item.Asm, baseAssemblyName)) continue; + + var ts = item.FindPlugins(baseType); + if (ts != null && ts.Count > 0) + { + // 真实加载 + if (XTrace.Debug) + { + // 如果是本目录的程序集,去掉目录前缀 + var file = item.Asm.Location; + var root = AppDomain.CurrentDomain.BaseDirectory; + if (!root.IsNullOrEmpty() && file.StartsWithIgnoreCase(root)) file = file.Substring(root.Length).TrimStart("\\"); + XTrace.WriteLine("AssemblyX.FindAllPlugins(\"{0}\") => {1}", baseType.FullName, file); + } + var asm2 = Assembly.LoadFrom(item.Asm.Location); + ts = Create(asm2)?.FindPlugins(baseType); + if (ts != null) + { + foreach (var elm in ts) + { + if (!list.Contains(elm)) + { + list.Add(elm); + yield return elm; + } + } + } + } + } + } + + /// 是否引用了 + /// 程序集 + /// 被引用程序集全名 + /// + private static Boolean IsReferencedFrom(Assembly asm, String? baseAsmName) + { + if (baseAsmName.IsNullOrEmpty()) return false; + if (asm.GetName().Name.EqualIgnoreCase(baseAsmName)) return true; + + foreach (var item in asm.GetReferencedAssemblies()) + { + if (item.Name.EqualIgnoreCase(baseAsmName)) return true; + } + + return false; + } + + /// 根据名称获取类型 + /// 类型名 + /// 是否从未加载程序集中获取类型。使用仅反射的方法检查目标类型,如果存在,则进行常规加载 + /// + public static Type? GetType(String typeName, Boolean isLoadAssembly) + { + var type = Type.GetType(typeName); + if (type != null) return type; + + // 数组 + if (typeName.EndsWith("[]")) + { + var elemType = GetType(typeName[0..^2], isLoadAssembly); + if (elemType == null) return null; + + return elemType.MakeArrayType(); + } + + // 加速基础类型识别,忽略大小写 + if (!typeName.Contains('.')) + { + foreach (var item in Enum.GetNames(typeof(TypeCode))) + { + if (typeName.EqualIgnoreCase(item)) + { + type = Type.GetType("System." + item); + if (type != null) return type; + } + } + } + + // 尝试本程序集 + var asms = new[] { + Create(Assembly.GetExecutingAssembly()), + Create(Assembly.GetCallingAssembly()), + Create(Assembly.GetEntryAssembly()) }; + var loads = new List(); + + foreach (var asm in asms) + { + if (asm == null || loads.Contains(asm)) continue; + loads.Add(asm); + + type = asm.GetType(typeName); + if (type != null) return type; + } + + // 尝试所有程序集 + foreach (var asm in GetAssemblies()) + { + if (loads.Contains(asm)) continue; + loads.Add(asm); + + type = asm.GetType(typeName); + if (type != null) return type; + } + + // 尝试加载只读程序集 + if (!isLoadAssembly) return null; + + foreach (var asm in ReflectionOnlyGetAssemblies()) + { + type = asm.GetType(typeName); + if (type != null) + { + // 真实加载 + var file = asm.Asm.Location; + try + { + type = null; + var asm2 = Assembly.LoadFrom(file); + var type2 = Create(asm2)?.GetType(typeName); + if (type2 == null) continue; + + type = type2; + } + catch (Exception ex) + { + if (XTrace.Debug) NewLife.Log.XTrace.WriteException(ex); + } + + return type; + } + } + + return null; + } + #endregion + + #region 静态加载 + /// 获取指定程序域所有程序集 + /// + /// + public static IEnumerable GetAssemblies(AppDomain? domain = null) + { + domain ??= AppDomain.CurrentDomain; + + var asms = domain.GetAssemblies(); + if (asms == null || asms.Length <= 0) yield break; + + //return asms.Select(item => Create(item)); + foreach (var item in asms) + { + var rs = Create(item); + if (rs != null) yield return rs; + } + } + + private static ICollection? _AssemblyPaths; + /// 程序集目录集合 + public static ICollection AssemblyPaths + { + [return: NotNull] + get + { + if (_AssemblyPaths == null) + { + var set = new HashSet(StringComparer.OrdinalIgnoreCase); + + var basedir = AppDomain.CurrentDomain.BaseDirectory; + if (!basedir.IsNullOrEmpty()) set.Add(basedir); + + //取消 Diego + + //var cfg = Setting.Current; + //if (!cfg.PluginPath.IsNullOrEmpty()) + //{ + // var plugin = cfg.PluginPath.GetFullPath(); + // if (!set.Contains(plugin)) set.Add(plugin); + + // plugin = cfg.PluginPath.GetBasePath(); + // if (!set.Contains(plugin)) set.Add(plugin); + //} + + _AssemblyPaths = set; + } + return _AssemblyPaths; + } + set => _AssemblyPaths = value; + } + + /// 获取当前程序域所有只反射程序集的辅助类。NETCore不支持只反射加载,该方法动态加载DLL后返回 + /// + public static IEnumerable ReflectionOnlyGetAssemblies() + { + var loadeds = GetAssemblies().ToList(); + + // 先返回已加载的只加载程序集 + var loadeds2 = AppDomain.CurrentDomain.ReflectionOnlyGetAssemblies().Select(e => Create(e)).ToList(); + foreach (var item in loadeds2) + { + if (item == null) continue; + + if (loadeds.Any(e => e.Location.EqualIgnoreCase(item.Location))) continue; + // 尽管目录不一样,但这两个可能是相同的程序集 + // 这里导致加载了不同目录的同一个程序集,然后导致对象容器频繁报错 + //if (loadeds.Any(e => e.Asm.FullName.EqualIgnoreCase(item.Asm.FullName))) continue; + // 相同程序集不同版本,全名不想等 + if (loadeds.Any(e => e.Asm.GetName().Name.EqualIgnoreCase(item.Asm.GetName().Name))) continue; + + yield return item; + } + + foreach (var item in AssemblyPaths) + { + foreach (var asm in ReflectionOnlyLoad(item)) yield return asm; + } + } + + private static readonly ConcurrentHashSet _BakImages = new(); + /// 只反射加载指定路径的所有程序集。NETCore不支持只反射加载,该方法动态加载DLL后返回 + /// + /// + public static IEnumerable ReflectionOnlyLoad(String path) + { + if (!Directory.Exists(path)) yield break; + + // 先返回已加载的只加载程序集 + //var loadeds2 = AppDomain.CurrentDomain.ReflectionOnlyGetAssemblies().Select(e => Create(e)).ToList(); + var loadeds2 = new List(); + foreach (var item in AppDomain.CurrentDomain.ReflectionOnlyGetAssemblies()) + { + var ax = Create(item); + if (ax != null) loadeds2.Add(ax); + } + + // 再去遍历目录 + var ss = Directory.GetFiles(path, "*.*", SearchOption.TopDirectoryOnly); + if (ss == null || ss.Length <= 0) yield break; + + var loadeds = GetAssemblies().ToList(); + + var ver = new Version(Assembly.GetExecutingAssembly().ImageRuntimeVersion.TrimStart('v')); + + foreach (var item in ss) + { + // 仅尝试加载dll + if (!item.EndsWithIgnoreCase(".dll")) continue; + if (_BakImages.Contains(item)) continue; + + if (loadeds.Any(e => e.Location.EqualIgnoreCase(item)) || + loadeds2.Any(e => e.Location.EqualIgnoreCase(item))) continue; + + Assembly? asm = null; + try + { + asm = Assembly.LoadFrom(item); + } + catch + { + _BakImages.TryAdd(item); + } + if (asm == null) continue; + + // 不搜索系统程序集,优化性能 + if (CheckSystem(asm)) continue; + + // 尽管目录不一样,但这两个可能是相同的程序集 + // 这里导致加载了不同目录的同一个程序集,然后导致对象容器频繁报错 + //if (loadeds.Any(e => e.Asm.FullName.EqualIgnoreCase(asm.FullName)) || + // loadeds2.Any(e => e.Asm.FullName.EqualIgnoreCase(asm.FullName))) continue; + // 相同程序集不同版本,全名不想等 + if (loadeds.Any(e => e.Asm.GetName().Name.EqualIgnoreCase(asm.GetName().Name)) || + loadeds2.Any(e => e.Asm.GetName().Name.EqualIgnoreCase(asm.GetName().Name))) continue; + + var asmx = Create(asm); + if (asmx != null) yield return asmx; + } + } + + /// 获取当前应用程序的所有程序集,不包括系统程序集,仅限本目录 + /// + public static List GetMyAssemblies() + { + var list = new List(); + var hs = new HashSet(StringComparer.OrdinalIgnoreCase); + var cur = AppDomain.CurrentDomain.BaseDirectory; + foreach (var asmx in GetAssemblies()) + { + // 加载程序集列表很容易抛出异常,全部屏蔽 + try + { + //if (asmx.FileVersion.IsNullOrEmpty()) continue; + + var file = ""; + +#if NETFRAMEWORK + file = asmx.Asm.CodeBase; +#endif + if (file.IsNullOrEmpty()) file = asmx.Asm.Location; + if (file.IsNullOrEmpty()) continue; + + if (file.StartsWith("file:///")) + { + file = file.TrimStart("file:///"); + if (Path.DirectorySeparatorChar == '\\') + file = file.Replace('/', '\\'); + else + file = file.Replace('\\', '/').EnsureStart("/"); + } + if (file.IsNullOrEmpty()) continue; + if (!file.StartsWithIgnoreCase(cur)) continue; + + if (hs.Add(file)) + { + list.Add(asmx); + } + } + catch { } + } + return list; + } + + /// 在对程序集的解析失败时发生 + /// + /// + private static Assembly? OnResolve(String name) + { + foreach (var item in AppDomain.CurrentDomain.GetAssemblies()) + { + if (item.FullName == name) return item; + } + + // 支持加载不同版本 + var p = name.IndexOf(','); + if (p > 0) + { + name = name[..p]; + foreach (var item in AppDomain.CurrentDomain.GetAssemblies()) + { + if (item.GetName().Name == name) return item; + } + + // 查找文件并加载 + foreach (var item in AssemblyPaths) + { + var file = item.CombinePath(name + ".dll"); + if (File.Exists(file)) + { + try + { + var asm = Assembly.LoadFrom(file); + //var asm = Assembly.Load(File.ReadAllBytes(file)); + if (asm != null && asm.GetName().Name == name) return asm; + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + } + } + } + + return null; + } + #endregion + + #region 重载 + /// 已重载。 + /// + public override String ToString() + { + if (!String.IsNullOrEmpty(Title)) + return Title; + else + return Name; + } + + ///// 判断两个程序集是否相同,避免引用加载和执行上下文加载的相同程序集显示不同 + ///// + ///// + ///// + //public static Boolean Equal(Assembly asm1, Assembly asm2) + //{ + // if (asm1 == asm2) return true; + + // return asm1.FullName == asm2.FullName; + //} + #endregion + + #region 辅助 + /// 根据版本号计算得到编译时间 + /// + /// + public static DateTime GetCompileTime(String version) + { + var ss = version?.Split(['.']); + if (ss == null || ss.Length < 4) return DateTime.MinValue; + + var d = ss[2].ToInt(); + var s = ss[3].ToInt(); + var y = DateTime.Today.Year; + + // 指定年月日的版本格式 1.0.yyyy.mmdd-betaHHMM + if (d <= y && d >= y - 10) + { + var dt = new DateTime(d, 1, 1); + if (s > 0) + { + if (s >= 200) dt = dt.AddMonths(s / 100 - 1); + s %= 100; + if (s > 1) dt = dt.AddDays(s - 1); + } + else + { + var str = ss[3]; + var p = str.IndexOf('-'); + if (p > 0) + { + s = str[..p].ToInt(); + if (s > 0) + { + if (s >= 200) dt = dt.AddMonths(s / 100 - 1); + s %= 100; + if (s > 1) dt = dt.AddDays(s - 1); + } + + if (str.Length >= 4 + 1 + 4) + { + s = str[^4..].ToInt(); + if (s > 0) dt = dt.AddHours(s / 100).AddMinutes(s % 100).ToLocalTime(); + } + } + } + + return dt; + } + else + { + var dt = new DateTime(2000, 1, 1); + dt = dt.AddDays(d).AddSeconds(s * 2); + + return dt; + } + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Reflection/AttributeX.cs b/src/Admin/ThingsGateway.NewLife.X/Reflection/AttributeX.cs new file mode 100644 index 000000000..62af3412b --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Reflection/AttributeX.cs @@ -0,0 +1,132 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Reflection; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife; + +/// 特性辅助类 +public static class AttributeX +{ + #region 静态方法 + private static readonly ConcurrentDictionary _asmCache = new(); + + /// 获取自定义属性,带有缓存功能,避免因.Net内部GetCustomAttributes没有缓存而带来的损耗 + /// + /// + /// + public static TAttribute[] GetCustomAttributes(this Assembly assembly) + { + if (assembly == null) return Array.Empty(); + + var key = $"{assembly.FullName}_{typeof(TAttribute).FullName}"; + + return (TAttribute[])_asmCache.GetOrAdd(key, k => + { + var atts = assembly.GetCustomAttributes(typeof(TAttribute), true) as TAttribute[]; + return atts ?? (Array.Empty()); + }); + } + + /// 获取成员绑定的显示名 + /// + /// + /// + public static String? GetDisplayName(this MemberInfo member, Boolean inherit = true) + { + var att = member.GetCustomAttribute(inherit); + if (att != null && !att.DisplayName.IsNullOrWhiteSpace()) return att.DisplayName; + + return null; + } + + /// 获取成员绑定的备注 + /// + /// + /// + public static String? GetDescription(this MemberInfo member, Boolean inherit = true) + { + var att2 = member.GetCustomAttribute(inherit); + if (att2 != null && !att2.Description.IsNullOrWhiteSpace()) return att2.Description; + + return null; + } + + /// 获取自定义属性的值。可用于ReflectionOnly加载的程序集 + /// + /// + /// + public static TResult? GetCustomAttributeValue(this Assembly target) where TAttribute : Attribute + { + if (target == null) return default; + + // CustomAttributeData可能会导致只反射加载,需要屏蔽内部异常 + try + { + var list = CustomAttributeData.GetCustomAttributes(target); + if (list == null || list.Count <= 0) return default; + + foreach (var item in list) + { + if (typeof(TAttribute) != item.Constructor.DeclaringType) continue; + + var args = item.ConstructorArguments; + if (args != null && args.Count > 0) return (TResult?)args[0].Value; + } + } + catch { } + + return default; + } + + /// 获取自定义属性的值。可用于ReflectionOnly加载的程序集 + /// + /// + /// 目标对象 + /// 是否递归 + /// + public static TResult? GetCustomAttributeValue(this MemberInfo target, Boolean inherit = true) where TAttribute : Attribute + { + if (target == null) return default; + + try + { + var list = CustomAttributeData.GetCustomAttributes(target); + if (list != null && list.Count > 0) + { + foreach (var item in list) + { + if (typeof(TAttribute).FullName != item.Constructor.DeclaringType?.FullName) continue; + + var args = item.ConstructorArguments; + if (args != null && args.Count > 0) return (TResult?)args[0].Value; + } + } + if (inherit && target is Type type && type.BaseType != null) + { + target = type.BaseType; + if (target != null && target != typeof(Object)) + return GetCustomAttributeValue(target, inherit); + } + } + catch + { + // 出错以后,如果不是仅反射加载,可以考虑正面来一次 + if (!target.Module.Assembly.ReflectionOnly) + { + //var att = GetCustomAttribute(target, inherit); + var att = target.GetCustomAttribute(inherit); + if (att != null) + { + var pi = typeof(TAttribute).GetProperties().FirstOrDefault(p => p.PropertyType == typeof(TResult)); + if (pi != null) return (TResult?)att.GetValue(pi); + } + } + } + + return default; + } + + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Reflection/DynamicInternal.cs b/src/Admin/ThingsGateway.NewLife.X/Reflection/DynamicInternal.cs new file mode 100644 index 000000000..3c2a4a93b --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Reflection/DynamicInternal.cs @@ -0,0 +1,72 @@ +using System.Dynamic; +using System.Globalization; +using System.Reflection; + +namespace ThingsGateway.NewLife.Reflection; + +/// 包装程序集内部类的动态对象 +public class DynamicInternal : DynamicObject +{ + private Object? Real { get; set; } + + /// 类型转换 + /// + /// + /// + public override Boolean TryConvert(ConvertBinder binder, out Object? result) + { + result = Real; + + return true; + } + + /// 成员取值 + /// + /// + /// + public override Boolean TryGetMember(GetMemberBinder binder, out Object? result) + { + if (Real == null) throw new ArgumentNullException(nameof(Real)); + + var property = Real.GetType().GetProperty(binder.Name, BindingFlags.GetProperty | BindingFlags.Public | BindingFlags.Instance); + if (property == null) + { + result = null; + } + else + { + result = property.GetValue(Real, null); + if (result != null) result = Wrap(result); + } + return true; + } + + /// 调用成员 + /// + /// + /// + /// + public override Boolean TryInvokeMember(InvokeMemberBinder binder, Object?[]? args, out Object? result) + { + if (Real == null) throw new ArgumentNullException(nameof(Real)); + + result = Real.GetType().InvokeMember(binder.Name, BindingFlags.InvokeMethod | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance, null, Real, args, CultureInfo.InvariantCulture); + + return true; + } + + /// 包装 + /// + /// + public static Object Wrap(Object obj) + { + //if (obj == null) return null; + if (obj.GetType().IsPublic) return obj; + + return new DynamicInternal { Real = obj }; + } + + /// 已重载。 + /// + public override String ToString() => Real?.ToString() ?? nameof(DynamicInternal); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Reflection/DynamicXml.cs b/src/Admin/ThingsGateway.NewLife.X/Reflection/DynamicXml.cs new file mode 100644 index 000000000..50638d2b7 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Reflection/DynamicXml.cs @@ -0,0 +1,79 @@ +using System.Dynamic; +using System.Xml.Linq; + +namespace ThingsGateway.NewLife.Reflection; + +/// 动态Xml +public class DynamicXml : DynamicObject +{ + /// 节点 + public XElement? Node { get; set; } + + /// 实例化 + public DynamicXml() { } + + /// 实例化 + /// + public DynamicXml(XElement node) => Node = node; + + /// 实例化 + /// + public DynamicXml(String name) => Node = new XElement(name); + + /// 设置 + /// + /// + /// + public override Boolean TrySetMember(SetMemberBinder binder, Object? value) + { + if (Node == null) throw new ArgumentNullException(nameof(Node)); + if (value == null) throw new ArgumentNullException(nameof(value)); + + var setNode = Node.Element(binder.Name); + if (setNode != null) + setNode.SetValue(value); + else + { + if (value.GetType() == typeof(DynamicXml)) + Node.Add(new XElement(binder.Name)); + else + Node.Add(new XElement(binder.Name, value)); + } + + return true; + } + + /// 获取 + /// + /// + /// + public override Boolean TryGetMember(GetMemberBinder binder, out Object? result) + { + if (Node == null) throw new ArgumentNullException(nameof(Node)); + + result = null; + var getNode = Node.Element(binder.Name); + if (getNode == null) return false; + + result = new DynamicXml(getNode); + return true; + } + +#if DEBUG + // 2019-03-10合并为混合项目后一下编译不通过,注释掉 + ///// 测试 + //public static void Test() + //{ + // dynamic xml = new DynamicXml("Test"); + // xml.Name = "ThingsGateway.NewLife"; + // xml.Sign = "学无先后达者为师!"; + // xml.Detail = new DynamicXml(); + // xml.Detail.Name = "新生命开发团队"; + // xml.Detail.CreateTime = new DateTime(2002, 12, 31); + + // var node = xml.Node as XElement; + // var str = node.ToString(); + // Console.WriteLine(str); + //} +#endif +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Reflection/IIndexAccessor.cs b/src/Admin/ThingsGateway.NewLife.X/Reflection/IIndexAccessor.cs new file mode 100644 index 000000000..620701e9a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Reflection/IIndexAccessor.cs @@ -0,0 +1,15 @@ +namespace ThingsGateway.NewLife.Reflection +{ + /// + /// 索引器接访问口。 + /// 该接口用于通过名称快速访问对象属性或字段(属性优先)。 + /// + //[Obsolete("=>IIndex")] + public interface IIndexAccessor + { + /// 获取/设置 指定名称的属性或字段的值 + /// 名称 + /// + Object this[String name] { get; set; } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Reflection/IReflect.cs b/src/Admin/ThingsGateway.NewLife.X/Reflection/IReflect.cs new file mode 100644 index 000000000..b5e5ab9a2 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Reflection/IReflect.cs @@ -0,0 +1,880 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Reflection; +using System.Runtime.Serialization; +using System.Web.Script.Serialization; +using System.Xml.Serialization; + +namespace ThingsGateway.NewLife.Reflection; + +/// 反射接口 +/// 该接口仅用于扩展,不建议外部使用 +[EditorBrowsable(EditorBrowsableState.Advanced)] +public interface IReflect +{ + #region 反射获取 + /// 根据名称获取类型 + /// 类型名 + /// 是否从未加载程序集中获取类型。使用仅反射的方法检查目标类型,如果存在,则进行常规加载 + /// + Type? GetType(String typeName, Boolean isLoadAssembly); + + /// 获取方法 + /// 用于具有多个签名的同名方法的场合,不确定是否存在性能问题,不建议普通场合使用 + /// 类型 + /// 名称 + /// 参数类型数组 + /// + MethodInfo? GetMethod(Type type, String name, params Type[] paramTypes); + + /// 获取指定名称的方法集合,支持指定参数个数来匹配过滤 + /// + /// + /// 参数个数,-1表示不过滤参数个数 + /// + MethodInfo[] GetMethods(Type type, String name, Int32 paramCount = -1); + + /// 获取属性 + /// 类型 + /// 名称 + /// 忽略大小写 + /// + PropertyInfo? GetProperty(Type type, String name, Boolean ignoreCase); + + /// 获取字段 + /// 类型 + /// 名称 + /// 忽略大小写 + /// + FieldInfo? GetField(Type type, String name, Boolean ignoreCase); + + /// 获取成员 + /// 类型 + /// 名称 + /// 忽略大小写 + /// + MemberInfo? GetMember(Type type, String name, Boolean ignoreCase); + + /// 获取字段 + /// + /// + /// + IList GetFields(Type type, Boolean baseFirst = true); + + /// 获取属性 + /// + /// + /// + IList GetProperties(Type type, Boolean baseFirst = true); + #endregion + + #region 反射调用 + /// 反射创建指定类型的实例 + /// 类型 + /// 参数数组 + /// + Object? CreateInstance(Type type, params Object?[] parameters); + + /// 反射调用指定对象的方法 + /// 要调用其方法的对象,如果要调用静态方法,则target是类型 + /// 方法 + /// 方法参数 + /// + Object? Invoke(Object? target, MethodBase method, params Object?[]? parameters); + + /// 反射调用指定对象的方法 + /// 要调用其方法的对象,如果要调用静态方法,则target是类型 + /// 方法 + /// 方法参数字典 + /// + Object? InvokeWithParams(Object? target, MethodBase method, IDictionary? parameters); + + /// 获取目标对象的属性值 + /// 目标对象 + /// 属性 + /// + Object? GetValue(Object? target, PropertyInfo property); + + /// 获取目标对象的字段值 + /// 目标对象 + /// 字段 + /// + Object? GetValue(Object? target, FieldInfo field); + + /// 设置目标对象的属性值 + /// 目标对象 + /// 属性 + /// 数值 + void SetValue(Object target, PropertyInfo property, Object? value); + + /// 设置目标对象的字段值 + /// 目标对象 + /// 字段 + /// 数值 + void SetValue(Object target, FieldInfo field, Object? value); + + /// 从源对象拷贝数据到目标对象 + /// 目标对象 + /// 源对象 + /// 递归深度拷贝,直接拷贝成员值而不是引用 + /// 要忽略的成员 + void Copy(Object target, Object src, Boolean deep = false, params String[] excludes); + + /// 从源字典拷贝数据到目标对象 + /// 目标对象 + /// 源字典 + /// 递归深度拷贝,直接拷贝成员值而不是引用 + void Copy(Object target, IDictionary dic, Boolean deep = false); + #endregion + + #region 类型辅助 + /// 获取一个类型的元素类型 + /// 类型 + /// + Type? GetElementType(Type type); + + /// 类型转换 + /// 数值 + /// + /// + Object? ChangeType(Object? value, Type conversionType); + + /// 获取类型的友好名称 + /// 指定类型 + /// 是否全名,包含命名空间 + /// + String GetName(Type type, Boolean isfull); + #endregion + + #region 插件 + /// 是否能够转为指定基类 + /// + /// + /// + Boolean As(Type type, Type baseType); + + /// 在指定程序集中查找指定基类或接口的所有子类实现 + /// 指定程序集 + /// 基类或接口,为空时返回所有类型 + /// + IEnumerable GetSubclasses(Assembly asm, Type baseType); + + /// 在所有程序集中查找指定基类或接口的子类实现 + /// 基类或接口 + /// + IEnumerable GetAllSubclasses(Type baseType); + + ///// 在所有程序集中查找指定基类或接口的子类实现 + ///// 基类或接口 + ///// 是否加载为加载程序集 + ///// + //IEnumerable GetAllSubclasses(Type baseType, Boolean isLoadAssembly); + #endregion +} + +/// 默认反射实现 +/// 该接口仅用于扩展,不建议外部使用 +[EditorBrowsable(EditorBrowsableState.Advanced)] +public class DefaultReflect : IReflect +{ + #region 反射获取 + /// 根据名称获取类型 + /// 类型名 + /// 是否从未加载程序集中获取类型。使用仅反射的方法检查目标类型,如果存在,则进行常规加载 + /// + public virtual Type? GetType(String typeName, Boolean isLoadAssembly) => AssemblyX.GetType(typeName, isLoadAssembly); + + private const BindingFlags bf = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + private const BindingFlags bfic = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance | BindingFlags.IgnoreCase; + + /// 获取方法 + /// 用于具有多个签名的同名方法的场合,不确定是否存在性能问题,不建议普通场合使用 + /// 类型 + /// 名称 + /// 参数类型数组 + /// + public virtual MethodInfo? GetMethod(Type type, String name, params Type[] paramTypes) + { + MethodInfo? mi = null; + while (true) + { + if (paramTypes == null || paramTypes.Length == 0) + mi = type.GetMethod(name, bf); + else + mi = type.GetMethod(name, bf, null, paramTypes, null); + if (mi != null) return mi; + + if (type.BaseType == null) break; + type = type.BaseType; + if (type == null || type == typeof(Object)) break; + } + return null; + } + + /// 获取指定名称的方法集合,支持指定参数个数来匹配过滤 + /// + /// + /// 参数个数,-1表示不过滤参数个数 + /// + public virtual MethodInfo[] GetMethods(Type type, String name, Int32 paramCount = -1) + { + var ms = type.GetMethods(bf); + //if (ms == null || ms.Length == 0) return ms; + + var list = new List(); + foreach (var item in ms) + { + if (item.Name == name) + { + if (paramCount >= 0 && item.GetParameters().Length == paramCount) list.Add(item); + } + } + return list.ToArray(); + } + + /// 获取属性 + /// 类型 + /// 名称 + /// 忽略大小写 + /// + public virtual PropertyInfo? GetProperty(Type type, String name, Boolean ignoreCase) + { + // 父类私有属性的获取需要递归,可见范围则不需要,有些类型的父类为空,比如接口 + var type2 = type; + while (type2 != null && type2 != typeof(Object)) + { + //var pi = type.GetProperty(name, ignoreCase ? bfic : bf); + var pi = type2.GetProperty(name, bf); + if (pi != null) return pi; + if (ignoreCase) + { + pi = type2.GetProperty(name, bfic); + if (pi != null) return pi; + } + + type2 = type2.BaseType; + } + return null; + } + + /// 获取字段 + /// 类型 + /// 名称 + /// 忽略大小写 + /// + public virtual FieldInfo? GetField(Type type, String name, Boolean ignoreCase) + { + // 父类私有字段的获取需要递归,可见范围则不需要,有些类型的父类为空,比如接口 + var type2 = type; + while (type2 != null && type2 != typeof(Object)) + { + //var fi = type.GetField(name, ignoreCase ? bfic : bf); + var fi = type2.GetField(name, bf); + if (fi != null) return fi; + if (ignoreCase) + { + fi = type2.GetField(name, bfic); + if (fi != null) return fi; + } + + type2 = type2.BaseType; + } + return null; + } + + /// 获取成员 + /// 类型 + /// 名称 + /// 忽略大小写 + /// + public virtual MemberInfo? GetMember(Type type, String name, Boolean ignoreCase) + { + // 父类私有成员的获取需要递归,可见范围则不需要,有些类型的父类为空,比如接口 + var type2 = type; + while (type2 != null && type2 != typeof(Object)) + { + var fs = type2.GetMember(name, ignoreCase ? bfic : bf); + if (fs != null && fs.Length > 0) + { + // 得到多个的时候,优先返回精确匹配 + if (ignoreCase && fs.Length > 1) + { + foreach (var fi in fs) + { + if (fi.Name == name) return fi; + } + } + return fs[0]; + } + + type2 = type2.BaseType; + } + return null; + } + #endregion + + #region 反射获取 字段/属性 + private readonly ConcurrentDictionary> _cache1 = new(); + private readonly ConcurrentDictionary> _cache2 = new(); + /// 获取字段 + /// + /// + /// + public virtual IList GetFields(Type type, Boolean baseFirst = true) + { + if (baseFirst) + return _cache1.GetOrAdd(type, key => GetFields2(key, true)); + else + return _cache2.GetOrAdd(type, key => GetFields2(key, false)); + } + + private List GetFields2(Type type, Boolean baseFirst) + { + var list = new List(); + + // Void*的基类就是null + if (type == typeof(Object) || type.BaseType == null) return list; + + if (baseFirst) list.AddRange(GetFields(type.BaseType)); + + var fis = type.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach (var fi in fis) + { + if (fi.GetCustomAttribute() != null) continue; + + list.Add(fi); + } + + if (!baseFirst) list.AddRange(GetFields(type.BaseType)); + + return list; + } + + private readonly ConcurrentDictionary> _cache3 = new(); + private readonly ConcurrentDictionary> _cache4 = new(); + /// 获取属性 + /// + /// + /// + public virtual IList GetProperties(Type type, Boolean baseFirst = true) + { + if (baseFirst) + return _cache3.GetOrAdd(type, key => GetProperties2(key, true)); + else + return _cache4.GetOrAdd(type, key => GetProperties2(key, false)); + } + + private List GetProperties2(Type type, Boolean baseFirst) + { + var list = new List(); + + // Void*的基类就是null + if (type == typeof(Object) || type.BaseType == null) return list; + + // 本身type.GetProperties就可以得到父类属性,只是不能保证父类属性在子类属性之前 + if (baseFirst) list.AddRange(GetProperties(type.BaseType)); + + // 父类子类可能因为继承而有重名的属性,此时以子类优先,否则反射父类属性会出错 + var set = new HashSet(list.Select(e => e.Name)); + + //var pis = type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var pis = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); + foreach (var pi in pis) + { + if (pi.GetIndexParameters().Length > 0) continue; + if (pi.GetCustomAttribute() != null) continue; + if (pi.GetCustomAttribute() != null) continue; + if (pi.GetCustomAttribute() != null) continue; + + if (!set.Contains(pi.Name)) + { + list.Add(pi); + set.Add(pi.Name); + } + } + + // 获取用于序列化的属性列表时,加上非公有的数据成员 + pis = type.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic); + foreach (var pi in pis) + { + if (pi.GetIndexParameters().Length > 0) continue; + if (pi.GetCustomAttribute() == null && pi.GetCustomAttribute() == null) continue; + + if (!set.Contains(pi.Name)) + { + list.Add(pi); + set.Add(pi.Name); + } + } + + if (!baseFirst) list.AddRange(GetProperties(type.BaseType).Where(e => !set.Contains(e.Name))); + + return list; + } + #endregion + + #region 反射调用 + /// 反射创建指定类型的实例 + /// 类型 + /// 参数数组 + /// + public virtual Object? CreateInstance(Type type, params Object?[] parameters) + { + try + { + var code = type.GetTypeCode(); + + // 列表 + if (code == TypeCode.Object && (type.As() || type.As(typeof(IList<>)))) + { + var type2 = type; + if (type2.IsInterface) + { + if (type2.IsGenericType) + type2 = typeof(List<>).MakeGenericType(type2.GetGenericArguments()); + else if (type2 == typeof(IList)) + type2 = typeof(List); + } + return Activator.CreateInstance(type2); + } + + // 字典 + if (code == TypeCode.Object && (type.As() || type.As(typeof(IDictionary<,>)))) + { + var type2 = type; + if (type2.IsInterface) + { + if (type2.IsGenericType) + type2 = typeof(Dictionary<,>).MakeGenericType(type2.GetGenericArguments()); + else if (type2 == typeof(IDictionary)) + type2 = typeof(Dictionary); + } + return Activator.CreateInstance(type2); + } + + if (parameters == null || parameters.Length == 0) + { + // 基元类型 + return code switch + { + //TypeCode.Empty or TypeCode.DBNull => null, + TypeCode.Boolean => false, + TypeCode.Char => '\0', + TypeCode.SByte => (SByte)0, + TypeCode.Byte => (Byte)0, + TypeCode.Int16 => (Int16)0, + TypeCode.UInt16 => (UInt16)0, + TypeCode.Int32 => 0, + TypeCode.UInt32 => 0U, + TypeCode.Int64 => 0L, + TypeCode.UInt64 => 0UL, + TypeCode.Single => 0F, + TypeCode.Double => 0D, + TypeCode.Decimal => 0M, + TypeCode.DateTime => DateTime.MinValue, + TypeCode.String => String.Empty, + _ => Activator.CreateInstance(type, true), + }; + } + else + return Activator.CreateInstance(type, parameters); + } + catch (Exception ex) + { + throw new Exception($"Fail to create object type={type.FullName} parameters={parameters?.Join()} {ex.GetTrue()?.Message}", ex); + } + } + + /// 反射调用指定对象的方法 + /// 要调用其方法的对象,如果要调用静态方法,则target是类型 + /// 方法 + /// 方法参数 + /// + public virtual Object? Invoke(Object? target, MethodBase method, Object?[]? parameters) => method.Invoke(target, parameters); + + /// 反射调用指定对象的方法 + /// 要调用其方法的对象,如果要调用静态方法,则target是类型 + /// 方法 + /// 方法参数字典 + /// + public virtual Object? InvokeWithParams(Object? target, MethodBase method, IDictionary? parameters) + { + // 该方法没有参数,无视外部传入参数 + var pis = method.GetParameters(); + if (pis == null || pis.Length == 0) return Invoke(target, method, null); + + var ps = new Object?[pis.Length]; + for (var i = 0; i < pis.Length; i++) + { + Object? v = null; + var name = pis[i].Name; + if (parameters != null && !name.IsNullOrEmpty() && parameters.Contains(name)) v = parameters[name]; + ps[i] = v.ChangeType(pis[i].ParameterType); + } + + return method.Invoke(target, ps); + } + + /// 获取目标对象的属性值 + /// 目标对象 + /// 属性 + /// + public virtual Object? GetValue(Object? target, PropertyInfo property) => property.GetValue(target, null); + + /// 获取目标对象的字段值 + /// 目标对象 + /// 字段 + /// + public virtual Object? GetValue(Object? target, FieldInfo field) => field.GetValue(target); + + /// 设置目标对象的属性值 + /// 目标对象 + /// 属性 + /// 数值 + public virtual void SetValue(Object target, PropertyInfo property, Object? value) => property.SetValue(target, value.ChangeType(property.PropertyType), null); + + /// 设置目标对象的字段值 + /// 目标对象 + /// 字段 + /// 数值 + public virtual void SetValue(Object target, FieldInfo field, Object? value) => field.SetValue(target, value.ChangeType(field.FieldType)); + #endregion + + #region 对象拷贝 + private static Dictionary> _properties = []; + /// 从源对象拷贝数据到目标对象 + /// 目标对象 + /// 源对象 + /// 递归深度拷贝,直接拷贝成员值而不是引用 + /// 要忽略的成员 + public virtual void Copy(Object target, Object source, Boolean deep = false, params String[] excludes) + { + if (target == null || source == null || target == source) return; + + var targetType = target.GetType(); + // 基础类型无法拷贝 + if (targetType.IsBaseType()) throw new XException("The base type {0} cannot be copied", targetType.FullName); + + var sourceType = source.GetType(); + if (!_properties.TryGetValue(sourceType, out var sourceProperties)) + _properties[sourceType] = sourceProperties = sourceType.GetProperties(true).ToDictionary(e => e.Name, e => e); + + // 不是深度拷贝时,直接复制引用 + if (!deep) + { + foreach (var pi in targetType.GetProperties(true)) + { + if (!pi.CanWrite) continue; + if (excludes != null && excludes.Contains(pi.Name)) continue; + + if (sourceProperties.TryGetValue(pi.Name, out var pi2) && pi2.CanRead) + SetValue(target, pi, GetValue(source, pi2)); + } + return; + } + + // 来源对象转为字典 + var dic = new Dictionary(); + foreach (var pi in sourceProperties.Values) + { + if (!pi.CanRead) continue; + if (excludes != null && excludes.Contains(pi.Name)) continue; + + dic[pi.Name] = GetValue(source, pi); + } + + Copy(target, dic, deep); + } + + /// 从源字典拷贝数据到目标对象 + /// 目标对象 + /// 源字典 + /// 递归深度拷贝,直接拷贝成员值而不是引用 + public virtual void Copy(Object target, IDictionary source, Boolean deep = false) + { + if (target == null || source == null || source.Count == 0 || target == source) return; + + foreach (var pi in target.GetType().GetProperties(true)) + { + if (!pi.CanWrite) continue; + + if (source.TryGetValue(pi.Name, out var obj)) + { + // 基础类型直接拷贝,不考虑深拷贝 + if (!deep || pi.PropertyType.IsBaseType()) + SetValue(target, pi, obj); + else + { + var v = GetValue(target, pi); + + // 如果目标对象该成员为空,需要创建再拷贝 + if (v == null) + { + v = pi.PropertyType.CreateInstance(); + SetValue(target, pi, v); + } + if (v != null && obj != null) Copy(v, obj, deep); + } + } + } + } + #endregion + + #region 类型辅助 + /// 获取一个类型的元素类型 + /// 类型 + /// + public virtual Type? GetElementType(Type type) + { + if (type.HasElementType) return type.GetElementType(); + + if (type.As()) + { + // 如果实现了IEnumerable<>接口,那么取泛型参数 + foreach (var item in type.GetInterfaces()) + { + if (item.IsGenericType && item.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + return item.GetGenericArguments()[0]; + } + //// 通过索引器猜测元素类型 + //var pi = type.GetProperty("Item", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + //if (pi != null) return pi.PropertyType; + } + + return null; + } + + /// 类型转换 + /// 数值 + /// + /// + public virtual Object? ChangeType(Object? value, Type conversionType) + { + // 值类型就是目标类型 + Type? vtype = null; + if (value != null) vtype = value.GetType(); + if (vtype == conversionType) return value; + + // 可空类型 + var utype = Nullable.GetUnderlyingType(conversionType); + if (utype != null) + { + if (value == null) return null; + + // 时间日期可空处理 + if (value is DateTime dt && dt == DateTime.MinValue) return null; + + conversionType = utype; + } + + var code = Type.GetTypeCode(conversionType); + //conversionType = Nullable.GetUnderlyingType(conversionType) ?? conversionType; + if (conversionType.IsEnum) + { + if (vtype == typeof(String)) + return Enum.Parse(conversionType, (String)(value ?? String.Empty), true); + else + return Enum.ToObject(conversionType, value ?? 0); + } + + // 字符串转为货币类型,处理一下 + if (vtype == typeof(String)) + { + var str = (String)(value ?? String.Empty); + if (code == TypeCode.Decimal) + { + value = str.TrimStart(['$', '¥']); + } + else if (conversionType.As()) + { + return GetType(str, false); + } + + // 字符串转为简单整型,如果长度比较小,满足32位整型要求,则先转为32位再改变类型 + if (code >= TypeCode.Int16 && code <= TypeCode.UInt64 && str.Length <= 10) + return Convert.ChangeType(value.ToLong(), conversionType); + } + + if (value != null) + { + // 尝试基础类型转换 + switch (code) + { + case TypeCode.Boolean: + return value.ToBoolean(); + case TypeCode.DateTime: + return value.ToDateTime(); + case TypeCode.Double: + return value.ToDouble(); + case TypeCode.Int16: + return (Int16)value.ToInt(); + case TypeCode.Int32: + return value.ToInt(); + case TypeCode.UInt16: + return (UInt16)value.ToInt(); + case TypeCode.UInt32: + return (UInt32)value.ToInt(); + default: + break; + } + + // 支持DateTimeOffset转换 + if (conversionType == typeof(DateTimeOffset)) return value.ToDateTimeOffset(); + + if (value is String str) + { + // 特殊处理几种类型,避免后续反射影响性能 + if (conversionType == typeof(Guid)) return Guid.Parse(str); + if (conversionType == typeof(TimeSpan)) return TimeSpan.Parse(str); +#if NET6_0_OR_GREATER + if (conversionType == typeof(IntPtr)) return IntPtr.Parse(str); + if (conversionType == typeof(UIntPtr)) return UIntPtr.Parse(str); + if (conversionType == typeof(Half)) return Half.Parse(str); +#endif +#if NET6_0_OR_GREATER + if (conversionType == typeof(DateOnly)) return DateOnly.Parse(str); + if (conversionType == typeof(TimeOnly)) return TimeOnly.Parse(str); +#endif + +#if NET8_0_OR_GREATER + // 支持IParsable接口 + if (conversionType.GetInterfaces().Any(e => e.IsGenericType && e.GetGenericTypeDefinition() == typeof(IParsable<>))) + { + var mi = conversionType.GetMethod("Parse", [typeof(String), typeof(IFormatProvider)]); + if (mi != null) return mi.Invoke(null, [value, null]); + } +#endif + } + + if (value is IConvertible) value = Convert.ChangeType(value, conversionType); + } + else + { + // 如果原始值是null,要转为值类型,则new一个空白的返回 + if (conversionType.IsValueType) value = CreateInstance(conversionType); + } + + if (conversionType.IsAssignableFrom(vtype)) return value; + + return value; + } + + /// 获取类型的友好名称 + /// 指定类型 + /// 是否全名,包含命名空间 + /// + public virtual String GetName(Type type, Boolean isfull) => isfull ? (type.FullName ?? type.Name) : type.Name; + #endregion + + #region 插件 + //private readonly ConcurrentDictionary> _as_cache = new ConcurrentDictionary>(); + /// 是否子类 + /// + /// + /// + public Boolean As(Type type, Type baseType) + { + if (type == null) return false; + if (type == baseType) return true; + + // 如果基类是泛型定义,补充完整,例如IList<> + if (baseType.IsGenericTypeDefinition + && type.IsGenericType && !type.IsGenericTypeDefinition + && baseType is TypeInfo inf && inf.GenericTypeParameters.Length == type.GenericTypeArguments.Length) + baseType = baseType.MakeGenericType(type.GenericTypeArguments); + + if (type == baseType) return true; + + if (baseType.IsAssignableFrom(type)) return true; + + //// 绝大部分子类判断可通过IsAssignableFrom完成,除非其中一方ReflectionOnly + //if (type.Assembly.ReflectionOnly == baseType.Assembly.ReflectionOnly) return false; + + // 缓存 + //var key = $"{type.FullName}_{baseType.FullName}"; + //if (!_as_cache.TryGetValue(type, out var dic)) + //{ + // dic = new ConcurrentDictionary(); + // _as_cache.TryAdd(type, dic); + //} + + //if (dic.TryGetValue(baseType, out var rs)) return rs; + var rs = false; + + //// 接口 + //if (baseType.IsInterface) + //{ + // if (type.GetInterface(baseType.FullName) != null) + // rs = true; + // else if (type.GetInterfaces().Any(e => e.IsGenericType && baseType.IsGenericTypeDefinition ? e.GetGenericTypeDefinition() == baseType : e == baseType)) + // rs = true; + //} + + //// 判断是否子类时,支持只反射加载的程序集 + //if (!rs && type.Assembly.ReflectionOnly) + //{ + // // 反射加载时,需要特殊处理接口 + // //if (baseType.IsInterface && type.GetInterface(baseType.Name) != null) return true; + // while (!rs && type != typeof(Object)) + // { + // if (type.FullName == baseType.FullName && + // type.AssemblyQualifiedName == baseType.AssemblyQualifiedName) + // rs = true; + // type = type.BaseType; + // } + //} + + //dic.TryAdd(baseType, rs); + + return rs; + } + + /// 在指定程序集中查找指定基类的子类 + /// 指定程序集 + /// 基类或接口,为空时返回所有类型 + /// + public virtual IEnumerable GetSubclasses(Assembly asm, Type baseType) + { + if (asm == null) throw new ArgumentNullException(nameof(asm)); + if (baseType == null) throw new ArgumentNullException(nameof(baseType)); + + var asmx = AssemblyX.Create(asm); + if (asmx == null) return Enumerable.Empty(); + + return asmx.FindPlugins(baseType); + } + + /// 在所有程序集中查找指定基类或接口的子类实现 + /// 基类或接口 + /// + public virtual IEnumerable GetAllSubclasses(Type baseType) + { + // 不支持isLoadAssembly + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + foreach (var type in GetSubclasses(asm, baseType)) + { + yield return type; + } + } + } + + ///// 在所有程序集中查找指定基类或接口的子类实现 + ///// 基类或接口 + ///// 是否加载为加载程序集 + ///// + //public virtual IEnumerable GetAllSubclasses(Type baseType, Boolean isLoadAssembly) + //{ + // //// 不支持isLoadAssembly + // //foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + // //{ + // // foreach (var type in GetSubclasses(asm, baseType)) + // // { + // // yield return type; + // // } + // //} + // return AssemblyX.FindAllPlugins(baseType, isLoadAssembly); + //} + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Reflection/Reflect.cs b/src/Admin/ThingsGateway.NewLife.X/Reflection/Reflect.cs new file mode 100644 index 000000000..8fbdd2bfc --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Reflection/Reflect.cs @@ -0,0 +1,577 @@ +using System.Collections; +using System.Diagnostics; +using System.Reflection; + +namespace ThingsGateway.NewLife.Reflection; + +/// 反射工具类 +/// +/// 文档 https://newlifex.com/core/reflect +/// +public static class Reflect +{ + #region 静态 + /// 当前反射提供者 + public static IReflect Provider { get; set; } + + static Reflect() => Provider = new DefaultReflect();// 如果需要使用快速反射,启用下面这一行//Provider = new EmitReflect(); + #endregion + + #region 反射获取 + /// 根据名称获取类型。可搜索当前目录DLL,自动加载 + /// 类型名 + /// + public static Type? GetTypeEx(this String typeName) + { + if (String.IsNullOrEmpty(typeName)) return null; + + var type = Type.GetType(typeName); + if (type != null) return type; + + return Provider.GetType(typeName, false); + } + + + /// 获取方法 + /// 用于具有多个签名的同名方法的场合,不确定是否存在性能问题,不建议普通场合使用 + /// 类型 + /// 名称 + /// 参数类型数组 + /// + public static MethodInfo? GetMethodEx(this Type type, String name, params Type[] paramTypes) + { + if (name.IsNullOrEmpty()) return null; + + // 如果其中一个类型参数为空,得用别的办法 + if (paramTypes.Length > 0 && paramTypes.Any(e => e == null)) return Provider.GetMethods(type, name, paramTypes.Length).FirstOrDefault(); + + return Provider.GetMethod(type, name, paramTypes); + } + + /// 获取指定名称的方法集合,支持指定参数个数来匹配过滤 + /// + /// + /// 参数个数,-1表示不过滤参数个数 + /// + public static MethodInfo[] GetMethodsEx(this Type type, String name, Int32 paramCount = -1) + { + if (name.IsNullOrEmpty()) return Array.Empty(); + + return Provider.GetMethods(type, name, paramCount); + } + + /// 获取属性。搜索私有、静态、基类,优先返回大小写精确匹配成员 + /// 类型 + /// 名称 + /// 忽略大小写 + /// + public static PropertyInfo? GetPropertyEx(this Type type, String name, Boolean ignoreCase = false) + { + if (String.IsNullOrEmpty(name)) return null; + + return Provider.GetProperty(type, name, ignoreCase); + } + + /// 获取字段。搜索私有、静态、基类,优先返回大小写精确匹配成员 + /// 类型 + /// 名称 + /// 忽略大小写 + /// + public static FieldInfo? GetFieldEx(this Type type, String name, Boolean ignoreCase = false) + { + if (String.IsNullOrEmpty(name)) return null; + + return Provider.GetField(type, name, ignoreCase); + } + + /// 获取成员。搜索私有、静态、基类,优先返回大小写精确匹配成员 + /// 类型 + /// 名称 + /// 忽略大小写 + /// + public static MemberInfo? GetMemberEx(this Type type, String name, Boolean ignoreCase = false) + { + if (String.IsNullOrEmpty(name)) return null; + + return Provider.GetMember(type, name, ignoreCase); + } + + /// 获取用于序列化的字段 + /// 过滤特性的字段 + /// + /// + /// + public static IList GetFields(this Type type, Boolean baseFirst) => Provider.GetFields(type, baseFirst); + + /// 获取用于序列化的属性 + /// 过滤特性的属性和索引器 + /// + /// + /// + public static IList GetProperties(this Type type, Boolean baseFirst) => Provider.GetProperties(type, baseFirst); + #endregion + + #region 反射调用 + /// 反射创建指定类型的实例 + /// 类型 + /// 参数数组 + /// + [DebuggerHidden] + public static Object? CreateInstance(this Type type, params Object?[] parameters) + { + if (type == null) throw new ArgumentNullException(nameof(type)); + + return Provider.CreateInstance(type, parameters); + } + + /// 反射调用指定对象的方法。target为类型时调用其静态方法 + /// 要调用其方法的对象,如果要调用静态方法,则target是类型 + /// 方法名 + /// 方法参数 + /// + public static Object? Invoke(this Object target, String name, params Object?[] parameters) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (String.IsNullOrEmpty(name)) throw new ArgumentNullException(nameof(name)); + + if (TryInvoke(target, name, out var value, parameters)) return value; + + var type = GetType(target); + throw new XException("Cannot find method named {1} in class {0}!", type, name); + } + + /// 反射调用指定对象的方法 + /// 要调用其方法的对象,如果要调用静态方法,则target是类型 + /// 方法名 + /// 数值 + /// 方法参数 + /// 反射调用是否成功 + public static Boolean TryInvoke(this Object target, String name, out Object? value, params Object?[] parameters) + { + value = null; + + if (String.IsNullOrEmpty(name)) return false; + + var type = GetType(target); + + // 参数类型数组 + var ps = parameters.Select(e => e?.GetType()).ToArray(); + + // 如果参数数组出现null,则无法精确匹配,可按参数个数进行匹配 + var method = ps.Any(e => e == null) ? GetMethodEx(type, name) : GetMethodEx(type, name, ps!); + method ??= GetMethodsEx(type, name, ps.Length > 0 ? ps.Length : -1).FirstOrDefault(); + if (method == null) return false; + + value = Invoke(target, method, parameters); + + return true; + } + + /// 反射调用指定对象的方法 + /// 要调用其方法的对象,如果要调用静态方法,则target是类型 + /// 方法 + /// 方法参数 + /// + [DebuggerHidden] + public static Object? Invoke(this Object? target, MethodBase method, params Object?[]? parameters) + { + //if (target == null) throw new ArgumentNullException("target"); + if (method == null) throw new ArgumentNullException(nameof(method)); + if (!method.IsStatic && target == null) throw new ArgumentNullException(nameof(target)); + + return Provider.Invoke(target, method, parameters); + } + + /// 反射调用指定对象的方法 + /// 要调用其方法的对象,如果要调用静态方法,则target是类型 + /// 方法 + /// 方法参数字典 + /// + [DebuggerHidden] + public static Object? InvokeWithParams(this Object? target, MethodBase method, IDictionary? parameters) + { + //if (target == null) throw new ArgumentNullException("target"); + if (method == null) throw new ArgumentNullException(nameof(method)); + if (!method.IsStatic && target == null) throw new ArgumentNullException(nameof(target)); + + return Provider.InvokeWithParams(target, method, parameters); + } + + /// 获取目标对象指定名称的属性/字段值 + /// 目标对象 + /// 名称 + /// 出错时是否抛出异常 + /// + [DebuggerHidden] + public static Object? GetValue(this Object target, String name, Boolean throwOnError = true) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (String.IsNullOrEmpty(name)) throw new ArgumentNullException(nameof(name)); + + if (TryGetValue(target, name, out var value)) return value; + + if (!throwOnError) return null; + + var type = GetType(target); + throw new ArgumentException($"The [{name}] property or field does not exist in class [{type.FullName}]."); + } + + /// 获取目标对象指定名称的属性/字段值 + /// 目标对象 + /// 名称 + /// 数值 + /// 是否成功获取数值 + internal static Boolean TryGetValue(this Object target, String name, out Object? value) + { + value = null; + + if (String.IsNullOrEmpty(name)) return false; + + var type = GetType(target); + + var mi = type.GetMemberEx(name, true); + if (mi == null) return false; + + value = target.GetValue(mi); + + return true; + } + + /// 获取目标对象的成员值 + /// 目标对象 + /// 成员 + /// + [DebuggerHidden] + public static Object? GetValue(this Object? target, MemberInfo member) + { + // 有可能跟普通的 PropertyInfo.GetValue(Object target) 搞混了 + if (member == null && target is MemberInfo mi) + { + member = mi; + target = null; + } + + //if (target is IModel model && member is PropertyInfo) return model[member.Name]; + + if (member is PropertyInfo property) + return Provider.GetValue(target, property); + else if (member is FieldInfo field) + return Provider.GetValue(target, field); + else + throw new ArgumentOutOfRangeException(nameof(member)); + } + + /// 设置目标对象指定名称的属性/字段值,若不存在返回false + /// 目标对象 + /// 名称 + /// 数值 + /// 反射调用是否成功 + [DebuggerHidden] + public static Boolean SetValue(this Object target, String name, Object? value) + { + if (String.IsNullOrEmpty(name)) return false; + + //// 借助 IModel 优化取值赋值,有 IExtend 扩展属性的实体类过于复杂而不支持,例如IEntity就有脏数据问题 + //if (target is IModel model && target is not IExtend) + //{ + // model[name] = value; + // return true; + //} + + var type = GetType(target); + + var mi = type.GetMemberEx(name, true); + if (mi == null) return false; + + target.SetValue(mi, value); + + //throw new ArgumentException("The [{name}] property or field does not exist in class [{type.FullName}]."); + return true; + } + + /// 设置目标对象的成员值 + /// 目标对象 + /// 成员 + /// 数值 + [DebuggerHidden] + public static void SetValue(this Object target, MemberInfo member, Object? value) + { + //// 借助 IModel 优化取值赋值,有 IExtend 扩展属性的实体类过于复杂而不支持,例如IEntity就有脏数据问题 + //if (target is IModel model && target is not IExtend && member is PropertyInfo) + // model[member.Name] = value; + //else + if (member is PropertyInfo pi) + Provider.SetValue(target, pi, value); + else if (member is FieldInfo fi) + Provider.SetValue(target, fi, value); + else + throw new ArgumentOutOfRangeException(nameof(member)); + } + + /// 从源对象拷贝数据到目标对象 + /// 目标对象 + /// 源对象 + /// 递归深度拷贝,直接拷贝成员值而不是引用 + /// 要忽略的成员 + public static void Copy(this Object target, Object src, Boolean deep = false, params String[] excludes) => Provider.Copy(target, src, deep, excludes); + + /// 从源字典拷贝数据到目标对象 + /// 目标对象 + /// 源字典 + /// 递归深度拷贝,直接拷贝成员值而不是引用 + public static void Copy(this Object target, IDictionary dic, Boolean deep = false) => Provider.Copy(target, dic, deep); + #endregion + + #region 类型辅助 + /// 获取一个类型的元素类型 + /// 类型 + /// + public static Type? GetElementTypeEx(this Type type) => Provider.GetElementType(type); + + /// 类型转换 + /// 数值 + /// + /// + public static Object? ChangeType(this Object? value, Type conversionType) => Provider.ChangeType(value, conversionType); + + /// 类型转换 + /// + /// 数值 + /// + public static TResult? ChangeType(this Object? value) + { + if (value == null && typeof(TResult).IsValueType) return default; + if (value == null && typeof(TResult).IsNullable()) return (TResult?)(Object?)null; + if (value is TResult result) return result; + + return (TResult?)ChangeType(value, typeof(TResult)); + } + +#if NET8_0_OR_GREATER + /// 类型转换 + /// + /// 数值 + /// + public static TResult? ChangeType(this String value) where TResult : IParsable + { + if (value is TResult result) return result; + + // 支持IParsable接口 + if (TResult.TryParse(value, null, out var rs)) return rs; + + return default; + } +#endif + + /// 获取类型的友好名称 + /// 指定类型 + /// 是否全名,包含命名空间 + /// + public static String GetName(this Type type, Boolean isfull = false) => Provider.GetName(type, isfull); + + /// 从参数数组中获取类型数组 + /// + /// + public static Type[] GetTypeArray(this Object?[]? args) + { + if (args == null) return Type.EmptyTypes; + + var typeArray = new Type[args.Length]; + for (var i = 0; i < typeArray.Length; i++) + { + var arg = args[i]; + if (arg == null) + typeArray[i] = typeof(Object); + else + typeArray[i] = arg.GetType(); + } + return typeArray; + } + + /// 获取成员的类型,字段和属性是它们的类型,方法是返回类型,类型是自身 + /// + /// + public static Type? GetMemberType(this MemberInfo member) + { + //return member.MemberType switch + //{ + // MemberTypes.Constructor => (member as ConstructorInfo).DeclaringType, + // MemberTypes.Field => (member as FieldInfo).FieldType, + // MemberTypes.Method => (member as MethodInfo).ReturnType, + // MemberTypes.Property => (member as PropertyInfo).PropertyType, + // MemberTypes.TypeInfo or MemberTypes.NestedType => member as Type, + // _ => null, + //}; + + if (member is ConstructorInfo ctor) return ctor.DeclaringType; + if (member is FieldInfo field) return field.FieldType; + if (member is MethodInfo method) return method.ReturnType; + if (member is PropertyInfo property) return property.PropertyType; + if (member is Type type) return type; + + return null; + } + + /// 获取类型代码,支持可空类型 + /// + /// + public static TypeCode GetTypeCode(this Type type) => Type.GetTypeCode(Nullable.GetUnderlyingType(type) ?? type); + + /// 是否基础类型。识别常见基元类型和String,支持可空类型 + /// + /// 基础类型可以方便的进行字符串转换,用于存储于传输。 + /// 在序列化时,基础类型作为原子数据不可再次拆分,而复杂类型则可以进一步拆分。 + /// 包括:Boolean/Char/SByte/Byte/Int16/UInt16/Int32/UInt32/Int64/UInt64/Single/Double/Decimal/DateTime/String/枚举,以及这些类型的可空类型 + /// + /// + /// + public static Boolean IsBaseType(this Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return Type.GetTypeCode(type) != TypeCode.Object; + } + + /// 是否可空类型。继承泛型定义Nullable的类型 + /// + /// + public static Boolean IsNullable(this Type type) => type.IsGenericType && !type.IsGenericTypeDefinition && type.GetGenericTypeDefinition() == typeof(Nullable<>); + + /// 是否整数。Byte/Int16/Int32/Int64/SByte/UInt16/UInt32/UInt64 + /// + /// + public static Boolean IsInt(this Type type) + { + var code = type.GetTypeCode(); + return code >= TypeCode.SByte && code <= TypeCode.UInt64; + + //return type.GetTypeCode() switch + //{ + // TypeCode.Empty => false, + // TypeCode.Object => false, + // TypeCode.DBNull => false, + // TypeCode.Boolean => false, + // TypeCode.Char => false, + // TypeCode.Byte or TypeCode.SByte => true, + // TypeCode.Int16 or TypeCode.Int32 or TypeCode.Int64 => true, + // TypeCode.UInt16 or TypeCode.UInt32 or TypeCode.UInt64 => true, + // TypeCode.Single or TypeCode.Double or TypeCode.Decimal => false, + // TypeCode.DateTime => false, + // TypeCode.String => false, + // _ => false, + //}; + } + + /// 是否数字类型。包括整数、小数、字节等 + /// + /// + public static Boolean IsNumber(this Type type) + { + var code = type.GetTypeCode(); + return code >= TypeCode.SByte && code <= TypeCode.Decimal; + + //return type.GetTypeCode() switch + //{ + // TypeCode.Empty => false, + // TypeCode.Object => false, + // TypeCode.DBNull => false, + // TypeCode.Boolean => false, + // TypeCode.Char => false, + // TypeCode.Byte or TypeCode.SByte => true, + // TypeCode.Int16 or TypeCode.Int32 or TypeCode.Int64 => true, + // TypeCode.UInt16 or TypeCode.UInt32 or TypeCode.UInt64 => true, + // TypeCode.Single or TypeCode.Double or TypeCode.Decimal => true, + // TypeCode.DateTime => false, + // TypeCode.String => false, + // _ => false, + //}; + } + + /// 是否泛型列表 + /// + /// + public static Boolean IsList(this Type type) => type != null && type.IsGenericType && type.As(typeof(IList<>)); + + /// 是否泛型字典 + /// + /// + public static Boolean IsDictionary(this Type type) => type != null && type.IsGenericType && type.As(typeof(IDictionary<,>)); + #endregion + + #region 插件 + /// 是否能够转为指定基类 + /// + /// + /// + public static Boolean As(this Type type, Type baseType) => Provider.As(type, baseType); + + /// 是否能够转为指定基类 + /// + /// + /// + public static Boolean As(this Type type) => Provider.As(type, typeof(T)); + + /// 在指定程序集中查找指定基类的子类 + /// 指定程序集 + /// 基类或接口 + /// + public static IEnumerable GetSubclasses(this Assembly asm, Type baseType) => Provider.GetSubclasses(asm, baseType); + + /// 在所有程序集中查找指定基类或接口的子类实现 + /// 基类或接口 + /// + public static IEnumerable GetAllSubclasses(this Type baseType) => Provider.GetAllSubclasses(baseType); + + ///// 在所有程序集中查找指定基类或接口的子类实现 + ///// 基类或接口 + ///// 是否加载为加载程序集 + ///// + //[Obsolete] + //public static IEnumerable GetAllSubclasses(this Type baseType, Boolean isLoadAssembly) => Provider.GetAllSubclasses(baseType, isLoadAssembly); + #endregion + + #region 辅助方法 + /// 获取类型,如果target是Type类型,则表示要反射的是静态成员 + /// 目标对象 + /// + private static Type GetType(Object target) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + + var type = target as Type; + if (type == null) + type = target.GetType(); + //else + // target = null; + + return type; + } + + ///// 判断某个类型是否可空类型 + ///// 类型 + ///// + //static Boolean IsNullable(Type type) + //{ + // //if (type.IsValueType) return false; + + // if (type.IsGenericType && !type.IsGenericTypeDefinition && + // Object.ReferenceEquals(type.GetGenericTypeDefinition(), typeof(Nullable<>))) return true; + + // return false; + //} + + /// 把一个方法转为泛型委托,便于快速反射调用 + /// + /// + /// + /// + public static TFunc? As(this MethodInfo method, Object? target = null) + { + if (method == null) return default; + + if (target == null) + return (TFunc?)(Object?)Delegate.CreateDelegate(typeof(TFunc), method, true); + else + return (TFunc?)(Object?)Delegate.CreateDelegate(typeof(TFunc), target, method, true); + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Reflection/ScriptEngine.cs b/src/Admin/ThingsGateway.NewLife.X/Reflection/ScriptEngine.cs new file mode 100644 index 000000000..dacf9e5bc --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Reflection/ScriptEngine.cs @@ -0,0 +1,536 @@ +#if WIN +using System; +using System.CodeDom.Compiler; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Reflection; +using System.Reflection.Emit; +using System.Text; + +using ThingsGateway.NewLife.Collections; +using ThingsGateway.NewLife.Log; + +namespace ThingsGateway.NewLife.Reflection; + +/// 脚本引擎 +/// +/// 文档 https://newlifex.com/core/script_engine +/// +/// 三大用法: +/// 1,单个表达式,根据参数计算表达式结果并返回 +/// 2,多个语句,最后有返回语句 +/// 3,多个方法,有一个名为Execute的静态方法作为入口方法 +/// +/// 脚本引擎禁止实例化,必须通过方法创建,以代码为键进行缓存,避免重复创建反复编译形成泄漏。 +/// 其中方法的第二个参数为true表示前两种用法,为false表示第三种用法。 +/// +/// +/// 最简单而完整的用法: +/// +/// // 根据代码创建脚本实例,相同代码只编译一次 +/// var se = ScriptEngine.Create("a+b"); +/// // 如果Method为空说明未编译,可设置参数 +/// if (se.Method == null) +/// { +/// se.Parameters.Add("a", typeof(Int32)); +/// se.Parameters.Add("b", typeof(Int32)); +/// } +/// // 脚本固定返回Object类型,需要自己转换 +/// var n = (Int32)se.Invoke(2, 3); +/// Console.WriteLine("2+3={0}", n); +/// +/// +/// 无参数快速调用: +/// +/// var n = (Int32)ScriptEngine.Execute("2*3"); +/// +/// +/// 约定参数快速调用: +/// +/// var n = (Int32)ScriptEngine.Execute("p0*p1", new Object[] { 2, 3 }); +/// Console.WriteLine("2*3={0}", n); +/// +/// +public class ScriptEngine +{ + #region 属性 + /// 代码 + public String? Code { get; private set; } + + /// 是否表达式 + public Boolean IsExpression { get; set; } + + /// 参数集合。编译后就不可修改。 + public IDictionary Parameters { get; } = new Dictionary(); + + /// 最终代码 + public String? FinalCode { get; private set; } + + /// 编译得到的类型 + public Type? Type { get; private set; } + + /// 根据代码编译出来可供直接调用的入口方法,Eval/Main + public MethodInfo? Method { get; private set; } + + /// 命名空间集合 + public StringCollection NameSpaces { get; set; } = new StringCollection{ + "System", + "System.Collections", + "System.Diagnostics", + "System.Reflection", + "System.Text", + "System.Linq", + "System.IO"}; + + /// 引用程序集集合 + public StringCollection ReferencedAssemblies { get; set; } = new StringCollection(); + + /// 日志 + public ILog? Log { get; set; } + + /// 工作目录。执行时,将会作为环境变量的当前目录和PathHelper目录,执行后还原 + public String? WorkingDirectory { get; set; } + #endregion + + #region 创建 + static ScriptEngine() + { + // 考虑到某些要引用的程序集在别的目录 + AppDomain.CurrentDomain.AssemblyResolve += CurrentDomain_AssemblyResolve; + } + + /// 构造函数私有,禁止外部越过Create方法直接创建实例 + /// 代码片段 + /// 是否表达式,表达式将编译成为一个Main方法 + private ScriptEngine(String code, Boolean isExpression) + { + Code = code; + IsExpression = isExpression; + } + + private static readonly ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + /// 为指定代码片段创建脚本引擎实例。采用缓存,避免同一脚本重复创建引擎。 + /// 代码片段 + /// 是否表达式,表达式将编译成为一个Main方法 + /// + public static ScriptEngine Create(String code, Boolean isExpression = true) + { + if (String.IsNullOrEmpty(code)) throw new ArgumentNullException(nameof(code)); + + var key = code + isExpression; + return _cache.GetOrAdd(key, k => new ScriptEngine(code, isExpression)); + } + #endregion + + #region 快速静态方法 + /// 执行表达式,返回结果 + /// 代码片段 + /// + public static Object? Execute(String code) => Create(code).Invoke(); + + /// 执行表达式,返回结果 + /// 代码片段 + /// 参数名称 + /// 参数类型 + /// 参数值 + /// + public static Object? Execute(String code, String[] names, Type[] parameterTypes, Object[] parameters) + { + var se = Create(code); + if (/*se != null &&*/ se.Method != null) return se.Invoke(parameters); + + if (names != null) + { + var dic = se.Parameters; + for (var i = 0; i < names.Length; i++) + { + dic.Add(names[i], parameterTypes[i]); + } + } + + return se.Invoke(parameters); + } + + /// 执行表达式,返回结果 + /// 代码片段 + /// 参数名值对 + /// + public static Object? Execute(String code, IDictionary parameters) + { + if (parameters == null || parameters.Count <= 0) return Execute(code); + + var ps = parameters.Values.ToArray(); + + var se = Create(code); + if (/*se != null &&*/ se.Method != null) return se.Invoke(ps); + + var names = parameters.Keys.ToArray(); + var types = ps.GetTypeArray(); + + var dic = se.Parameters; + for (var i = 0; i < names.Length; i++) + { + dic.Add(names[i], types[i]); + } + + return se.Invoke(ps); + } + + /// 执行表达式,返回结果。参数名默认为p0/p1/p2/pn + /// + /// 参数数组 + /// + public static Object? Execute(String code, Object[] parameters) + { + if (parameters == null || parameters.Length <= 0) return Execute(code); + + var se = Create(code); + if (/*se != null &&*/ se.Method != null) return se.Invoke(parameters); + + var names = new String[parameters.Length]; + for (var i = 0; i < names.Length; i++) + { + names[i] = "p" + i; + } + var types = parameters.GetTypeArray(); + + var dic = se.Parameters; + for (var i = 0; i < names.Length; i++) + { + dic.Add(names[i], types[i]); + } + + return se.Invoke(parameters); + } + #endregion + + #region 动态编译 + /// 生成代码。根据完善得到最终代码 + [MemberNotNull(nameof(FinalCode))] + public void GenerateCode() => FinalCode = GetFullCode(); + + /// 获取完整源代码 + /// + public String GetFullCode() + { + if (Code.IsNullOrEmpty()) throw new ArgumentNullException("Code"); + + // 预处理代码 + var code = Code.Trim(); + // 把命名空间提取出来 + code = ParseNameSpace(code); + + // 表达式需要构造一个语句 + if (IsExpression) + { + // 如果代码不含有reutrn关键字,则在最前面加上,因为可能是简单表达式 + if (!code.Contains("return ")) code = "return " + code; + if (!code.EndsWith(';')) code += ";"; + + var sb = new StringBuilder(64 + code.Length); + sb.Append("\t\tpublic static Object Eval("); + // 参数 + var isfirst = false; + foreach (var item in Parameters) + { + if (isfirst) + sb.Append(", "); + else + isfirst = true; + + sb.AppendFormat("{0} {1}", item.Value.FullName, item.Key); + } + sb.AppendLine(")"); + sb.AppendLine("\t\t{"); + sb.Append("\t\t\t"); + sb.AppendLine(code); + sb.AppendLine("\t\t}"); + + code = sb.ToString(); + } + //else if (!code.Contains("static void Main(")) + // 这里也许用正则判断会更好一些 + else if (!code.Contains(" Main(") && !code.Contains(" class ")) + { + // 单行才考虑加分号,多行可能有 #line 指令开头 + if (!code.Contains(Environment.NewLine)) + { + // 如果不是;和}结尾,则增加分号 + var last = code[^1]; + if (last is not ';' and not '}') code += ";"; + } + code = $"\t\tstatic void Main()\r\n\t\t{{\r\n\t\t\t{code}\r\n\t\t}}"; + } + + // 没有命名空间,包含一个 + if (!code.Contains("namespace ")) + { + // 没有类名,包含一个 + if (!code.Contains("class ")) + { + code = $"\tpublic class {GetType().Name}\r\n\t{{\r\n{code}\r\n\t}}"; + } + + code = $"namespace {GetType().Namespace}\r\n{{\r\n{code}\r\n}}"; + } + + // 命名空间 + if (NameSpaces.Count > 0) + { + var sb = new StringBuilder(code.Length + NameSpaces.Count * 20); + foreach (var item in NameSpaces) + { + sb.AppendFormat("using {0};\r\n", item); + } + sb.AppendLine(); + sb.Append(code); + + code = sb.ToString(); + } + + return code; + } + + /// 编译 + public void Compile() + { + if (Method != null) return; + lock (Parameters) + { + if (Method != null) return; + + if (FinalCode == null) GenerateCode(); + + var rs = Compile(FinalCode, null); + if (rs.Errors == null || !rs.Errors.HasErrors) + { + // 加载外部程序集 + foreach (var item in ReferencedAssemblies) + { + if (item.IsNullOrEmpty()) continue; + + try + { + //Assembly.LoadFrom(item); + // 先加载到内存,再加载程序集,避免文件被锁定 + Assembly.Load(File.ReadAllBytes(item)); + WriteLog("加载外部程序集:{0}", item); + } + catch { } + } + + try + { + Type = rs.CompiledAssembly.GetTypes()[0]; + var name = IsExpression ? "Eval" : "Main"; + Method = Type.GetMethod(name, BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); + } + catch (ReflectionTypeLoadException ex) + { + var le = ex.LoaderExceptions?.FirstOrDefault(); + if (le != null) + throw le; + else + throw; + } + } + else + { + var err = rs.Errors[0]; + + // 异常中输出错误代码行 + var code = ""; + if (!err.FileName.IsNullOrEmpty() && File.Exists(err.FileName)) + { + code = File.ReadAllLines(err.FileName)[err.Line - 1]; + } + else + { + var ss = FinalCode.Split(new[] { Environment.NewLine }, StringSplitOptions.None); + if (err.Line > 0 && err.Line <= ss.Length) code = ss[err.Line - 1].Trim(); + } + + throw new XException("{0} {1} {2}({3},{4}) {5}", err.ErrorNumber, err.ErrorText, err.FileName, err.Line, err.Column, code); + } + } + } + + /// 编译 + /// + /// + /// + public CompilerResults Compile(String classCode, CompilerParameters? options) + { + options ??= new CompilerParameters + { + GenerateInMemory = true, + GenerateExecutable = !IsExpression + }; + + var hs = new HashSet(StringComparer.OrdinalIgnoreCase); + // 同名程序集只引入一个 + var fs = new HashSet(StringComparer.OrdinalIgnoreCase); + // 优先考虑外部引入的程序集 + foreach (var item in ReferencedAssemblies) + { + if (String.IsNullOrEmpty(item)) continue; + if (hs.Contains(item)) continue; + var name = Path.GetFileName(item); + if (fs.Contains(name)) continue; + + hs.Add(item); + fs.Add(name); + + if (!options.ReferencedAssemblies.Contains(item)) options.ReferencedAssemblies.Add(item); + } + foreach (var item in AppDomain.CurrentDomain.GetAssemblies()) + { + if (item is AssemblyBuilder) continue; + + var name = item.GetName()?.Name; + if (name == null) continue; + + // 三趾树獭 303409914 发现重复加载同一个DLL,表现为Web站点Bin目录有一个,系统缓存有一个 + // 相同程序集不同版本,全名不想等 + if (hs.Contains(name)) continue; + hs.Add(name); + + //String name = null; + try + { + name = item.Location; + } + catch { } + if (String.IsNullOrEmpty(name)) continue; + + var fname = Path.GetFileName(name); + if (fs.Contains(fname)) continue; + fs.Add(fname); + + if (!options.ReferencedAssemblies.Contains(name)) options.ReferencedAssemblies.Add(name); + } + + // 最高仅支持C# 5.0 + /* + * Microsoft (R) Visual C# Compiler version 4.6.1590.0 for C# 5 + * Copyright (C) Microsoft Corporation. All rights reserved. + * + * This compiler is provided as part of the Microsoft (R) .NET Framework, + * but only supports language versions up to C# 5, which is no longer the latest version. + * For compilers that support newer versions of the C# programming language, see http://go.microsoft.com/fwlink/?LinkID=533240 + */ + var opts = new Dictionary(); + //opts["CompilerVersion"] = "v6.0"; + // 开发者机器有C# 6.0编译器 + var pro = Environment.GetEnvironmentVariable("ProgramFiles(x86)"); + if (!pro.IsNullOrEmpty() && Directory.Exists(pro)) + { + var msbuild = pro.CombinePath(@"MSBuild\14.0\bin"); + if (File.Exists(msbuild.CombinePath("csc.exe"))) opts["CompilerDirectoryPath"] = msbuild; + } + using var provider = CodeDomProvider.CreateProvider("CSharp", opts); + //var provider = CodeDomProvider.CreateProvider("CSharp"); + return provider.CompileAssemblyFromSource(options, classCode); + } + #endregion + + #region 执行方法 + /// 按照传入参数执行代码 + /// 参数 + /// 结果 + public Object? Invoke(params Object?[] parameters) + { + if (Method == null) Compile(); + if (Method == null) throw new XException("脚本引擎未编译表达式!"); + // Main函数可能含有参数string[] args + if (parameters == null || parameters.Length == 0) + { + var ms = Method.GetParameters(); + if (Method.Name.EqualIgnoreCase("Main") && ms.Length == 1 && ms[0].ParameterType == typeof(String[])) + { + parameters = new Object[] { new String[] { "" } }; + } + } + + // 处理工作目录 + var flag = false; + var _cur = Environment.CurrentDirectory; + var _my = PathHelper.BasePath; + if (!WorkingDirectory.IsNullOrEmpty()) + { + flag = true; + Environment.CurrentDirectory = WorkingDirectory; + PathHelper.BasePath = WorkingDirectory; + } + + try + { + return "".Invoke(Method, parameters); + } + finally + { + if (flag) + { + Environment.CurrentDirectory = _cur; + PathHelper.BasePath = _my; + } + } + } + #endregion + + #region 辅助 + /// 分析命名空间 + /// + /// + private String ParseNameSpace(String code) + { + var sb = new StringBuilder(); + + var ss = code.Split(new String[] { Environment.NewLine }, StringSplitOptions.None); + foreach (var item in ss) + { + // 提取命名空间 + if (!String.IsNullOrEmpty(item)) + { + var line = item.Trim(); + if (line.StartsWith("using ") && line.EndsWith(';')) + { + var len = "using ".Length; + line = line.Substring(len, line.Length - len - 1); + if (!NameSpaces.Contains(line)) NameSpaces.Add(line); + // 不能截断命名空间,否则报错行号会出错 + sb.AppendLine(); + continue; + } + } + + sb.AppendLine(item); + } + + return sb.ToString().Trim(); + } + + private void WriteLog(String format, params Object?[] args) + { + Log?.Info(format, args); + } + + private static Assembly? CurrentDomain_AssemblyResolve(Object? sender, ResolveEventArgs args) + { + var name = args.Name; + if (String.IsNullOrEmpty(name)) return null; + + // 遍历现有程序集 + foreach (var item in AppDomain.CurrentDomain.GetAssemblies()) + { + if (item.FullName == name) return item; + } + + return null; + } + #endregion +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/AlgorithmKeyBlob.cs b/src/Admin/ThingsGateway.NewLife.X/Security/AlgorithmKeyBlob.cs new file mode 100644 index 000000000..6b3b6019a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/AlgorithmKeyBlob.cs @@ -0,0 +1,26 @@ +namespace ThingsGateway.NewLife.Security +{ + internal enum AlgorithmKeyBlob + { + ECDH_PUBLIC_P256 = 0x314B4345, + ECDH_PRIVATE_P256 = 0x324B4345, + ECDH_PUBLIC_P384 = 0x334B4345, + ECDH_PRIVATE_P384 = 0x344B4345, + ECDH_PUBLIC_P521 = 0x354B4345, + ECDH_PRIVATE_P521 = 0x364B4345, + ECDH_PUBLIC_GENERIC = 0x504B4345, + ECDH_PRIVATE_GENERIC = 0x564B4345, + ECDSA_PUBLIC_P256 = 0x31534345, + ECDSA_PRIVATE_P256 = 0x32534345, + ECDSA_PUBLIC_P384 = 0x33534345, + ECDSA_PRIVATE_P384 = 0x34534345, + ECDSA_PUBLIC_P521 = 0x35534345, + ECDSA_PRIVATE_P521 = 0x36534345, + ECDSA_PUBLIC_GENERIC = 0x50444345, + ECDSA_PRIVATE_GENERIC = 0x56444345, + RSAPUBLIC = 0x31415352, + RSAPRIVATE = 0x32415352, + RSAFULLPRIVATE = 0x33415352, + KEY_DATA_BLOB = 0x4D42444B + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/Asn1.cs b/src/Admin/ThingsGateway.NewLife.X/Security/Asn1.cs new file mode 100644 index 000000000..b3a7f1b96 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/Asn1.cs @@ -0,0 +1,319 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ThingsGateway.NewLife.Security; + +/// 抽象语法标记。ASN.1是一种 ISO/ITU-T 标准,描述了一种对数据进行表示、编码、传输和解码的数据格式。 +public class Asn1 +{ + #region 属性 + /// 标签 + public Asn1Tags Tag { get; set; } + + /// 长度 + public Int32 Length { get; set; } + + /// 数值 + public Object? Value { get; set; } + #endregion + + #region 构造 + /// 已重载。 + /// + public override String ToString() + { + switch (Tag) + { + case Asn1Tags.Boolean: + break; + case Asn1Tags.Integer: return "#" + (Value as Byte[]).ToHex(0, 32); + case Asn1Tags.BitString: + case Asn1Tags.OctetString: return (Value as Byte[]).ToHex(0, 32); + case Asn1Tags.Null: return "Null"; + case Asn1Tags.ObjectIdentifier: + if (Value is Oid oid) return oid.FriendlyName + " " + oid.Value; + break; + case Asn1Tags.Sequence: + if (Value is Asn1[] arr) return arr.Join(); + break; + } + + return $"{Tag} {Value}"; + } + #endregion + + #region 方法 + /// 获取OID + /// + public Oid[] GetOids() + { + if (Value is Oid oid) return [oid]; + + var list = new List(); + if (Value is Asn1[] arr) + { + foreach (var item in arr) + { + var ds = item.GetOids(); + if (ds != null && ds.Length > 0) list.AddRange(ds); + } + } + + return list.ToArray(); + } + #endregion + + #region 读取 + /// 读取 + /// + /// + public static Asn1? Read(Byte[] data) + { + using var reader = new BinaryReader(new MemoryStream(data)); + return Read(reader); + } + /// 读取对象 + /// + /// + public static Asn1? Read(BinaryReader reader) + { + var len = ReadTLV(reader, out var tag); + if (len < 0) return null; + + var asn = new Asn1 { Length = len }; + + var tagNo = tag & 0x1F; + //if (tagNo == 0x1F) tagNo = reader.BaseStream.ReadEncodedInt(); + + // isConstructed + asn.Tag = (Asn1Tags)tagNo; + if ((tag & (Byte)Asn1Tags.Constructed) != 0) + { + switch (asn.Tag) + { + case Asn1Tags.OctetString: + break; + case Asn1Tags.External: + break; + case Asn1Tags.Sequence: + using (var reader2 = new BinaryReader(new MemoryStream(reader.ReadBytes(len)))) + { + var list = new List(); + while (true) + { + var obj = Read(reader2); + if (obj == null) break; + + list.Add(obj); + } + asn.Value = list.ToArray(); + return asn; + } + case Asn1Tags.Set: + break; + } + } + + // 基础类型 + var buf = reader.ReadBytes(len); + asn.Value = buf; + switch (asn.Tag) + { + case Asn1Tags.Boolean: + break; + case Asn1Tags.Integer: + asn.Value = buf; + break; + case Asn1Tags.BitString: + if (buf.Length > 0 && buf[0] == 0) buf = buf.ReadBytes(1, buf.Length - 1); + asn.Value = buf; + break; + case Asn1Tags.OctetString: + asn.Value = buf; + break; + case Asn1Tags.Null: + break; + case Asn1Tags.ObjectIdentifier: + //asn.Value = reader.ReadBytes(len); + asn.Value = new Oid(MakeOidStringFromBytes(buf)); + break; + case Asn1Tags.External: + break; + case Asn1Tags.Enumerated: + break; + //case Asn1Tags.Sequence: + // break; + //case Asn1Tags.SequenceOf: + // break; + case Asn1Tags.Set: + break; + //case Asn1Tags.SetOf: + // break; + case Asn1Tags.NumericString: + break; + case Asn1Tags.PrintableString: + break; + case Asn1Tags.T61String: + break; + case Asn1Tags.VideotexString: + break; + case Asn1Tags.IA5String: + break; + case Asn1Tags.UtcTime: + break; + case Asn1Tags.GeneralizedTime: + break; + case Asn1Tags.GraphicString: + break; + case Asn1Tags.VisibleString: + break; + case Asn1Tags.GeneralString: + break; + case Asn1Tags.UniversalString: + break; + case Asn1Tags.BmpString: + break; + case Asn1Tags.Utf8String: + break; + case Asn1Tags.Constructed: + break; + case Asn1Tags.Application: + break; + case Asn1Tags.Tagged: + break; + default: + break; + } + + return asn; + } + + /// 获取字节数组 + /// + /// + public Byte[]? GetByteArray(Boolean trimZero = false) + { + var buf = Value as Byte[]; + if (buf != null && trimZero && buf[0] == 0) buf = buf.ReadBytes(1, buf.Length - 1); + + return buf; + } + #endregion + + #region 辅助 + /// 读取TLV,Tag+Length+Value + /// 读取器 + /// + /// 返回长度,数据流指针移到Value第一个字节 + private static Int32 ReadTLV(BinaryReader reader, out Byte tag) + { + tag = 0; + + var v = reader.BaseStream.ReadByte(); + if (v < 0) return v; + + tag = (Byte)v; + + var len = (Int32)reader.ReadByte(); + if (len == 0x81) + len = reader.ReadByte(); + else if (len == 0x82) + len = reader.ReadBytes(2).ToUInt16(0, false); + else if (len == 0x84) + len = (Int32)reader.ReadBytes(4).ToUInt32(0, false); + + return len; + } + + /// 读取TLV,Tag+Length+Value + /// 读取器 + /// 是否剔除头部的0x00 + /// + private static Byte[] ReadTLV(BinaryReader reader, Boolean trimZero = true) + { + var len = ReadTLV(reader, out _); + //Debug.Assert(tag == 0x02); + + //if (offset > 0) reader.BaseStream.Seek(1, SeekOrigin.Current); + if (trimZero && reader.PeekChar() == 0) { reader.ReadByte(); len--; } + + return reader.ReadBytes(len); + } + + private const Int64 LONG_LIMIT = (Int64.MaxValue >> 7) - 0x7f; + private static String MakeOidStringFromBytes(Byte[] bytes) + { + var objId = new StringBuilder(); + Int64 value = 0; + //BigInteger bigValue; + var first = true; + + for (var i = 0; i != bytes.Length; i++) + { + Int32 b = bytes[i]; + + if (value <= LONG_LIMIT) + { + value += (b & 0x7f); + if ((b & 0x80) == 0) // end of number reached + { + if (first) + { + if (value < 40) + { + objId.Append('0'); + } + else if (value < 80) + { + objId.Append('1'); + value -= 40; + } + else + { + objId.Append('2'); + value -= 80; + } + first = false; + } + + objId.Append('.'); + objId.Append(value); + value = 0; + } + else + { + value <<= 7; + } + } + //else + //{ + // if (bigValue == null) + // { + // bigValue = BigInteger.ValueOf(value); + // } + // bigValue = bigValue.Or(BigInteger.ValueOf(b & 0x7f)); + // if ((b & 0x80) == 0) + // { + // if (first) + // { + // objId.Append('2'); + // bigValue = bigValue.Subtract(BigInteger.ValueOf(80)); + // first = false; + // } + + // objId.Append('.'); + // objId.Append(bigValue); + // bigValue = null; + // value = 0; + // } + // else + // { + // bigValue = bigValue.ShiftLeft(7); + // } + //} + } + + return objId.ToString(); + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/Asn1Tags.cs b/src/Admin/ThingsGateway.NewLife.X/Security/Asn1Tags.cs new file mode 100644 index 000000000..3dc7cf339 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/Asn1Tags.cs @@ -0,0 +1,87 @@ +namespace ThingsGateway.NewLife.Security +{ + /// ASN.1标签 + [Flags] + public enum Asn1Tags + { + /// 布尔 + Boolean = 0x01, + + /// 长整数 + Integer = 0x02, + + /// 比特串 + BitString = 0x03, + + /// 字节串 + OctetString = 0x04, + + /// + Null = 0x05, + + /// OID实体标识符 + ObjectIdentifier = 0x06, + + /// 外部 + External = 0x08, + + /// 枚举 + Enumerated = 0x0a, + + /// 序列 + Sequence = 0x10, + //SequenceOf = 0x10, // for completeness + + /// 集合 + Set = 0x11, + //SetOf = 0x11, // for completeness + + /// 数字字符串 + NumericString = 0x12, + + /// 可打印字符串 + PrintableString = 0x13, + + /// T61字符串 + T61String = 0x14, + + /// 视频 + VideotexString = 0x15, + + /// IA5字符串 + IA5String = 0x16, + + /// UTC时间 + UtcTime = 0x17, + + /// 通用时间 + GeneralizedTime = 0x18, + + /// 图形 + GraphicString = 0x19, + + /// 可见字符串 + VisibleString = 0x1a, + + /// 基本字符串 + GeneralString = 0x1b, + + /// 全局字符串 + UniversalString = 0x1c, + + /// 位图 + BmpString = 0x1e, + + /// UTF8字符串 + Utf8String = 0x0c, + + /// 组合 + Constructed = 0x20, + + /// 应用 + Application = 0x40, + + /// 标记 + Tagged = 0x80, + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/CbcTransform.cs b/src/Admin/ThingsGateway.NewLife.X/Security/CbcTransform.cs new file mode 100644 index 000000000..1f6a396cc --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/CbcTransform.cs @@ -0,0 +1,133 @@ +using System.Security.Cryptography; + +namespace ThingsGateway.NewLife.Security; + +/// CBC块密码模式 +/// +/// 密码块链 (CBC) 模式引入了反馈。 每个纯文本块在加密前,通过按位“异或”操作与前一个块的密码文本结合。 +/// 这样确保了即使纯文本包含许多相同的块,这些块中的每一个也会加密为不同的密码文本块。 +/// 在加密块之前,初始化向量通过按位“异或”操作与第一个纯文本块结合。 +/// 如果密码文本块中有一个位出错,相应的纯文本块也将出错。 +/// 此外,后面的块中与原出错位的位置相同的位也将出错。 +/// +public sealed class CbcTransform : ICryptoTransform +{ + #region 属性 + private readonly ICryptoTransform _transform; + private readonly Boolean _encryptMode; + private readonly Byte[] _iv; + private readonly Byte[] _lastBlock; + + /// 获取一个值,该值指示是否可以转换多个块。 + public Boolean CanTransformMultipleBlocks => true; + + /// 获取一个值,该值指示是否可重复使用当前转换。 + public Boolean CanReuseTransform => _transform.CanReuseTransform; + + /// 获取输入块大小。 + public Int32 InputBlockSize => _transform.InputBlockSize; + + /// 获取输出块大小。 + public Int32 OutputBlockSize => _transform.OutputBlockSize; + #endregion + + #region 构造 + /// 实例化 + /// + /// + /// + /// + public CbcTransform(ICryptoTransform transform, Byte[]? iv, Boolean encryptMode) + { + _transform = transform; + _encryptMode = encryptMode; + _lastBlock = new Byte[transform.InputBlockSize]; + + if (transform.InputBlockSize != transform.OutputBlockSize) throw new CryptographicException(); + + if (iv == null || iv.Length != transform.InputBlockSize) throw new CryptographicException("IV length mismatch"); + + Array.Copy(iv, _lastBlock, transform.InputBlockSize); + _iv = (Byte[])_lastBlock.Clone(); + } + + /// 销毁 + public void Dispose() => _transform.Dispose(); + #endregion + + /// 转换输入字节数组的指定区域,并将所得到的转换复制到输出字节数组的指定区域。 + /// + /// + /// + /// + /// + /// + /// + public Int32 TransformBlock(Byte[] inputBuffer, Int32 inputOffset, Int32 inputCount, Byte[] outputBuffer, Int32 outputOffset) + { + if (inputCount % InputBlockSize != 0) + throw new ArgumentOutOfRangeException(nameof(inputCount)); + + var blocks = inputCount / InputBlockSize; + while (blocks > 0) + { + TransformOneBlock(inputBuffer, inputOffset, outputBuffer, outputOffset, false); + + blocks -= 1; + inputOffset += InputBlockSize; + outputOffset += OutputBlockSize; + } + + return inputCount; + } + + private void TransformOneBlock(Byte[] inputBuffer, Int32 inputOffset, Byte[] outputBuffer, Int32 outputOffset, Boolean signalFinalBlock) + { + var imm = new Byte[InputBlockSize]; + Array.Copy(inputBuffer, inputOffset, imm, 0, InputBlockSize); + if (_encryptMode) + for (var i = 0; i < InputBlockSize; i++) + imm[i] ^= _lastBlock[i]; + + if (signalFinalBlock) + { + var lastBlock = _transform.TransformFinalBlock(imm, 0, InputBlockSize); + Array.Copy(lastBlock, 0, outputBuffer, outputOffset, InputBlockSize); + } + else + _transform.TransformBlock(imm, 0, InputBlockSize, outputBuffer, outputOffset); + + if (!_encryptMode) + { + for (var i = 0; i < InputBlockSize; i++) + outputBuffer[outputOffset + i] ^= _lastBlock[i]; + Array.Copy(imm, 0, _lastBlock, 0, InputBlockSize); + } + else + Array.Copy(outputBuffer, outputOffset, _lastBlock, 0, InputBlockSize); + } + + /// 转换指定字节数组的指定区域。 + /// + /// + /// + /// + /// + public Byte[] TransformFinalBlock(Byte[] inputBuffer, Int32 inputOffset, Int32 inputCount) + { + if (inputCount == 0) return Array.Empty(); + + var blocks = inputCount / InputBlockSize; + var output = new Byte[blocks * OutputBlockSize]; + if (blocks > 1) + TransformBlock(inputBuffer, inputOffset, inputCount - InputBlockSize, output, 0); + + if (blocks >= 1) + TransformOneBlock(inputBuffer, inputOffset + inputCount - InputBlockSize, output, output.Length - InputBlockSize, true); + else + output = _transform.TransformFinalBlock(inputBuffer, inputOffset, inputCount); + + Array.Copy(_iv, _lastBlock, InputBlockSize); + return output; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/Certificate.cs b/src/Admin/ThingsGateway.NewLife.X/Security/Certificate.cs new file mode 100644 index 000000000..48466b85d --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/Certificate.cs @@ -0,0 +1,368 @@ +using System.Runtime.InteropServices; +using System.Security.Cryptography.X509Certificates; + +using SecureString = System.Security.SecureString; + +namespace ThingsGateway.NewLife.Security +{ + /// 证书 + /// http://blogs.msdn.com/b/dcook/archive/2008/11/25/creating-a-self-signed-certificate-in-c.aspx + public class Certificate + { + /// 建立自签名证书 + /// + /// + public static Byte[] CreateSelfSignCertificatePfx(String x500) + { + var dt = DateTime.UtcNow; + return CreateSelfSignCertificatePfx(x500, dt, dt.AddYears(2), (SecureString)null); + } + + /// 建立自签名证书 + /// + /// + /// + /// + public static Byte[] CreateSelfSignCertificatePfx(String x500, DateTime startTime, DateTime endTime) => CreateSelfSignCertificatePfx(x500, startTime, endTime, (SecureString)null); + + /// 建立自签名证书 + /// + /// + /// + /// + /// + public static Byte[] CreateSelfSignCertificatePfx(String x500, DateTime startTime, DateTime endTime, String insecurePassword) + { + SecureString password = null; + + try + { + if (!String.IsNullOrEmpty(insecurePassword)) + { + password = new SecureString(); + foreach (var ch in insecurePassword) + { + password.AppendChar(ch); + } + + password.MakeReadOnly(); + } + + return CreateSelfSignCertificatePfx(x500, startTime, endTime, password); + } + finally + { + if (password != null) password.Dispose(); + } + } + + /// 建立自签名证书 + /// 例如CN=SelfSignCertificate;C=China;OU=ThingsGateway.NewLife;O=Development Team;E=nnhy@vip.qq.com,其中CN是显示名 + /// + /// + /// + /// + public static Byte[] CreateSelfSignCertificatePfx(String x500, DateTime startTime, DateTime endTime, SecureString password) + { + if (String.IsNullOrEmpty(x500)) x500 = "CN=" + Environment.MachineName; + + //X500DistinguishedNameFlags flag = X500DistinguishedNameFlags.UseUTF8Encoding; + return CreateSelfSignCertificatePfx(new X500DistinguishedName(x500), startTime, endTime, password); + } + + /// 建立自签名证书 + /// + /// + /// + /// + /// + public static Byte[] CreateSelfSignCertificatePfx(X500DistinguishedName distName, DateTime startTime, DateTime endTime, SecureString password) + { + var containerName = Guid.NewGuid().ToString(); + + var dataHandle = new GCHandle(); + var providerContext = IntPtr.Zero; + var cryptKey = IntPtr.Zero; + var certContext = IntPtr.Zero; + var certStore = IntPtr.Zero; + var storeCertContext = IntPtr.Zero; + var passwordPtr = IntPtr.Zero; + + //#if !NETCOREAPP + // RuntimeHelpers.PrepareConstrainedRegions(); + //#endif + + try + { + Check(NativeMethods.CryptAcquireContextW( + out providerContext, + containerName, + null, + 1, // PROV_RSA_FULL + 8)); // CRYPT_NEWKEYSET + + Check(NativeMethods.CryptGenKey( + providerContext, + 1, // AT_KEYEXCHANGE + 1, // CRYPT_EXPORTABLE + out cryptKey)); + + var nameData = distName.RawData; + + dataHandle = GCHandle.Alloc(nameData, GCHandleType.Pinned); + var nameBlob = new CryptoApiBlob(nameData.Length, dataHandle.AddrOfPinnedObject()); + + var kpi = new CryptKeyProviderInformation + { + ContainerName = containerName, + ProviderType = 1, // PROV_RSA_FULL + KeySpec = 1 // AT_KEYEXCHANGE + }; + + var startSystemTime = ToSystemTime(startTime); + var endSystemTime = ToSystemTime(endTime); + certContext = NativeMethods.CertCreateSelfSignCertificate( + providerContext, + ref nameBlob, + 0, + ref kpi, + IntPtr.Zero, // default = SHA1RSA + ref startSystemTime, + ref endSystemTime, + IntPtr.Zero); + Check(certContext != IntPtr.Zero); + dataHandle.Free(); + + certStore = NativeMethods.CertOpenStore( + "Memory", // sz_CERT_STORE_PROV_MEMORY + 0, + IntPtr.Zero, + 0x2000, // CERT_STORE_CREATE_NEW_FLAG + IntPtr.Zero); + Check(certStore != IntPtr.Zero); + + Check(NativeMethods.CertAddCertificateContextToStore( + certStore, + certContext, + 1, // CERT_STORE_ADD_NEW + out storeCertContext)); + + NativeMethods.CertSetCertificateContextProperty( + storeCertContext, + 2, // CERT_KEY_PROV_INFO_PROP_ID + 0, + ref kpi); + + if (password != null) + { + passwordPtr = Marshal.SecureStringToCoTaskMemUnicode(password); + } + + var pfxBlob = new CryptoApiBlob(); + Check(NativeMethods.PFXExportCertStoreEx( + certStore, + ref pfxBlob, + passwordPtr, + IntPtr.Zero, + 7)); // EXPORT_PRIVATE_KEYS | REPORT_NO_PRIVATE_KEY | REPORT_NOT_ABLE_TO_EXPORT_PRIVATE_KEY + + var pfxData = new Byte[pfxBlob.DataLength]; + dataHandle = GCHandle.Alloc(pfxData, GCHandleType.Pinned); + pfxBlob.Data = dataHandle.AddrOfPinnedObject(); + Check(NativeMethods.PFXExportCertStoreEx( + certStore, + ref pfxBlob, + passwordPtr, + IntPtr.Zero, + 7)); // EXPORT_PRIVATE_KEYS | REPORT_NO_PRIVATE_KEY | REPORT_NOT_ABLE_TO_EXPORT_PRIVATE_KEY + dataHandle.Free(); + + return pfxData; + } + finally + { + if (passwordPtr != IntPtr.Zero) Marshal.ZeroFreeCoTaskMemUnicode(passwordPtr); + + if (dataHandle.IsAllocated) dataHandle.Free(); + + if (certContext != IntPtr.Zero) NativeMethods.CertFreeCertificateContext(certContext); + + if (storeCertContext != IntPtr.Zero) NativeMethods.CertFreeCertificateContext(storeCertContext); + + if (certStore != IntPtr.Zero) NativeMethods.CertCloseStore(certStore, 0); + + if (cryptKey != IntPtr.Zero) NativeMethods.CryptDestroyKey(cryptKey); + + if (providerContext != IntPtr.Zero) + { + NativeMethods.CryptReleaseContext(providerContext, 0); + NativeMethods.CryptAcquireContextW( + out providerContext, + containerName, + null, + 1, // PROV_RSA_FULL + 0x10); // CRYPT_DELETEKEYSET + } + } + } + + private static SystemTime ToSystemTime(DateTime dateTime) + { + var fileTime = dateTime.ToFileTime(); + Check(NativeMethods.FileTimeToSystemTime(ref fileTime, out var systemTime)); + return systemTime; + } + + private static void Check(Boolean nativeCallSucceeded) + { + if (!nativeCallSucceeded) + { + var error = Marshal.GetHRForLastWin32Error(); + Marshal.ThrowExceptionForHR(error); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct SystemTime + { + public Int16 Year; + public Int16 Month; + public Int16 DayOfWeek; + public Int16 Day; + public Int16 Hour; + public Int16 Minute; + public Int16 Second; + public Int16 Milliseconds; + } + + [StructLayout(LayoutKind.Sequential)] + private struct CryptoApiBlob + { + public Int32 DataLength; + public IntPtr Data; + + public CryptoApiBlob(Int32 dataLength, IntPtr data) + { + DataLength = dataLength; + Data = data; + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct CryptKeyProviderInformation + { + [MarshalAs(UnmanagedType.LPWStr)] + public String ContainerName; + [MarshalAs(UnmanagedType.LPWStr)] + public String ProviderName; + public Int32 ProviderType; + public Int32 Flags; + public Int32 ProviderParameterCount; + public IntPtr ProviderParameters; // PCRYPT_KEY_PROV_PARAM + public Int32 KeySpec; + } + + private static class NativeMethods + { + [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean FileTimeToSystemTime( + [In] ref Int64 fileTime, + out SystemTime systemTime); + + [DllImport("AdvApi32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean CryptAcquireContextW( + out IntPtr providerContext, + [MarshalAs(UnmanagedType.LPWStr)] String container, + [MarshalAs(UnmanagedType.LPWStr)] String provider, + Int32 providerType, + Int32 flags); + + [DllImport("AdvApi32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean CryptReleaseContext( + IntPtr providerContext, + Int32 flags); + + [DllImport("AdvApi32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean CryptGenKey( + IntPtr providerContext, + Int32 algorithmId, + Int32 flags, + out IntPtr cryptKeyHandle); + + [DllImport("AdvApi32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean CryptDestroyKey( + IntPtr cryptKeyHandle); + + [DllImport("Crypt32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean CertStrToNameW( + Int32 certificateEncodingType, + IntPtr x500, + Int32 strType, + IntPtr reserved, + [MarshalAs(UnmanagedType.LPArray)][Out] Byte[] encoded, + ref Int32 encodedLength, + out IntPtr errorString); + + [DllImport("Crypt32.dll", SetLastError = true, ExactSpelling = true)] + public static extern IntPtr CertCreateSelfSignCertificate( + IntPtr providerHandle, + [In] ref CryptoApiBlob subjectIssuerBlob, + Int32 flags, + [In] ref CryptKeyProviderInformation keyProviderInformation, + IntPtr signatureAlgorithm, + [In] ref SystemTime startTime, + [In] ref SystemTime endTime, + IntPtr extensions); + + [DllImport("Crypt32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean CertFreeCertificateContext( + IntPtr certificateContext); + + [DllImport("Crypt32.dll", SetLastError = true, ExactSpelling = true)] + public static extern IntPtr CertOpenStore( + [MarshalAs(UnmanagedType.LPWStr)] String storeProvider, + Int32 messageAndCertificateEncodingType, + IntPtr cryptProvHandle, + Int32 flags, + IntPtr parameters); + + [DllImport("Crypt32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean CertCloseStore( + IntPtr certificateStoreHandle, + Int32 flags); + + [DllImport("Crypt32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean CertAddCertificateContextToStore( + IntPtr certificateStoreHandle, + IntPtr certificateContext, + Int32 addDisposition, + out IntPtr storeContextPtr); + + [DllImport("Crypt32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean CertSetCertificateContextProperty( + IntPtr certificateContext, + Int32 propertyId, + Int32 flags, + [In] ref CryptKeyProviderInformation data); + + [DllImport("Crypt32.dll", SetLastError = true, ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern Boolean PFXExportCertStoreEx( + IntPtr certificateStoreHandle, + ref CryptoApiBlob pfxBlob, + IntPtr password, + IntPtr reserved, + Int32 flags); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/Crc16.cs b/src/Admin/ThingsGateway.NewLife.X/Security/Crc16.cs new file mode 100644 index 000000000..e50a28efc --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/Crc16.cs @@ -0,0 +1,233 @@ +namespace ThingsGateway.NewLife.Security; + +/// CRC16校验 +public sealed class Crc16 +{ + #region 数据表 + /// CRC16表 + static readonly UInt16[] CrcTable = + [ + /* CRC16 余式表 */ + 0x0000,0x1021,0x2042,0x3063,0x4084,0x50A5,0x60C6,0x70E7, + 0x8108,0x9129,0xA14A,0xB16B,0xC18C,0xD1AD,0xE1CE,0xF1EF, + 0x1231,0x0210,0x3273,0x2252,0x52B5,0x4294,0x72F7,0x62D6, + 0x9339,0x8318,0xB37B,0xA35A,0xD3BD,0xC39C,0xF3FF,0xE3DE, + 0x2462,0x3443,0x0420,0x1401,0x64E6,0x74C7,0x44A4,0x5485, + 0xA56A,0xB54B,0x8528,0x9509,0xE5EE,0xF5CF,0xC5AC,0xD58D, + 0x3653,0x2672,0x1611,0x0630,0x76D7,0x66F6,0x5695,0x46B4, + 0xB75B,0xA77A,0x9719,0x8738,0xF7DF,0xE7FE,0xD79D,0xC7BC, + 0x48C4,0x58E5,0x6886,0x78A7,0x0840,0x1861,0x2802,0x3823, + 0xC9CC,0xD9ED,0xE98E,0xF9AF,0x8948,0x9969,0xA90A,0xB92B, + 0x5AF5,0x4AD4,0x7AB7,0x6A96,0x1A71,0x0A50,0x3A33,0x2A12, + 0xDBFD,0xCBDC,0xFBBF,0xEB9E,0x9B79,0x8B58,0xBB3B,0xAB1A, + 0x6CA6,0x7C87,0x4CE4,0x5CC5,0x2C22,0x3C03,0x0C60,0x1C41, + 0xEDAE,0xFD8F,0xCDEC,0xDDCD,0xAD2A,0xBD0B,0x8D68,0x9D49, + 0x7E97,0x6EB6,0x5ED5,0x4EF4,0x3E13,0x2E32,0x1E51,0x0E70, + 0xFF9F,0xEFBE,0xDFDD,0xCFFC,0xBF1B,0xAF3A,0x9F59,0x8F78, + 0x9188,0x81A9,0xB1CA,0xA1EB,0xD10C,0xC12D,0xF14E,0xE16F, + 0x1080,0x00A1,0x30C2,0x20E3,0x5004,0x4025,0x7046,0x6067, + 0x83B9,0x9398,0xA3FB,0xB3DA,0xC33D,0xD31C,0xE37F,0xF35E, + 0x02B1,0x1290,0x22F3,0x32D2,0x4235,0x5214,0x6277,0x7256, + 0xB5EA,0xA5CB,0x95A8,0x8589,0xF56E,0xE54F,0xD52C,0xC50D, + 0x34E2,0x24C3,0x14A0,0x0481,0x7466,0x6447,0x5424,0x4405, + 0xA7DB,0xB7FA,0x8799,0x97B8,0xE75F,0xF77E,0xC71D,0xD73C, + 0x26D3,0x36F2,0x0691,0x16B0,0x6657,0x7676,0x4615,0x5634, + 0xD94C,0xC96D,0xF90E,0xE92F,0x99C8,0x89E9,0xB98A,0xA9AB, + 0x5844,0x4865,0x7806,0x6827,0x18C0,0x08E1,0x3882,0x28A3, + 0xCB7D,0xDB5C,0xEB3F,0xFB1E,0x8BF9,0x9BD8,0xABBB,0xBB9A, + 0x4A75,0x5A54,0x6A37,0x7A16,0x0AF1,0x1AD0,0x2AB3,0x3A92, + 0xFD2E,0xED0F,0xDD6C,0xCD4D,0xBDAA,0xAD8B,0x9DE8,0x8DC9, + 0x7C26,0x6C07,0x5C64,0x4C45,0x3CA2,0x2C83,0x1CE0,0x0CC1, + 0xEF1F,0xFF3E,0xCF5D,0xDF7C,0xAF9B,0xBFBA,0x8FD9,0x9FF8, + 0x6E17,0x7E36,0x4E55,0x5E74,0x2E93,0x3EB2,0x0ED1,0x1EF0 + ]; + + const UInt16 CrcSeed = 0xFFFF; + /// 校验值 + public UInt16 Value { get; set; } = CrcSeed; + #endregion + + /// 重置清零 + public Crc16 Reset() { Value = 0xFFFF; return this; } + + /// 添加整数进行校验 + /// + /// the byte is taken as the lower 8 bits of value + /// + public Crc16 Update(Int16 value) + { + Value = (UInt16)((Value << 8) ^ CrcTable[((Value >> 8) ^ value)]); + + return this; + } + + /// 添加字节数组进行校验,查表计算 CRC16-CCITT x16+x12+x5+1 1021 ISO HDLC, ITU X.25, V.34/V.41/V.42, PPP-FCS + /// 字符串123456789的Crc16是31C3 + /// 数据缓冲区 + /// 偏移量 + /// 字节个数 + public Crc16 Update(Byte[] buffer, Int32 offset = 0, Int32 count = -1) + { + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); + if (count < 0) count = buffer.Length; + if (offset < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException(nameof(offset)); + + var crc = (UInt16)(Value ^ CrcSeed); + for (var i = 0; i < count; i++) + { + crc = (UInt16)((crc << 8) ^ CrcTable[(crc >> 8 ^ buffer[offset + i]) & 0xFF]); + } + Value = crc; + + return this; + } + + /// 添加数据区进行检验 + /// + /// + public Crc16 Update(ReadOnlySpan buffer) + { + var crc = (UInt16)(Value ^ CrcSeed); + for (var i = 0; i < buffer.Length; i++) + { + crc = (UInt16)((crc << 8) ^ CrcTable[(crc >> 8 ^ buffer[i]) & 0xFF]); + } + Value = crc; + + return this; + } + + /// 添加数据流进行校验,不查表计算 CRC-16 x16+x15+x2+1 8005 IBM SDLC + /// + /// 数量 + public Crc16 Update(Stream stream, Int64 count = -1) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + if (count <= 0) count = stream.Length - stream.Position; + + var crc = (UInt16)(Value ^ CrcSeed); + while (--count >= 0) + { + var b = stream.ReadByte(); + if (b == -1) break; + + crc = (UInt16)((crc << 8) ^ CrcTable[(crc >> 8 ^ b) & 0xFF]); + //crc ^= (Byte)b; + //for (var i = 0; i < 8; i++) + //{ + // if ((crc & 0x0001) != 0) + // crc = (UInt16)((crc >> 1) ^ 0xa001); + // else + // crc = (UInt16)(crc >> 1); + //} + } + Value = crc; + + return this; + } + + /// 计算校验码 + /// + /// + /// + /// + public static UInt16 Compute(Byte[] buf, Int32 offset = 0, Int32 count = -1) + { + var crc = new Crc16(); + crc.Update(buf, offset, count); + return crc.Value; + } + + /// 计算校验码 + /// + /// + public static UInt16 Compute(ReadOnlySpan buffer) + { + var crc = new Crc16(); + crc.Update(buffer); + return crc.Value; + } + + /// 计算数据流校验码 + /// + /// + /// + public static UInt16 Compute(Stream stream, Int32 count = -1) + { + var crc = new Crc16(); + crc.Update(stream, count); + return crc.Value; + } + + /// 计算数据流校验码,指定起始位置和字节数偏移量 + /// + /// 一般用于计算数据包校验码,需要回过头去开始校验,并且可能需要跳过最后的校验码长度。 + /// position小于0时,数据流从当前位置开始计算校验; + /// position大于等于0时,数据流移到该位置开始计算校验,最后由count决定可能差几个字节不参与计算; + /// + /// + /// 如果大于等于0,则表示从该位置开始计算 + /// 字节数偏移量,一般用负数表示 + /// + public static UInt16 Compute(Stream stream, Int64 position, Int32 count) + { + if (position >= 0) + { + if (count > 0) count = -count; + count += (Int32)(stream.Position - position); + stream.Position = position; + } + + var crc = new Crc16(); + crc.Update(stream, count); + return crc.Value; + } + + #region Modbus + private static readonly UInt16[] crc_ta = [0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401, 0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400,]; + + /// Modbus版Crc校验 + /// + /// 偏移 + /// 数量 + /// + public static UInt16 ComputeModbus(Byte[] data, Int32 offset, Int32 count = -1) + { + if (data == null || data.Length < 1) return 0; + + UInt16 u = 0xFFFF; + Byte b; + + if (count <= 0) count = data.Length - offset; + + for (var i = offset; i < count; i++) + { + b = data[i]; + u = (UInt16)(crc_ta[(b ^ u) & 15] ^ (u >> 4)); + u = (UInt16)(crc_ta[((b >> 4) ^ u) & 15] ^ (u >> 4)); + } + + return u; + } + + /// Modbus版Crc校验 + /// 数据流 + /// 回到该位置开始 + /// + public static UInt16 ComputeModbus(Stream stream, Int64 position = -1) + { + if (position >= 0) stream.Position = position; + + UInt16 u = 0xFFFF; + + while (stream.Position < stream.Length) + { + var b = stream.ReadByte(); + u = (UInt16)(crc_ta[(b ^ u) & 15] ^ (u >> 4)); + u = (UInt16)(crc_ta[((b >> 4) ^ u) & 15] ^ (u >> 4)); + } + + return u; + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/Crc32.cs b/src/Admin/ThingsGateway.NewLife.X/Security/Crc32.cs new file mode 100644 index 000000000..c75b14b69 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/Crc32.cs @@ -0,0 +1,214 @@ +namespace ThingsGateway.NewLife.Security; + +/// CRC32校验 +/// +/// Generate a table for a byte-wise 32-bit CRC calculation on the polynomial: +/// x^32+x^26+x^23+x^22+x^16+x^12+x^11+x^10+x^8+x^7+x^5+x^4+x^2+x+1. +/// +/// Polynomials over GF(2) are represented in binary, one bit per coefficient, +/// with the lowest powers in the most significant bit. Then adding polynomials +/// is just exclusive-or, and multiplying a polynomial by x is a right shift by +/// one. If we call the above polynomial p, and represent a byte as the +/// polynomial q, also with the lowest power in the most significant bit (so the +/// byte 0xb1 is the polynomial x^7+x^3+x+1), then the CRC is (q*x^32) mod p, +/// where a mod b means the remainder after dividing a by b. +/// +/// This calculation is done using the shift-register method of multiplying and +/// taking the remainder. The register is initialized to zero, and for each +/// incoming bit, x^32 is added mod p to the register if the bit is a one (where +/// x^32 mod p is p+x^32 = x^26+...+1), and the register is multiplied mod p by +/// x (which is shifting right by one and adding x^32 mod p if the bit shifted +/// out is a one). We start with the highest power (least significant bit) of +/// q and repeat for all eight bits of q. +/// +/// The table is simply the CRC of all possible eight bit values. This is all +/// the information needed to generate CRC's on data a byte at a time for all +/// combinations of CRC register values and incoming bytes. +/// +public sealed class Crc32 //: HashAlgorithm +{ + const UInt32 CrcSeed = 0xFFFFFFFF; + + #region 数据表 + /// 校验表 + public readonly static UInt32[] Table; + + static Crc32() + { + Table = new UInt32[256]; + const UInt32 kPoly = 0xEDB88320; + for (UInt32 i = 0; i < 256; i++) + { + var r = i; + for (var j = 0; j < 8; j++) + if ((r & 1) != 0) + r = (r >> 1) ^ kPoly; + else + r >>= 1; + Table[i] = r; + } + } + #endregion + + //internal static uint ComputeCrc32(uint oldCrc, byte value) + //{ + // return (uint)(Table[(oldCrc ^ value) & 0xFF] ^ (oldCrc >> 8)); + //} + + /// 校验值 + UInt32 crc = CrcSeed; + /// 校验值 + public UInt32 Value { get { return crc ^ CrcSeed; } set { crc = value ^ CrcSeed; } } + + /// 重置清零 + public Crc32 Reset() { crc = CrcSeed; return this; } + + /// 添加整数进行校验 + /// + /// the byte is taken as the lower 8 bits of value + /// + public Crc32 Update(Int32 value) + { + crc = Table[(crc ^ value) & 0xFF] ^ (crc >> 8); + + return this; + } + + /// 添加字节数组进行校验 + /// + /// The buffer which contains the data + /// + /// + /// The offset in the buffer where the data starts + /// + /// + /// The number of data bytes to update the CRC with. + /// + public Crc32 Update(Byte[] buffer, Int32 offset = 0, Int32 count = -1) + { + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); + //if (count < 0) throw new ArgumentOutOfRangeException("count", "Count不能小于0!"); + if (count < 0) count = buffer.Length; + if (offset < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException(nameof(offset)); + + while (--count >= 0) + { + crc = Table[(crc ^ buffer[offset++]) & 0xFF] ^ (crc >> 8); + } + + return this; + } + + /// 添加数据区进行检验 + /// + /// + public Crc32 Update(ReadOnlySpan buffer) + { + for (var i = 0; i < buffer.Length; i++) + { + crc = Table[(crc ^ buffer[i]) & 0xFF] ^ (crc >> 8); + } + + return this; + } + + /// 添加数据流进行校验 + /// + /// 数量 + public Crc32 Update(Stream stream, Int64 count = -1) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + //if (count < 0) throw new ArgumentOutOfRangeException("count", "Count不能小于0!"); + if (count <= 0) count = Int64.MaxValue; + + while (--count >= 0) + { + var b = stream.ReadByte(); + if (b == -1) break; + + crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + } + + return this; + } + + /// 计算校验码 + /// + /// + /// + /// + public static UInt32 Compute(Byte[] buf, Int32 offset = 0, Int32 count = -1) + { + var crc = new Crc32(); + crc.Update(buf, offset, count); + return crc.Value; + } + + /// 计算校验码 + /// + /// + public static UInt32 Compute(ReadOnlySpan buffer) + { + var crc = new Crc32(); + crc.Update(buffer); + return crc.Value; + } + + /// 计算数据流校验码 + /// + /// + /// + public static UInt32 Compute(Stream stream, Int32 count = -1) + { + var crc = new Crc32(); + crc.Update(stream, count); + return crc.Value; + } + + /// 计算数据流校验码,指定起始位置和字节数偏移量 + /// + /// 一般用于计算数据包校验码,需要回过头去开始校验,并且可能需要跳过最后的校验码长度。 + /// position小于0时,数据流从当前位置开始计算校验; + /// position大于等于0时,数据流移到该位置开始计算校验,最后由count决定可能差几个字节不参与计算; + /// + /// + /// 如果大于等于0,则表示从该位置开始计算 + /// 字节数偏移量,一般用负数表示 + /// + public static UInt32 ComputeRange(Stream stream, Int64 position = -1, Int32 count = -1) + { + if (position >= 0) + { + if (count > 0) count = -count; + count += (Int32)(stream.Position - position); + if (count == 0) return 0; + + stream.Position = position; + } + + var crc = new Crc32(); + crc.Update(stream, count); + return crc.Value; + } + + //#region 抽象实现 + ///// 哈希核心 + ///// + ///// + ///// + //protected override void HashCore(byte[] array, int ibStart, int cbSize) + //{ + // while (--cbSize >= 0) + // { + // crc = Table[(crc ^ array[ibStart++]) & 0xFF] ^ (crc >> 8); + // } + //} + + ///// 最后哈希 + ///// + //protected override byte[] HashFinal() { return BitConverter.GetBytes(Value); } + + ///// 初始化 + //public override void Initialize() { } + //#endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/DSAHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Security/DSAHelper.cs new file mode 100644 index 000000000..c15ab8dd8 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/DSAHelper.cs @@ -0,0 +1,106 @@ +using System.Security.Cryptography; +using System.Xml; + +namespace ThingsGateway.NewLife.Security; + +/// DSA算法 +public static class DSAHelper +{ + #region 产生密钥 + /// 产生非对称密钥对(私钥和公钥) + /// 密钥长度,默认1024位强密钥 + /// 私钥和公钥 + public static String[] GenerateKey(Int32 keySize = 1024) + { + using var dsa = new DSACryptoServiceProvider(keySize); + + var ss = new String[2]; + _ = dsa.ExportParameters(true); + ss[0] = dsa.ToXmlStringX(true); + ss[1] = dsa.ToXmlStringX(false); + + return ss; + } + + #endregion + + #region 数字签名 + /// 签名 + /// + /// + /// + public static Byte[] Sign(Byte[] buf, String priKey) + { + using var dsa = new DSACryptoServiceProvider(); + dsa.FromXmlStringX(priKey); + + return dsa.SignData(buf); + } + + /// 验证 + /// + /// + /// + /// + public static Boolean Verify(Byte[] buf, String pukKey, Byte[] rgbSignature) + { + using var dsa = new DSACryptoServiceProvider(); + dsa.FromXmlStringX(pukKey); + + return dsa.VerifyData(buf, rgbSignature); + } + #endregion + + #region 兼容core + /// 从Xml加载DSA密钥 + /// + /// + public static void FromXmlStringX(this DSACryptoServiceProvider rsa, String xmlString) + { + var parameters = new DSAParameters(); + + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(xmlString); + + if (xmlDoc.DocumentElement == null || !xmlDoc.DocumentElement.Name.Equals("DSAKeyValue")) + { + throw new Exception("Invalid XML DSA key."); + } + + foreach (var item in xmlDoc.DocumentElement.ChildNodes) + { + if (item is not XmlNode node) continue; + switch (node.Name) + { + case "P": parameters.P = (String.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break; + case "Q": parameters.Q = (String.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break; + case "G": parameters.G = (String.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break; + case "Y": parameters.Y = (String.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break; + case "Seed": parameters.Seed = (String.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break; + case "Counter": parameters.Counter = Convert.ToInt32(node.InnerText); break; + case "X": parameters.X = (String.IsNullOrEmpty(node.InnerText) ? null : Convert.FromBase64String(node.InnerText)); break; + } + } + + rsa.ImportParameters(parameters); + } + + /// 保存DSA密钥到Xml + /// + /// + /// + public static String ToXmlStringX(this DSACryptoServiceProvider rsa, Boolean includePrivateParameters) + { + var parameters = rsa.ExportParameters(includePrivateParameters); + + return String.Format("

{0}

{1}{2}{3}{4}{5}{6}
", + parameters.P != null ? Convert.ToBase64String(parameters.P) : null, + parameters.Q != null ? Convert.ToBase64String(parameters.Q) : null, + parameters.G != null ? Convert.ToBase64String(parameters.G) : null, + parameters.Y != null ? Convert.ToBase64String(parameters.Y) : null, + parameters.Seed != null ? Convert.ToBase64String(parameters.Seed) : null, + parameters.Counter, + parameters.X != null ? Convert.ToBase64String(parameters.X) : null); + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/IPasswordProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Security/IPasswordProvider.cs new file mode 100644 index 000000000..1dc3e8db7 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/IPasswordProvider.cs @@ -0,0 +1,155 @@ +using ThingsGateway.NewLife.Collections; + +namespace ThingsGateway.NewLife.Security; + +/// 密码提供者 +public interface IPasswordProvider +{ + /// 对密码进行散列处理,此处可以加盐,结果保存在数据库 + /// 密码明文 + /// + String Hash(String password); + + /// 验证密码散列,包括加盐判断 + /// 传输密码。可能是明文、MD5 + /// 哈希密文。服务端数据库保存,带有算法、盐值、哈希值 + /// + Boolean Verify(String password, String hash); +} + +/// 默认密码提供者 +public class PasswordProvider : IPasswordProvider +{ + /// 对密码进行散列处理,此处可以加盐,结果保存在数据库 + /// 密码明文 + /// + public String Hash(String password) => password; + + /// 验证密码散列,包括加盐判断 + /// 传输密码。可能是明文、MD5 + /// 哈希密文。服务端数据库保存,带有算法、盐值、哈希值 + /// + public Boolean Verify(String password, String hash) => password.EqualIgnoreCase(hash); +} + +/// MD5密码提供者 +public class MD5PasswordProvider : IPasswordProvider +{ + /// 对密码进行散列处理,此处可以加盐,结果保存在数据库 + /// 密码明文 + /// + public String Hash(String password) => password.MD5(); + + /// 验证密码散列,包括加盐判断 + /// 传输密码。可能是明文、MD5 + /// 哈希密文。服务端数据库保存,带有算法、盐值、哈希值 + /// + public Boolean Verify(String password, String hash) => hash.EqualIgnoreCase(password, password.MD5()); +} + +/// 盐值密码提供者 +/// +/// 1,在Web应用中,数据库保存哈希密码hash,登录时传输密码明文pass,服务端验证密码。算法配置为md5+sha512时,传输MD5散列。 +/// 2,在App验证时,数据库保存密码明文pass,登录时传输哈希密码hash,服务端验证密码。 +/// +public class SaltPasswordProvider : IPasswordProvider +{ + /// 算法。支持md5/sha1/sha512 + public String Algorithm { get; set; } = "sha512"; + + /// 使用Unix秒作为盐值。该值为允许的最大时间差,默认0,不使用时间盐值,而是使用随机字符串 + /// 一般在传输中使用,避免临时盐值被截取作为它用,建议值30秒。不仅仅是传输耗时,还有两端时间差 + public Int32 SaltTime { get; set; } + + /// 对密码进行散列处理,此处可以加盐,结果保存在数据库 + /// 密码明文 + /// + public String Hash(String password) + { + var salt = CreateSalt(); + var hash = Algorithm switch + { + "md5" => (password.MD5() + salt).MD5(), + "sha1" => password.GetBytes().SHA1(salt.GetBytes()).ToBase64(), + "sha512" => password.GetBytes().SHA512(salt.GetBytes()).ToBase64(), + "md5+sha1" => password.GetBytes().MD5().SHA1(salt.GetBytes()).ToBase64(), + "md5+sha512" => password.GetBytes().MD5().SHA512(salt.GetBytes()).ToBase64(), + _ => throw new NotImplementedException(), + }; + + return $"${Algorithm}${salt}${hash}"; + } + + private const String _cs = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./"; + + /// 创建盐值 + /// + protected virtual String CreateSalt() + { + if (SaltTime > 0) return DateTime.UtcNow.ToInt().ToString(); + + var length = 16; + var sb = Pool.StringBuilder.Get(); + for (var i = 0; i < length; i++) + { + var ch = _cs[Rand.Next(0, _cs.Length)]; + sb.Append(ch); + } + + return sb.Return(true); + } + + /// 验证密码散列,包括加盐判断 + /// 传输密码。可能是明文、MD5 + /// 哈希密文。服务端数据库保存,带有算法、盐值、哈希值 + /// + public virtual Boolean Verify(String password, String hash) + { + var ss = hash?.Split('$'); + if (ss == null || ss.Length == 0) throw new ArgumentNullException(nameof(hash)); + + // 老式MD5,password可能是密码原文,也可能是前端已经md5散列的值。数据库里刚好也是明文或MD5散列 + if (ss.Length == 1) return hash.EqualIgnoreCase(password, password.MD5()); + + if (ss.Length != 4) throw new NotSupportedException("Unsupported password hash value"); + + var salt = ss[2]; + if (SaltTime > 0) + { + // Unix秒作为盐值,时间差不得超过 SaltTime + var t = DateTime.UtcNow.ToInt() - salt.ToInt(); + if (Math.Abs(t) > SaltTime) return false; + } + + switch (ss[1]) + { + case "md5": + // 传输密码是明文 + if (ss[3] == (password.MD5() + salt).MD5()) return true; + // 传输密码是MD5哈希 + return ss[3] == (password + salt).MD5(); + case "sha1": + return ss[3] == password.GetBytes().SHA1(salt.GetBytes()).ToBase64(); + case "sha512": + return ss[3] == password.GetBytes().SHA512(salt.GetBytes()).ToBase64(); + case "md5+sha1": + // 标准校验 + if (ss[3] == password.GetBytes().MD5().SHA1(salt.GetBytes()).ToBase64()) return true; + // 兼容sha1。不大可能出现 + if (ss[3] == password.GetBytes().SHA1(salt.GetBytes()).ToBase64()) return true; + // 传输密码是MD5哈希 + if (ss[3] == password.ToHex().SHA1(salt.GetBytes()).ToBase64()) return true; + return false; + case "md5+sha512": + // 标准校验 + if (ss[3] == password.GetBytes().MD5().SHA512(salt.GetBytes()).ToBase64()) return true; + // 兼容sha512。不大可能出现 + if (ss[3] == password.GetBytes().SHA512(salt.GetBytes()).ToBase64()) return true; + // 传输密码是MD5哈希 + if (ss[3] == password.ToHex().SHA512(salt.GetBytes()).ToBase64()) return true; + return false; + default: + throw new NotSupportedException($"Unsupported password hash mode [{ss[1]}]"); + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/Murmur128.cs b/src/Admin/ThingsGateway.NewLife.X/Security/Murmur128.cs new file mode 100644 index 000000000..a818d0c65 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/Murmur128.cs @@ -0,0 +1,159 @@ +using System.Runtime.CompilerServices; +using System.Security.Cryptography; + +namespace ThingsGateway.NewLife.Security +{ + /// 高性能低碰撞Murmur128哈希算法 + /// + /// Redis等大量使用,比MD5要好 + /// + public class Murmur128 : HashAlgorithm + { + #region 属性 + private const UInt64 C1 = 0x87c37b91114253d5; + private const UInt64 C2 = 0x4cf5ad432745937f; + + private readonly UInt32 _Seed; + /// 种子 + public UInt32 Seed => _Seed; + + /// 哈希大小 + public override Int32 HashSize => 128; + + private Int32 _Length; + private UInt64 _H1; + private UInt64 _H2; + #endregion + + #region 构造 + /// 实例化 + /// + public Murmur128(UInt32 seed = 0) + { + _Seed = seed; + Reset(); + } + #endregion + + #region 方法 + private void Reset() + { + // 初始化哈希值到种子 + _H1 = _H2 = Seed; + + // 重置长度为0 + _Length = 0; + } + + /// 初始化 + public override void Initialize() => Reset(); + + /// 哈希核心 + /// + /// + /// + protected override void HashCore(Byte[] array, Int32 ibStart, Int32 cbSize) + { + // 增加长度 + _Length += cbSize; + Body(array, ibStart, cbSize); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Body(Byte[] data, Int32 start, Int32 length) + { + var remainder = length & 15; + var alignedLength = start + (length - remainder); + for (var i = start; i < alignedLength; i += 16) + { + _H1 ^= RotateLeft(data.ToUInt64(i) * C1, 31) * C2; + _H1 = (RotateLeft(_H1, 27) + _H2) * 5 + 0x52dce729; + + _H2 ^= RotateLeft(data.ToUInt64(i + 8) * C2, 33) * C1; + _H2 = (RotateLeft(_H2, 31) + _H1) * 5 + 0x38495ab5; + } + + if (remainder > 0) + Tail(data, alignedLength, remainder); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void Tail(Byte[] tail, Int32 start, Int32 remaining) + { + // create our keys and initialize to 0 + UInt64 k1 = 0, k2 = 0; + + // determine how many bytes we have left to work with based on length + switch (remaining) + { + case 15: k2 ^= (UInt64)tail[start + 14] << 48; goto case 14; + case 14: k2 ^= (UInt64)tail[start + 13] << 40; goto case 13; + case 13: k2 ^= (UInt64)tail[start + 12] << 32; goto case 12; + case 12: k2 ^= (UInt64)tail[start + 11] << 24; goto case 11; + case 11: k2 ^= (UInt64)tail[start + 10] << 16; goto case 10; + case 10: k2 ^= (UInt64)tail[start + 9] << 8; goto case 9; + case 9: k2 ^= (UInt64)tail[start + 8] << 0; goto case 8; + case 8: k1 ^= (UInt64)tail[start + 7] << 56; goto case 7; + case 7: k1 ^= (UInt64)tail[start + 6] << 48; goto case 6; + case 6: k1 ^= (UInt64)tail[start + 5] << 40; goto case 5; + case 5: k1 ^= (UInt64)tail[start + 4] << 32; goto case 4; + case 4: k1 ^= (UInt64)tail[start + 3] << 24; goto case 3; + case 3: k1 ^= (UInt64)tail[start + 2] << 16; goto case 2; + case 2: k1 ^= (UInt64)tail[start + 1] << 8; goto case 1; + case 1: k1 ^= (UInt64)tail[start] << 0; break; + } + + _H2 ^= RotateLeft(k2 * C2, 33) * C1; + _H1 ^= RotateLeft(k1 * C1, 31) * C2; + } + + /// 哈希结束 + /// + protected override Byte[] HashFinal() + { + var len = (UInt64)_Length; + _H1 ^= len; _H2 ^= len; + + _H1 += _H2; + _H2 += _H1; + + _H1 = FMix(_H1); + _H2 = FMix(_H2); + + _H1 += _H2; + _H2 += _H1; + + var result = new Byte[16]; + Array.Copy(BitConverter.GetBytes(_H1), 0, result, 0, 8); + Array.Copy(BitConverter.GetBytes(_H2), 0, result, 8, 8); + + return result; + } + #endregion + + #region 辅助 + //[MethodImpl(MethodImplOptions.AggressiveInlining)] + //private static UInt32 RotateLeft(UInt32 x, Byte r) => (x << r) | (x >> (32 - r)); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static UInt64 RotateLeft(UInt64 x, Byte r) => (x << r) | (x >> (64 - r)); + + //[MethodImpl(MethodImplOptions.AggressiveInlining)] + //private static UInt32 FMix(UInt32 h) + //{ + // h = (h ^ (h >> 16)) * 0x85ebca6b; + // h = (h ^ (h >> 13)) * 0xc2b2ae35; + // return h ^ (h >> 16); + //} + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static UInt64 FMix(UInt64 h) + { + h = (h ^ (h >> 33)) * 0xff51afd7ed558ccd; + h = (h ^ (h >> 33)) * 0xc4ceb9fe1a85ec53; + + return (h ^ (h >> 33)); + } + #endregion + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/PKCS7PaddingTransform.cs b/src/Admin/ThingsGateway.NewLife.X/Security/PKCS7PaddingTransform.cs new file mode 100644 index 000000000..9950efec6 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/PKCS7PaddingTransform.cs @@ -0,0 +1,182 @@ +using System.Security.Cryptography; + +namespace ThingsGateway.NewLife.Security; + +/// PKCS7填充 +public sealed class PKCS7PaddingTransform : ICryptoTransform +{ + #region 属性 + private readonly ICryptoTransform _transform; + private readonly Byte[] _lastBlock; + private readonly PaddingMode _mode; + private readonly Boolean _encryptMode; + private Boolean _hasWithheldBlock; + + /// 获取一个值,该值指示是否可重复使用当前转换。 + public Boolean CanReuseTransform => _transform.CanReuseTransform; + + /// 获取一个值,该值指示是否可以转换多个块。 + public Boolean CanTransformMultipleBlocks => _transform.CanTransformMultipleBlocks; + + /// 获取输入块大小。 + public Int32 InputBlockSize => _transform.InputBlockSize; + + /// 获取输出块大小。 + public Int32 OutputBlockSize => _transform.OutputBlockSize; + #endregion + + #region 构造 + /// 实例化 + /// + /// + /// + /// + /// + public PKCS7PaddingTransform(ICryptoTransform transform, PaddingMode mode, Boolean encryptMode) + { + _mode = mode; + _transform = transform; + _encryptMode = encryptMode; + + if (mode is not PaddingMode.ISO10126 and not PaddingMode.ANSIX923 and not PaddingMode.PKCS7) + throw new NotSupportedException(); + + if (transform.InputBlockSize > Byte.MaxValue || transform.OutputBlockSize > Byte.MaxValue || transform.InputBlockSize == 0 || transform.OutputBlockSize == 0) + throw new CryptographicException("Padding can only be used with block ciphers with block size of [2,255]"); + + _lastBlock = new Byte[encryptMode ? OutputBlockSize : InputBlockSize]; + } + + /// 销毁 + public void Dispose() => _transform.Dispose(); + #endregion + + /// 转换输入字节数组的指定区域,并将所得到的转换复制到输出字节数组的指定区域。 + /// + /// + /// + /// + /// + /// + public Int32 TransformBlock(Byte[] inputBuffer, Int32 inputOffset, Int32 inputCount, Byte[] outputBuffer, Int32 outputOffset) + { + var count = _transform.TransformBlock(inputBuffer, inputOffset, inputCount, outputBuffer, outputOffset); + if (_encryptMode) return count; + + //todo !!! 仅能临时解决短密文填充清理问题 + if (!_encryptMode && count <= OutputBlockSize) + { + // 最后一块 + if (count == OutputBlockSize) + { + // 清除后面的填充 + var last = outputBuffer[outputOffset + count - 1]; + if (last < count) + { + var pads = 0; + for (var i = OutputBlockSize - 1; i >= 0; i--) + { + if (outputBuffer[outputOffset + i] != last) break; + pads++; + } + + return pads != last ? count : count - pads; + } + } + + return count; + + } + + if (_hasWithheldBlock) + { + var lastBlock = new Byte[OutputBlockSize]; + Array.Copy(outputBuffer, outputOffset + count - OutputBlockSize, lastBlock, 0, OutputBlockSize); + Array.Copy(outputBuffer, outputOffset, outputBuffer, outputOffset + OutputBlockSize, count - OutputBlockSize); + Array.Copy(_lastBlock, 0, outputBuffer, outputOffset, OutputBlockSize); + Array.Copy(lastBlock, 0, _lastBlock, 0, OutputBlockSize); + } + else + { + Array.Copy(outputBuffer, outputOffset + count - OutputBlockSize, _lastBlock, 0, OutputBlockSize); + _hasWithheldBlock = true; + count -= OutputBlockSize; + } + + return count; + } + + /// 转换指定字节数组的指定区域。 + /// + /// + /// + /// + public Byte[] TransformFinalBlock(Byte[] inputBuffer, Int32 inputOffset, Int32 inputCount) + { + if (inputCount == 0) return Array.Empty(); + + if (_encryptMode) + { + var paddingLength = InputBlockSize - (inputCount % InputBlockSize); + var paddingValue = _mode switch + { + PaddingMode.ANSIX923 => 0, + PaddingMode.ISO10126 => (GetHashCode() & 0xFF) ^ paddingLength, + PaddingMode.PKCS7 => paddingLength, + _ => throw new Exception() + }; + var cipherBlock = new Byte[inputCount + paddingLength]; + Array.Copy(inputBuffer, inputOffset, cipherBlock, 0, inputCount); + for (var i = InputBlockSize; i >= 1; i--) + { + var posMask = ~(paddingLength - i) >> 31; + cipherBlock[cipherBlock.Length - i] &= (Byte)~posMask; + cipherBlock[cipherBlock.Length - i] |= (Byte)(paddingValue & posMask); + } + + if (cipherBlock.Length <= InputBlockSize || CanTransformMultipleBlocks) + return _transform.TransformFinalBlock(cipherBlock, 0, cipherBlock.Length); + + var remainingBlocks = cipherBlock.Length / InputBlockSize; + var returnData = new Byte[(remainingBlocks - 1) * OutputBlockSize]; + for (var i = 0; i < remainingBlocks - 1; i++) + _transform.TransformBlock(cipherBlock, i * InputBlockSize, InputBlockSize, returnData, i * OutputBlockSize); + + var lastBlock = _transform.TransformFinalBlock(cipherBlock, cipherBlock.Length - InputBlockSize, InputBlockSize); + Array.Resize(ref returnData, returnData.Length + lastBlock.Length); + Array.Copy(lastBlock, 0, returnData, OutputBlockSize, lastBlock.Length); + return returnData; + } + else + { + var data = _transform.TransformFinalBlock(inputBuffer, inputOffset, inputCount); + if (_hasWithheldBlock) + { + Array.Resize(ref data, data.Length + OutputBlockSize); + Array.Copy(data, 0, data, OutputBlockSize, data.Length - OutputBlockSize); + Array.Copy(_lastBlock, 0, data, 0, OutputBlockSize); + } + + if (data.Length < 1) + throw new CryptographicException("Invalid padding"); + + var paddingLength = data[data.Length - 1]; + var paddingValue = _mode == PaddingMode.ANSIX923 ? 0 : paddingLength; + var paddingError = 0; + if (_mode != PaddingMode.ISO10126) + for (var i = OutputBlockSize; i >= 1; i--) + { + // if i > paddingLength ignore; + // if paddingLength != data[data.Length - i] error; + var posMask = ~(paddingLength - i) >> 31; + paddingError |= (paddingValue ^ data[data.Length - i]) & posMask; + } + + if (paddingError != 0 || paddingLength == 0 || paddingLength > OutputBlockSize) + throw new CryptographicException("Invalid padding"); + + Array.Resize(ref data, data.Length - paddingLength); + return data; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/ProtectedKey.cs b/src/Admin/ThingsGateway.NewLife.X/Security/ProtectedKey.cs new file mode 100644 index 000000000..28fc25ff9 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/ProtectedKey.cs @@ -0,0 +1,130 @@ +using System.Security.Cryptography; + + +namespace ThingsGateway.NewLife.Security; + +/// 数据保护者。保护连接字符串中的密码 +public class ProtectedKey +{ + #region 属性 + /// 保护数据的密钥 + public Byte[]? Secret { get; set; } + + /// 算法。默认AES + public String Algorithm { get; set; } = "AES"; + + /// 隐藏字符串 + public String HideString { get; set; } = "{***}"; + + /// 密码名字 + public String[] Names { get; set; } = ["password", "pass", "pwd"]; + #endregion + + #region 方法 + /// 保护连接字符串中的密码 + /// + /// + public String Protect(String value) + { + using var alg = Create(Algorithm); + + // 单纯待加密数据 + var p = value.IndexOf('='); + if (p < 0) + { + var pass = alg.Encrypt(value.GetBytes(), Secret).ToUrlBase64(); + return $"${Algorithm}${pass}"; + } + + // 查找密码片段 + var dic = value.SplitAsDictionary("=", ";", true); + foreach (var item in Names) + { + if (dic.TryGetValue(item, out var pass)) + { + if (pass.IsNullOrEmpty()) break; + + // 加密密码后,重新组装 + pass = alg.Encrypt(pass.GetBytes(), Secret).ToUrlBase64(); + dic[item] = $"${Algorithm}${pass}"; + + return dic.Join(";", e => $"{e.Key}={e.Value}"); + } + } + + return value; + } + + /// 解保护连接字符串中的密码 + /// + /// + public String Unprotect(String value) + { + // 单纯待加密数据 + var p = value.IndexOf('='); + if (p < 0) + { + // 分解加密算法,$AES$string + var ss = value.Split('$'); + if (ss == null || ss.Length < 3) return value; + + using var alg = Create(ss[1]); + + return alg.Decrypt(ss[2].ToBase64(), Secret).ToStr(); + } + + // 查找密码片段 + var dic = value.SplitAsDictionary("=", ";"); + foreach (var item in Names) + { + if (dic.TryGetValue(item, out var pass)) + { + if (pass.IsNullOrEmpty()) break; + + // 分解加密算法,$AES$string + var ss = pass.Split('$'); + if (ss == null || ss.Length < 3) continue; + + using var alg = Create(ss[1]); + + dic[item] = alg.Decrypt(ss[2].ToBase64(), Secret).ToStr(); + + return dic.Join(";", e => $"{e.Key}={e.Value}"); + } + } + + return value; + } + + /// 隐藏连接字符串中的密码 + /// + /// + public String Hide(String value) + { + var dic = value.SplitAsDictionary("=", ";"); + foreach (var item in Names) + { + if (dic.TryGetValue(item, out var pass)) + { + dic[item] = HideString; + + return dic.Join(";", e => $"{e.Key}={e.Value}"); + } + } + + return value; + } + + private static SymmetricAlgorithm Create(String name) + { + return name.ToLowerInvariant() switch + { + "aes" => Aes.Create(), + "des" => DES.Create(), + "rc2" => RC2.Create(), + "tripledes" => TripleDES.Create(), + _ => throw new NotSupportedException($"Not Supported [{name}]"), + }; + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/RC4.cs b/src/Admin/ThingsGateway.NewLife.X/Security/RC4.cs new file mode 100644 index 000000000..5c83330f2 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/RC4.cs @@ -0,0 +1,65 @@ +namespace ThingsGateway.NewLife.Security +{ + /// RC4对称加密算法 + /// + /// RC4于1987年提出,和DES算法一样,是一种对称加密算法,也就是说使用的密钥为单钥(或称为私钥)。 + /// 但不同于DES的是,RC4不是对明文进行分组处理,而是字节流的方式依次加密明文中的每一个字节,解密的时候也是依次对密文中的每一个字节进行解密。 + /// + /// RC4算法的特点是算法简单,运行速度快,而且密钥长度是可变的,可变范围为1-256字节(8-2048比特), + /// 在如今技术支持的前提下,当密钥长度为128比特时,用暴力法搜索密钥已经不太可行,所以可以预见RC4的密钥范围任然可以在今后相当长的时间里抵御暴力搜索密钥的攻击。 + /// 实际上,如今也没有找到对于128bit密钥长度的RC4加密算法的有效攻击方法。 + /// + internal sealed class RC4 + { + /// 加密 + /// 数据 + /// 密码 + /// + public static Byte[] Encrypt(Byte[] data, Byte[] pass) + { + if (data == null || data.Length == 0) return Array.Empty(); + if (pass == null || pass.Length == 0) return data; + + var output = new Byte[data.Length]; + var i = 0; + var j = 0; + var box = GetKey(pass, 256); + // 加密 + for (var k = 0; k < data.Length; k++) + { + i = (i + 1) % box.Length; + j = (j + box[i]) % box.Length; + var temp = box[i]; + box[i] = box[j]; + box[j] = temp; + var a = data[k]; + var b = box[(box[i] + box[j]) % box.Length]; + output[k] = (Byte)(a ^ b); + } + + return output; + } + + /// 打乱密码 + /// 密码 + /// 密码箱长度 + /// 打乱后的密码 + private static Byte[] GetKey(Byte[] pass, Int32 len) + { + var box = new Byte[len]; + for (var i = 0; i < len; i++) + { + box[i] = (Byte)i; + } + var j = 0; + for (var i = 0; i < len; i++) + { + j = (j + box[i] + pass[i % pass.Length]) % len; + var temp = box[i]; + box[i] = box[j]; + box[j] = temp; + } + return box; + } + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/RSAHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Security/RSAHelper.cs new file mode 100644 index 000000000..6db742a00 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/RSAHelper.cs @@ -0,0 +1,321 @@ +using System.Security.Cryptography; + +namespace ThingsGateway.NewLife.Security; + +/// RSA算法 +/// +/// RSA加密或签名小数据块时,密文长度128,速度也很快。 +/// +public static class RSAHelper +{ + #region 加密解密 + /// 产生非对称密钥对 + /// + /// RSAParameters的各个字段采用大端字节序,转为BigInteger的之前一定要倒序。 + /// RSA加密后密文最小长度就是密钥长度,所以1024密钥最小密文长度是128字节。 + /// + /// 密钥长度,默认1024位强密钥 + /// + public static String[] GenerateKey(Int32 keySize = 2048) + { + using var rsa = new RSACryptoServiceProvider(keySize); + + var ss = new String[2]; + ss[0] = rsa.ToXmlString(true); + ss[1] = rsa.ToXmlString(false); + + return ss; + } + + /// 产生非对称密钥对 + /// + /// RSAParameters的各个字段采用大端字节序,转为BigInteger的之前一定要倒序。 + /// RSA加密后密文最小长度就是密钥长度,所以1024密钥最小密文长度是128字节。 + /// + /// 密钥长度,默认1024位强密钥 + /// + public static String[] GenerateParameters(Int32 keySize = 2048) + { + using var rsa = new RSACryptoServiceProvider(keySize); + + var ss = new String[2]; + ss[0] = WriteParameters(rsa.ExportParameters(true)); + ss[1] = WriteParameters(rsa.ExportParameters(false)); + + return ss; + } + + /// RSA参数转为Base64密钥 + /// + /// + public static String WriteParameters(RSAParameters p) + { + // 判断参数p的每一个成员是否为空,如果为空则抛出异常 + if (p.Modulus == null || p.Exponent == null) throw new ArgumentNullException(nameof(p)); + var ms = new MemoryStream(); + ms.WriteArray(p.Modulus); + ms.WriteArray(p.Exponent); + + if (p.D != null && p.D.Length > 0) + { + if (p.D == null || p.P == null || p.Q == null || + p.DP == null || p.DQ == null || p.InverseQ == null) throw new ArgumentNullException(nameof(p)); + + ms.WriteArray(p.D); + ms.WriteArray(p.P); + ms.WriteArray(p.Q); + ms.WriteArray(p.DP); + ms.WriteArray(p.DQ); + ms.WriteArray(p.InverseQ); + } + + return ms.ToArray().ToUrlBase64(); + } + + /// 根据Base64密钥创建RSA参数 + /// + /// + public static RSAParameters ReadParameters(String key) + { + using var ms = new MemoryStream(key.ToBase64()); + + var p = new RSAParameters + { + Modulus = ms.ReadArray(), + Exponent = ms.ReadArray(), + }; + + if (ms.Position < ms.Length) + { + p.D = ms.ReadArray(); + p.P = ms.ReadArray(); + p.Q = ms.ReadArray(); + p.DP = ms.ReadArray(); + p.DQ = ms.ReadArray(); + p.InverseQ = ms.ReadArray(); + } + + return p; + } + + /// 创建RSA对象,支持Xml密钥和Pem密钥 + /// + /// + public static RSACryptoServiceProvider Create(String key) + { + key = key.Trim(); + if (key.IsNullOrEmpty()) throw new ArgumentNullException(nameof(key)); + + var rsa = new RSACryptoServiceProvider(); + if (key.StartsWith("") && key.EndsWith("")) + rsa.FromXmlString(key); + else if (key.StartsWith("--") || key.Contains('\r') || key.Contains('\n')) + rsa.ImportParameters(ReadPem(key)); + else + rsa.ImportParameters(ReadParameters(key)); + + return rsa; + } + + /// RSA公钥加密。仅用于加密少量数据 + /// + /// (PKCS # 1 v2) 的 OAEP 填充 模数大小-2-2 * hLen,其中 hLen 是哈希的大小。 + /// 直接加密 (PKCS # 1 1.5 版) 模数大小-11。 (11 个字节是可能的最小填充。 ) + /// + /// 数据明文 + /// 公钥 + /// 如果为 true,则使用 OAEP 填充(仅可用于运行 Windows XP 及更高版本的计算机)执行直接 System.Security.Cryptography.RSA加密;否则,如果为 false,则使用 PKCS#1 v1.5 填充。 + /// + public static Byte[] Encrypt(Byte[] data, String pubKey, Boolean fOAEP = true) + { + using var rsa = Create(pubKey); + + return rsa.Encrypt(data, fOAEP); + } + + /// RSA私钥解密。仅用于加密少量数据 + /// + /// (PKCS # 1 v2) 的 OAEP 填充 模数大小-2-2 * hLen,其中 hLen 是哈希的大小。 + /// 直接加密 (PKCS # 1 1.5 版) 模数大小-11。 (11 个字节是可能的最小填充。 ) + /// + /// 数据密文 + /// 私钥 + /// 如果为 true,则使用 OAEP 填充(仅可用于运行 Microsoft Windows XP 及更高版本的计算机)执行直接 System.Security.Cryptography.RSA解密;否则,如果为 false 则使用 PKCS#1 v1.5 填充。 + /// + public static Byte[] Decrypt(Byte[] data, String priKey, Boolean fOAEP = true) + { + using var rsa = Create(priKey); + + return rsa.Decrypt(data, fOAEP); + } + #endregion + + #region 数字签名 + /// 签名,MD5散列 + /// + /// + /// + public static Byte[] Sign(Byte[] data, String priKey) + { + using var rsa = Create(priKey); + using var value = MD5.Create(); + return rsa.SignData(data, value); + } + + /// 验证,MD5散列 + /// + /// + /// + /// + public static Boolean Verify(Byte[] data, String pukKey, Byte[] rgbSignature) + { + using var rsa = Create(pukKey); + using var value = MD5.Create(); + return rsa.VerifyData(data, value, rgbSignature); + } + + private static HashAlgorithm _sha256 = SHA256.Create(); + /// RS256 + /// + /// + /// + public static Byte[] SignSha256(this Byte[] data, String priKey) + { + using var rsa = Create(priKey); + return rsa.SignData(data, _sha256); + } + + /// RS256 + /// + /// + /// + /// + public static Boolean VerifySha256(this Byte[] data, String pukKey, Byte[] rgbSignature) + { + using var rsa = Create(pukKey); + return rsa.VerifyData(data, _sha256, rgbSignature); + } + + private static HashAlgorithm _sha384 = SHA384.Create(); + /// RS384 + /// + /// + /// + public static Byte[] SignSha384(this Byte[] data, String priKey) + { + using var rsa = Create(priKey); + return rsa.SignData(data, _sha384); + } + + /// RS384 + /// + /// + /// + /// + public static Boolean VerifySha384(this Byte[] data, String pukKey, Byte[] rgbSignature) + { + using var rsa = Create(pukKey); + return rsa.VerifyData(data, _sha384, rgbSignature); + } + + private static HashAlgorithm _sha512 = SHA512.Create(); + /// RS512 + /// + /// + /// + public static Byte[] SignSha512(this Byte[] data, String priKey) + { + using var rsa = Create(priKey); + return rsa.SignData(data, _sha512); + } + + /// RS512 + /// + /// + /// + /// + public static Boolean VerifySha512(this Byte[] data, String pukKey, Byte[] rgbSignature) + { + using var rsa = Create(pukKey); + return rsa.VerifyData(data, _sha512, rgbSignature); + } + #endregion + + #region PEM + /// 读取PEM文件到RSA参数 + /// + /// + public static RSAParameters ReadPem(String content) + { + if (String.IsNullOrEmpty(content)) throw new ArgumentNullException(nameof(content)); + + // 公钥私钥分别处理 + content = content.Trim(); + if (content.StartsWithIgnoreCase("-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN PRIVATE KEY-----")) + { + var content2 = content.TrimStart("-----BEGIN RSA PRIVATE KEY-----") + .TrimEnd("-----END RSA PRIVATE KEY-----") + .TrimStart("-----BEGIN PRIVATE KEY-----") + .TrimEnd("-----END PRIVATE KEY-----") + .Replace("\n", null).Replace("\r", null); + + var data = Convert.FromBase64String(content2); + + // PrivateKeyInfo: version + Algorithm(algorithm + parameters) + privateKey + var asn = Asn1.Read(data) ?? throw new InvalidDataException(); + var keys = asn.Value as Asn1[] ?? throw new InvalidDataException(); + + // 可能直接key,也可能有Oid包装 + var oids = asn.GetOids(); + if (oids.Any(e => e.FriendlyName == "RSA")) + { + var buf = keys[2].Value as Byte[]; + if (buf != null) keys = Asn1.Read(buf)?.Value as Asn1[]; + } + + if (keys == null) throw new InvalidDataException(); + + // 参数数据 + return new RSAParameters + { + Modulus = keys[1].GetByteArray(true), + Exponent = keys[2].GetByteArray(false), + D = keys[3].GetByteArray(true), + P = keys[4].GetByteArray(true), + Q = keys[5].GetByteArray(true), + DP = keys[6].GetByteArray(true), + DQ = keys[7].GetByteArray(true), + InverseQ = keys[8].GetByteArray(true) + }; + } + else + { + content = content.Replace("-----BEGIN PUBLIC KEY-----", null) + .Replace("-----END PUBLIC KEY-----", null) + .Replace("\n", null).Replace("\r", null); + + var data = Convert.FromBase64String(content); + + var asn = Asn1.Read(data) ?? throw new InvalidDataException(); + var keys = asn.Value as Asn1[] ?? throw new InvalidDataException(); + + // 可能直接key,也可能有Oid包装 + var oids = asn.GetOids(); + if (oids.Any(e => e.FriendlyName == "RSA")) + { + var buf = keys.FirstOrDefault(e => e.Tag == Asn1Tags.BitString)?.Value as Byte[]; + if (buf != null) keys = Asn1.Read(buf)?.Value as Asn1[]; + } + + if (keys == null) throw new InvalidDataException(); + + // 参数数据 + return new RSAParameters + { + Modulus = keys[0].GetByteArray(true), + Exponent = keys[1].GetByteArray(false), + }; + } + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/Rand.cs b/src/Admin/ThingsGateway.NewLife.X/Security/Rand.cs new file mode 100644 index 000000000..c5c819f60 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/Rand.cs @@ -0,0 +1,234 @@ +using System.Reflection; +using System.Security.Cryptography; + +using ThingsGateway.NewLife.Collections; +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Security; + + +/// 随机数 +public static class Rand +{ +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + /// 返回一个小于所指定最大值的非负随机数 + /// 返回的随机数的上界(随机数不能取该上界值) + /// + public static Int32 Next(Int32 max = Int32.MaxValue) => RandomNumberGenerator.GetInt32(max); + + /// 返回一个指定范围内的随机数 + /// + /// 调用平均耗时37.76ns,其中GC耗时77.56% + /// + /// 返回的随机数的下界(随机数可取该下界值) + /// 返回的随机数的上界(随机数不能取该上界值) + /// + public static Int32 Next(Int32 min, Int32 max) => RandomNumberGenerator.GetInt32(min, max); +#else + private static readonly RandomNumberGenerator _rnd; + + static Rand() => _rnd = new RNGCryptoServiceProvider(); + + /// 返回一个小于所指定最大值的非负随机数 + /// 返回的随机数的上界(随机数不能取该上界值) + /// + public static Int32 Next(Int32 max = Int32.MaxValue) + { + if (max <= 0) throw new ArgumentOutOfRangeException(nameof(max)); + + return Next(0, max); + } + + [ThreadStatic] + private static Byte[]? _buf; + /// 返回一个指定范围内的随机数 + /// + /// 调用平均耗时37.76ns,其中GC耗时77.56% + /// + /// 返回的随机数的下界(随机数可取该下界值) + /// 返回的随机数的上界(随机数不能取该上界值) + /// + public static Int32 Next(Int32 min, Int32 max) + { + if (max <= min) throw new ArgumentOutOfRangeException(nameof(max)); + + _buf ??= new Byte[4]; + _rnd.GetBytes(_buf); + + var n = BitConverter.ToInt32(_buf, 0); + if (min == Int32.MinValue && max == Int32.MaxValue) return n; + if (min == 0 && max == Int32.MaxValue) return Math.Abs(n); + if (min == Int32.MinValue && max == 0) return -Math.Abs(n); + + var num = max - min; + // 不要进行复杂运算,看做是生成从0到(max-min)的随机数,然后再加上min即可 + return (Int32)((num * (UInt32)n >> 32) + min); + } +#endif + + /// 返回指定长度随机字节数组 + /// + /// 调用平均耗时5.46ns,其中GC耗时15% + /// + /// + /// + public static Byte[] NextBytes(Int32 count) + { +#if NET6_0_OR_GREATER + return RandomNumberGenerator.GetBytes(count); +#elif NETFRAMEWORK || NETSTANDARD2_0 + return new Random().NextBytes(count); +#else + var buf = new Byte[count]; + RandomNumberGenerator.Fill(buf); + return buf; +#endif + } + +#if NETFRAMEWORK || NETSTANDARD2_0 + /// + /// 返回随机数填充的指定长度的数组 + /// + /// + /// 数组长度 + /// 随机数填充的指定长度的数组 + private static Byte[] NextBytes(this Random random, Int32 length) + { + if (length < 0) throw new ArgumentOutOfRangeException(nameof(length)); + + var data = new Byte[length]; + random.NextBytes(data); + return data; + } +#endif + + /// 返回指定长度随机字符串 + /// 长度 + /// 是否包含符号 + /// + public static String NextString(Int32 length, Boolean symbol = false) + { + var sb = Pool.StringBuilder.Get(); + for (var i = 0; i < length; i++) + { + var ch = ' '; + if (symbol) + ch = (Char)Next(' ', 0x7F); + else + { + var n = Next(0, 10 + 26 + 26); + if (n < 10) + ch = (Char)('0' + n); + else if (n < 10 + 26) + ch = (Char)('A' + n - 10); + else + ch = (Char)('a' + n - 10 - 26); + } + sb.Append(ch); + } + + return sb.Return(true); + } + + /// 随机填充指定对象的属性。可用于构造随机数据进行测试 + /// + /// + /// + public static T Fill(T value) + { + if (value == null) return value; + + foreach (var pi in value.GetType().GetProperties(true)) + { + // 可空类型,有一定记录填充null + var type = pi.PropertyType; + if (type.IsNullable()) + { + // 10%几率填充null + if (Next(0, 10) == 0) + { + pi.SetValue(value, null); + continue; + } + + type = Nullable.GetUnderlyingType(type) ?? type; + } + + // 给基础类型填充数据 + var code = type.GetTypeCode(); + switch (code) + { + case TypeCode.Empty: + case TypeCode.Object: + case TypeCode.DBNull: + break; + case TypeCode.Boolean: + pi.SetValue(value, Next(2) > 0); + break; + case TypeCode.Char: + pi.SetValue(value, (Char)Next(Char.MinValue, Char.MaxValue)); + break; + case TypeCode.SByte: + pi.SetValue(value, (SByte)Next(SByte.MinValue, SByte.MaxValue)); + break; + case TypeCode.Byte: + pi.SetValue(value, (Byte)Next(Byte.MinValue, Byte.MaxValue)); + break; + case TypeCode.Int16: + pi.SetValue(value, (Int16)Next(Int16.MinValue, Int16.MaxValue)); + break; + case TypeCode.UInt16: + pi.SetValue(value, (UInt16)Next(UInt16.MinValue, UInt16.MaxValue)); + break; + case TypeCode.Int32: + pi.SetValue(value, Next()); + break; + case TypeCode.UInt32: + pi.SetValue(value, (UInt32)Next()); + break; + case TypeCode.Int64: + pi.SetValue(value, (Int64)Next() * Next()); + break; + case TypeCode.UInt64: + pi.SetValue(value, (UInt64)Next() * (UInt64)Next()); + break; + case TypeCode.Single: + pi.SetValue(value, Next() / 100f); + break; + case TypeCode.Double: + pi.SetValue(value, Next() / 10000d); + break; + case TypeCode.Decimal: + pi.SetValue(value, (Decimal)Next() / 10000); + break; + case TypeCode.DateTime: + pi.SetValue(value, new DateTime(2000, 1, 1).AddSeconds(Next(20 * 365 * 24 * 3600))); + break; + case TypeCode.String: + pi.SetValue(value, NextString(8)); + break; + default: + break; + } + + // 支持特殊类型 + if (code == TypeCode.Object) + { + if (type == typeof(Guid)) + pi.SetValue(value, Guid.NewGuid()); + else if (type == typeof(DateTimeOffset)) + pi.SetValue(value, new DateTimeOffset(new DateTime(2000, 1, 1).AddSeconds(Next(20 * 365 * 24 * 3600)))); + else if (type == typeof(TimeSpan)) + pi.SetValue(value, new TimeSpan(Next(20 * 24 * 3600 * 1000))); +#if NET6_0_OR_GREATER + else if (type == typeof(DateOnly)) + pi.SetValue(value, new DateOnly(Next(1000, 2300), Next(1, 13), Next(1, 29))); + else if (type == typeof(TimeOnly)) + pi.SetValue(value, new TimeOnly(Next(0, 24), Next(0, 60), Next(0, 60), Next(0, 1000))); +#endif + } + } + + return value; + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/SM4.cs b/src/Admin/ThingsGateway.NewLife.X/Security/SM4.cs new file mode 100644 index 000000000..81502b9e1 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/SM4.cs @@ -0,0 +1,320 @@ +using System.Numerics; +using System.Security.Cryptography; + +using ThingsGateway.NewLife.Security; + +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER +using System.Buffers.Binary; +#endif + +/// SM4(国密4) +public class SM4 : SymmetricAlgorithm +{ + /// 实例化SM4 + public new static SM4 Create() => new(); + + /// 实例化SM4 + public SM4() + { + KeySizeValue = 128; + BlockSizeValue = 128; + FeedbackSizeValue = BlockSizeValue; + LegalBlockSizesValue = new[] { new KeySizes(128, 128, 0) }; + LegalKeySizesValue = new[] { new KeySizes(128, 128, 0) }; + + Mode = CipherMode.ECB; + Padding = PaddingMode.PKCS7; + } + + /// 生成IV + public override void GenerateIV() => IV = Rand.NextBytes(16); + + /// 生成密钥 + public override void GenerateKey() => IV = Rand.NextBytes(16); + + /// 生成加密器 + /// + /// + /// + public override ICryptoTransform CreateEncryptor(Byte[] key, Byte[]? iv) => CreateTransform(key, iv, true); + + /// 生成解密器 + /// + /// + /// + public override ICryptoTransform CreateDecryptor(Byte[] key, Byte[]? iv) => CreateTransform(key, iv, false); + + private ICryptoTransform CreateTransform(Byte[] rgbKey, Byte[]? rgbIV, Boolean encryptMode) + { + ICryptoTransform transform = new SM4Transform(rgbKey, rgbIV, encryptMode); + switch (Mode) + { + case CipherMode.ECB: + break; + case CipherMode.CBC: + transform = new CbcTransform(transform, rgbIV, encryptMode); + break; + default: + throw new NotSupportedException("Only CBC/ECB is supported"); + } + + switch (PaddingValue) + { + case PaddingMode.None: + break; + case PaddingMode.PKCS7: + case PaddingMode.ISO10126: + case PaddingMode.ANSIX923: + transform = new PKCS7PaddingTransform(transform, PaddingValue, encryptMode); + break; + case PaddingMode.Zeros: + transform = new ZerosPaddingTransform(transform, encryptMode); + break; + default: + throw new NotSupportedException("Only PKCS#7 padding is supported"); + } + + return transform; + } +} + +/// SM4无线局域网标准的分组数据算法。对称加密,密钥长度和分组长度均为128位。 +/// +/// 我国国家密码管理局在20012年公布了无线局域网产品使用的SM4密码算法——商用密码算法。 +/// 它是分组算法当中的一种,算法特点是设计简沽,结构有特点,安全高效。 +/// 数据分组长度为128比特,密钥长度为128 比特。加密算法与密钥扩展算法都采用32轮迭代结构。 +/// SM4密码算法以字节(8位)和字(32位)作为单位进行数据处理。 +/// SM4密码算法是对合运算,因此解密算法与加密算法的结构相同,只是轮密钥的使用顺序相反,解密轮密钥是加密轮密钥的逆序。 +/// +public class SM4Transform : ICryptoTransform +{ + #region 常量 + private const Int32 BLOCK_SIZE = 16; + + private static readonly Byte[] Sbox = + [ + 0xd6, 0x90, 0xe9, 0xfe, 0xcc, 0xe1, 0x3d, 0xb7, 0x16, 0xb6, 0x14, 0xc2, 0x28, 0xfb, 0x2c, 0x05, + 0x2b, 0x67, 0x9a, 0x76, 0x2a, 0xbe, 0x04, 0xc3, 0xaa, 0x44, 0x13, 0x26, 0x49, 0x86, 0x06, 0x99, + 0x9c, 0x42, 0x50, 0xf4, 0x91, 0xef, 0x98, 0x7a, 0x33, 0x54, 0x0b, 0x43, 0xed, 0xcf, 0xac, 0x62, + 0xe4, 0xb3, 0x1c, 0xa9, 0xc9, 0x08, 0xe8, 0x95, 0x80, 0xdf, 0x94, 0xfa, 0x75, 0x8f, 0x3f, 0xa6, + 0x47, 0x07, 0xa7, 0xfc, 0xf3, 0x73, 0x17, 0xba, 0x83, 0x59, 0x3c, 0x19, 0xe6, 0x85, 0x4f, 0xa8, + 0x68, 0x6b, 0x81, 0xb2, 0x71, 0x64, 0xda, 0x8b, 0xf8, 0xeb, 0x0f, 0x4b, 0x70, 0x56, 0x9d, 0x35, + 0x1e, 0x24, 0x0e, 0x5e, 0x63, 0x58, 0xd1, 0xa2, 0x25, 0x22, 0x7c, 0x3b, 0x01, 0x21, 0x78, 0x87, + 0xd4, 0x00, 0x46, 0x57, 0x9f, 0xd3, 0x27, 0x52, 0x4c, 0x36, 0x02, 0xe7, 0xa0, 0xc4, 0xc8, 0x9e, + 0xea, 0xbf, 0x8a, 0xd2, 0x40, 0xc7, 0x38, 0xb5, 0xa3, 0xf7, 0xf2, 0xce, 0xf9, 0x61, 0x15, 0xa1, + 0xe0, 0xae, 0x5d, 0xa4, 0x9b, 0x34, 0x1a, 0x55, 0xad, 0x93, 0x32, 0x30, 0xf5, 0x8c, 0xb1, 0xe3, + 0x1d, 0xf6, 0xe2, 0x2e, 0x82, 0x66, 0xca, 0x60, 0xc0, 0x29, 0x23, 0xab, 0x0d, 0x53, 0x4e, 0x6f, + 0xd5, 0xdb, 0x37, 0x45, 0xde, 0xfd, 0x8e, 0x2f, 0x03, 0xff, 0x6a, 0x72, 0x6d, 0x6c, 0x5b, 0x51, + 0x8d, 0x1b, 0xaf, 0x92, 0xbb, 0xdd, 0xbc, 0x7f, 0x11, 0xd9, 0x5c, 0x41, 0x1f, 0x10, 0x5a, 0xd8, + 0x0a, 0xc1, 0x31, 0x88, 0xa5, 0xcd, 0x7b, 0xbd, 0x2d, 0x74, 0xd0, 0x12, 0xb8, 0xe5, 0xb4, 0xb0, + 0x89, 0x69, 0x97, 0x4a, 0x0c, 0x96, 0x77, 0x7e, 0x65, 0xb9, 0xf1, 0x09, 0xc5, 0x6e, 0xc6, 0x84, + 0x18, 0xf0, 0x7d, 0xec, 0x3a, 0xdc, 0x4d, 0x20, 0x79, 0xee, 0x5f, 0x3e, 0xd7, 0xcb, 0x39, 0x48 + ]; + + private static readonly UInt32[] CK = + [ + 0x00070e15, 0x1c232a31, 0x383f464d, 0x545b6269, + 0x70777e85, 0x8c939aa1, 0xa8afb6bd, 0xc4cbd2d9, + 0xe0e7eef5, 0xfc030a11, 0x181f262d, 0x343b4249, + 0x50575e65, 0x6c737a81, 0x888f969d, 0xa4abb2b9, + 0xc0c7ced5, 0xdce3eaf1, 0xf8ff060d, 0x141b2229, + 0x30373e45, 0x4c535a61, 0x686f767d, 0x848b9299, + 0xa0a7aeb5, 0xbcc3cad1, 0xd8dfe6ed, 0xf4fb0209, + 0x10171e25, 0x2c333a41, 0x484f565d, 0x646b7279 + ]; + + private static readonly UInt32[] FK = + [ + 0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc + ]; + #endregion + + #region 算法 + /// roundKeys + private readonly UInt32[] rk = new UInt32[32]; + + // non-linear substitution tau. + private static UInt32 tau(UInt32 A) + { + UInt32 b0 = Sbox[A >> 24]; + UInt32 b1 = Sbox[(A >> 16) & 0xFF]; + UInt32 b2 = Sbox[(A >> 8) & 0xFF]; + UInt32 b3 = Sbox[A & 0xFF]; + + return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3; + } + + private static UInt32 L_ap(UInt32 B) => B ^ RotateLeft(B, 13) ^ RotateLeft(B, 23); + +#if NETCOREAPP3_0_OR_GREATER + private static UInt32 RotateLeft(UInt32 i, Int32 distance) => BitOperations.RotateLeft(i, distance); +#else + private static UInt32 RotateLeft(UInt32 i, Int32 distance) => (i << distance) | (i >> -distance); +#endif + + private static UInt32 T_ap(UInt32 Z) => L_ap(tau(Z)); + + // Key expansion + private void ExpandKey(Boolean forEncryption, Byte[] key) + { + var K0 = BE_To_UInt32(key, 0) ^ FK[0]; + var K1 = BE_To_UInt32(key, 4) ^ FK[1]; + var K2 = BE_To_UInt32(key, 8) ^ FK[2]; + var K3 = BE_To_UInt32(key, 12) ^ FK[3]; + + if (forEncryption) + { + rk[0] = K0 ^ T_ap(K1 ^ K2 ^ K3 ^ CK[0]); + rk[1] = K1 ^ T_ap(K2 ^ K3 ^ rk[0] ^ CK[1]); + rk[2] = K2 ^ T_ap(K3 ^ rk[0] ^ rk[1] ^ CK[2]); + rk[3] = K3 ^ T_ap(rk[0] ^ rk[1] ^ rk[2] ^ CK[3]); + for (var i = 4; i < 32; ++i) + { + rk[i] = rk[i - 4] ^ T_ap(rk[i - 3] ^ rk[i - 2] ^ rk[i - 1] ^ CK[i]); + } + } + else + { + rk[31] = K0 ^ T_ap(K1 ^ K2 ^ K3 ^ CK[0]); + rk[30] = K1 ^ T_ap(K2 ^ K3 ^ rk[31] ^ CK[1]); + rk[29] = K2 ^ T_ap(K3 ^ rk[31] ^ rk[30] ^ CK[2]); + rk[28] = K3 ^ T_ap(rk[31] ^ rk[30] ^ rk[29] ^ CK[3]); + for (var i = 27; i >= 0; --i) + { + rk[i] = rk[i + 4] ^ T_ap(rk[i + 3] ^ rk[i + 2] ^ rk[i + 1] ^ CK[31 - i]); + } + } + } + +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER + internal static UInt32 BE_To_UInt32(Byte[] bs, Int32 off) => BinaryPrimitives.ReadUInt32BigEndian(bs.AsSpan(off)); +#else + internal static UInt32 BE_To_UInt32(Byte[] bs, Int32 off) => ((UInt32)bs[off] << 24) | ((UInt32)bs[off + 1] << 16) | ((UInt32)bs[off + 2] << 8) | bs[off + 3]; +#endif + + internal static void UInt32_To_BE(UInt32 n, Byte[] bs, Int32 off) + { +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER + BinaryPrimitives.WriteUInt32BigEndian(bs.AsSpan(off), n); +#else + bs[off] = (Byte)(n >> 24); + bs[off + 1] = (Byte)(n >> 16); + bs[off + 2] = (Byte)(n >> 8); + bs[off + 3] = (Byte)n; +#endif + } + + // Linear substitution L + private static UInt32 L(UInt32 B) => B ^ RotateLeft(B, 2) ^ RotateLeft(B, 10) ^ RotateLeft(B, 18) ^ RotateLeft(B, 24); + + // Mixer-substitution T + private static UInt32 T(UInt32 Z) => L(tau(Z)); + #endregion + + #region 属性 + /// 获取一个值,该值指示是否可重复使用当前转换。 + public Boolean CanReuseTransform => false; + + /// 获取一个值,该值指示是否可以转换多个块。 + public Boolean CanTransformMultipleBlocks => true; + + /// 获取输入块大小。 + public Int32 InputBlockSize => BLOCK_SIZE; + + /// 获取输出块大小。 + public Int32 OutputBlockSize => BLOCK_SIZE; + + private readonly Byte[] _buffer = new Byte[BLOCK_SIZE]; + #endregion + + #region 构造 + /// 实例化转换器 + /// + /// + /// + /// + public SM4Transform(Byte[] key, Byte[]? iv, Boolean encryptMode) + { + if (key == null || key.Length != 16) throw new ArgumentException(nameof(key), "Key must be a 16-byte array."); + if (iv != null && iv.Length != 16) throw new ArgumentException(nameof(key), "IV must be a 16-byte array."); + + ExpandKey(encryptMode, key); + + if (iv != null) Array.Copy(iv, _buffer, BLOCK_SIZE); + } + + /// 销毁 + public void Dispose() { } + #endregion + + /// 块加密数据,传入缓冲区必须是整块数据 + /// + /// + /// + /// + /// + /// + public Int32 EncryptData(Byte[] inputBuffer, Int32 inputOffset, Byte[] outputBuffer, Int32 outputOffset) + { + var X0 = BE_To_UInt32(inputBuffer, inputOffset); + var X1 = BE_To_UInt32(inputBuffer, inputOffset + 4); + var X2 = BE_To_UInt32(inputBuffer, inputOffset + 8); + var X3 = BE_To_UInt32(inputBuffer, inputOffset + 12); + + for (var i = 0; i < 32; i += 4) + { + X0 ^= T(X1 ^ X2 ^ X3 ^ rk[i]); // F0 + X1 ^= T(X2 ^ X3 ^ X0 ^ rk[i + 1]); // F1 + X2 ^= T(X3 ^ X0 ^ X1 ^ rk[i + 2]); // F2 + X3 ^= T(X0 ^ X1 ^ X2 ^ rk[i + 3]); // F3 + } + + UInt32_To_BE(X3, outputBuffer, outputOffset); + UInt32_To_BE(X2, outputBuffer, outputOffset + 4); + UInt32_To_BE(X1, outputBuffer, outputOffset + 8); + UInt32_To_BE(X0, outputBuffer, outputOffset + 12); + + return BLOCK_SIZE; + } + + /// 转换输入字节数组的指定区域,并将所得到的转换复制到输出字节数组的指定区域。 + /// + /// + /// + /// + /// + /// + /// + public Int32 TransformBlock(Byte[] inputBuffer, Int32 inputOffset, Int32 inputCount, Byte[] outputBuffer, Int32 outputOffset) + { + if (inputCount % BLOCK_SIZE != 0) throw new ArgumentException(nameof(inputCount), "Input count must be equal to block size."); + + var blocks = inputCount / InputBlockSize; + while (blocks > 0) + { + EncryptData(inputBuffer, inputOffset, outputBuffer, outputOffset); + blocks--; + inputOffset += InputBlockSize; + outputOffset += OutputBlockSize; + } + + return inputCount / InputBlockSize * OutputBlockSize; + } + + /// 转换指定字节数组的指定区域。 + /// + /// + /// + /// + /// + public Byte[] TransformFinalBlock(Byte[] inputBuffer, Int32 inputOffset, Int32 inputCount) + { + if (inputCount == 0) return Array.Empty(); + + var blocks = inputCount / InputBlockSize; + var output = new Byte[blocks * OutputBlockSize]; + TransformBlock(inputBuffer, inputOffset, inputCount, output, 0); + + return output; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/SecurityHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Security/SecurityHelper.cs new file mode 100644 index 000000000..986e9d473 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/SecurityHelper.cs @@ -0,0 +1,309 @@ +using System.Security.Cryptography; +using System.Text; + +using ThingsGateway.NewLife.Security; + +namespace ThingsGateway.NewLife; + +/// 安全算法 +/// +/// 文档 https://newlifex.com/core/security_helper +/// +public static class SecurityHelper +{ + #region 哈希 + [ThreadStatic] + private static MD5? _md5; + /// MD5散列 + /// + /// + public static Byte[] MD5(this Byte[] data) + { + _md5 ??= System.Security.Cryptography.MD5.Create(); + + return _md5.ComputeHash(data); + } + + /// MD5散列 + /// + /// 字符串编码,默认UTF8 + /// + public static unsafe String MD5(this String data, Encoding? encoding = null) + { + encoding ??= Encoding.UTF8; + +#if NETCOREAPP || NETSTANDARD2_1 + Span src = stackalloc Byte[data.Length * 3]; + var len = encoding.GetBytes(data.AsSpan(), src); + + Span buf = stackalloc Byte[16]; + _md5 ??= System.Security.Cryptography.MD5.Create(); + _md5.TryComputeHash(src[..len], buf, out len); + + return buf.ToHex(); +#else + var buf = MD5(encoding.GetBytes(data + "")); + return buf.ToHex(); +#endif + } + + /// MD5散列 + /// + /// 字符串编码,默认Default + /// + public static String MD5_16(this String data, Encoding? encoding = null) + { + encoding ??= Encoding.UTF8; + + var buf = MD5(encoding.GetBytes(data + "")); + return buf.ToHex(0, 8); + } + + /// 计算文件的MD5散列 + /// + /// + public static Byte[] MD5(this FileInfo file) + { + _md5 ??= System.Security.Cryptography.MD5.Create(); + + using var fs = file.OpenRead(); + return _md5.ComputeHash(fs); + } + + /// Crc散列 + /// + /// + public static UInt32 Crc(this Byte[] data) => new Crc32().Update(data).Value; + + /// Crc16散列 + /// + /// + public static UInt16 Crc16(this Byte[] data) => new Crc16().Update(data).Value; + + /// SHA128 + /// + /// + /// + public static Byte[] SHA1(this Byte[] data, Byte[] key) => new HMACSHA1(key).ComputeHash(data); + + /// SHA256 + /// + /// + /// + public static Byte[] SHA256(this Byte[] data, Byte[] key) => new HMACSHA256(key).ComputeHash(data); + + /// SHA384 + /// + /// + /// + public static Byte[] SHA384(this Byte[] data, Byte[] key) => new HMACSHA384(key).ComputeHash(data); + + /// SHA512 + /// + /// + /// + public static Byte[] SHA512(this Byte[] data, Byte[] key) => new HMACSHA512(key).ComputeHash(data); + + /// Murmur128哈希 + /// + /// + /// + public static Byte[] Murmur128(this Byte[] data, UInt32 seed = 0) => new Murmur128(seed).ComputeHash(data); + #endregion + + #region 同步加密扩展 + /// 对称加密算法扩展 + /// 注意:CryptoStream会把 outstream 数据流关闭 + /// + /// + /// + /// + public static SymmetricAlgorithm Encrypt(this SymmetricAlgorithm sa, Stream instream, Stream outstream) + { + using (var stream = new CryptoStream(outstream, sa.CreateEncryptor(), CryptoStreamMode.Write)) + { + instream.CopyTo(stream); + stream.FlushFinalBlock(); + } + + return sa; + } + + /// 对称加密算法扩展 + /// CBC填充依赖IV,要求加解密的IV一致,而ECB填充则不需要 + /// 算法 + /// 数据 + /// 密码 + /// 模式。.Net默认CBC,Java默认ECB + /// 填充算法。默认PKCS7,等同Java的PKCS5 + /// + public static Byte[] Encrypt(this SymmetricAlgorithm sa, Byte[] data, Byte[]? pass = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + if (data == null || data.Length <= 0) throw new ArgumentNullException(nameof(data)); + + if (pass != null && pass.Length > 0) + { + if (sa.LegalKeySizes != null && sa.LegalKeySizes.Length > 0) + sa.Key = Pad(pass, sa.LegalKeySizes[0]); + else + sa.Key = pass; + + // CBC填充依赖IV,要求加解密的IV一致,而ECB填充则不需要 + var iv = new Byte[sa.IV.Length]; + iv.Write(0, pass); + sa.IV = iv; + + sa.Mode = mode; + sa.Padding = padding; + } + + var outstream = new MemoryStream(); + using var stream = new CryptoStream(outstream, sa.CreateEncryptor(), CryptoStreamMode.Write); + stream.Write(data, 0, data.Length); + + // 数据长度必须是8的倍数 + if (sa.Padding == PaddingMode.None) + { + var len = data.Length % 8; + if (len > 0) + { + var buf = new Byte[8 - len]; + stream.Write(buf, 0, buf.Length); + } + } + + stream.FlushFinalBlock(); + + return outstream.ToArray(); + } + + /// 对称解密算法扩展 + /// 注意:CryptoStream会把 instream 数据流关闭 + /// + /// + /// + /// + /// + public static SymmetricAlgorithm Decrypt(this SymmetricAlgorithm sa, Stream instream, Stream outstream) + { + using (var stream = new CryptoStream(instream, sa.CreateDecryptor(), CryptoStreamMode.Read)) + { + stream.CopyTo(outstream); + } + + return sa; + } + + /// 对称解密算法扩展 + /// CBC填充依赖IV,要求加解密的IV一致,而ECB填充则不需要 + /// 算法 + /// 数据 + /// 密码 + /// 模式。.Net默认CBC,Java默认ECB + /// 填充算法。默认PKCS7,等同Java的PKCS5 + /// + public static Byte[] Decrypt(this SymmetricAlgorithm sa, Byte[] data, Byte[]? pass = null, CipherMode mode = CipherMode.CBC, PaddingMode padding = PaddingMode.PKCS7) + { + if (data == null || data.Length <= 0) throw new ArgumentNullException(nameof(data)); + + if (pass != null && pass.Length > 0) + { + if (sa.LegalKeySizes != null && sa.LegalKeySizes.Length > 0) + sa.Key = Pad(pass, sa.LegalKeySizes[0]); + else + sa.Key = pass; + + // CBC填充依赖IV,要求加解密的IV一致,而ECB填充则不需要 + var iv = new Byte[sa.IV.Length]; + iv.Write(0, pass); + sa.IV = iv; + + sa.Mode = mode; + sa.Padding = padding; + } + + using var stream = new CryptoStream(new MemoryStream(data), sa.CreateDecryptor(), CryptoStreamMode.Read); + return stream.ReadBytes(-1); + } + + private static Byte[] Pad(Byte[] buf, KeySizes keySize) + { + var psize = buf.Length * 8; + var size = 0; + for (var i = keySize.MinSize; i <= keySize.MaxSize; i += keySize.SkipSize) + { + if (i >= psize) + { + size = i / 8; + break; + } + + // DES的SkipSize为0 + if (keySize.SkipSize == 0) break; + } + + // 所有key大小都不合适,取最大值,此时密码过长,需要截断 + if (size == 0) size = keySize.MaxSize / 8; + + if (buf.Length == size) return buf; + + var buf2 = new Byte[size]; + buf2.Write(0, buf); + + return buf2; + } + + /// 转换数据(内部加解密) + /// + /// + /// + public static Byte[] Transform(this ICryptoTransform transform, Byte[] data) + { + // 小数据块 + if (data.Length <= transform.InputBlockSize) + return transform.TransformFinalBlock(data, 0, data.Length); + + // 逐个数据块转换 + var blocks = data.Length / transform.InputBlockSize; + var inputCount = blocks * transform.InputBlockSize; + if (inputCount < data.Length) blocks++; + + var output = new Byte[blocks * transform.OutputBlockSize]; + var count = 0; + if (inputCount > 0 && transform.CanTransformMultipleBlocks) + count = transform.TransformBlock(data, 0, inputCount, output, 0); + else + { + var pOutput = 0; + for (var pInput = 0; pInput < inputCount;) + { + count += transform.TransformBlock(data, pInput, transform.InputBlockSize, output, pOutput); + pInput += transform.InputBlockSize; + pOutput += transform.OutputBlockSize; + } + } + + if (count == data.Length) return output; + + //var outstream = new MemoryStream(); + //outstream.Write(output, 0, count); + + var rs = transform.TransformFinalBlock(data, count, data.Length - count); + Buffer.BlockCopy(rs, 0, output, count, rs.Length); + + return output; + + //outstream.Write(rs); + + //return outstream.ToArray(); + } + #endregion + + #region RC4 + /// RC4对称加密算法 + /// + /// + /// + public static Byte[] RC4(this Byte[] data, Byte[] pass) => NewLife.Security.RC4.Encrypt(data, pass); + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Security/ZerosPaddingTransform.cs b/src/Admin/ThingsGateway.NewLife.X/Security/ZerosPaddingTransform.cs new file mode 100644 index 000000000..ae79784f6 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Security/ZerosPaddingTransform.cs @@ -0,0 +1,88 @@ +using System.Security.Cryptography; + +namespace ThingsGateway.NewLife.Security; + +/// Zero填充 +public sealed class ZerosPaddingTransform : ICryptoTransform +{ + #region 属性 + private readonly ICryptoTransform _transform; + private readonly Boolean _encryptMode; + + /// 获取一个值,该值指示是否可重复使用当前转换。 + public Boolean CanReuseTransform => _transform.CanReuseTransform; + + /// 获取一个值,该值指示是否可以转换多个块。 + public Boolean CanTransformMultipleBlocks => _transform.CanTransformMultipleBlocks; + + /// 获取输入块大小。 + public Int32 InputBlockSize => _transform.InputBlockSize; + + /// 获取输出块大小。 + public Int32 OutputBlockSize => _transform.OutputBlockSize; + #endregion + + #region 构造 + /// 实例化 + /// + /// + public ZerosPaddingTransform(ICryptoTransform transform, Boolean encryptMode) + { + _transform = transform; + _encryptMode = encryptMode; + } + + /// 销毁 + public void Dispose() => _transform.Dispose(); + #endregion + + /// 转换输入字节数组的指定区域,并将所得到的转换复制到输出字节数组的指定区域。 + /// + /// + /// + /// + /// + /// + public Int32 TransformBlock(Byte[] inputBuffer, Int32 inputOffset, Int32 inputCount, Byte[] outputBuffer, Int32 outputOffset) + { + var count = _transform.TransformBlock(inputBuffer, inputOffset, inputCount, outputBuffer, outputOffset); + + if (!_encryptMode) + { + // 清除后面的填充 + var pads = 0; + for (var i = OutputBlockSize - 1; i >= 0; i--) + { + if (outputBuffer[outputOffset + i] != 0) break; + pads++; + } + + return pads == 0 ? count : count - pads; + } + + return count; + } + + /// 转换指定字节数组的指定区域。 + /// + /// + /// + /// + public Byte[] TransformFinalBlock(Byte[] inputBuffer, Int32 inputOffset, Int32 inputCount) + { + if (inputCount == 0) return Array.Empty(); + + //todo !!! 仅能临时解决短密文填充清理问题 + if (_encryptMode && inputCount % InputBlockSize != 0) + { + var paddingNeeded = InputBlockSize - (inputCount % InputBlockSize); + var padded = new Byte[inputCount + paddingNeeded]; + Array.Copy(inputBuffer, inputOffset, padded, 0, inputCount); + inputBuffer = padded; + inputOffset = 0; + inputCount += paddingNeeded; + } + + return _transform.TransformFinalBlock(inputBuffer, inputOffset, inputCount); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/Binary.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/Binary.cs new file mode 100644 index 000000000..7ead2e7f2 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/Binary.cs @@ -0,0 +1,584 @@ +using System.Buffers; +using System.Reflection; +using System.Runtime.InteropServices; + +using ThingsGateway.NewLife.Collections; +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 二进制序列化 +public class Binary : FormatterBase, IBinary +{ + #region 属性 + /// 使用7位编码整数。默认false不使用 + public Boolean EncodeInt { get; set; } + + /// 小端字节序。默认false大端 + public Boolean IsLittleEndian { get; set; } + + /// 使用指定大小的FieldSizeAttribute特性,默认false + public Boolean UseFieldSize { get; set; } + + /// 使用对象引用,默认false + public Boolean UseRef { get; set; } = false; + + /// 大小宽度。可选0/1/2/4,默认0表示压缩编码整数 + public Int32 SizeWidth { get; set; } + + /// 解析字符串时,是否清空两头的0字节,默认false + public Boolean TrimZero { get; set; } + + /// 协议版本。用于支持多版本协议序列化,配合FieldSize特性使用。例如JT/T808的2011/2019 + public String? Version { get; set; } + + /// 使用完整的时间格式。完整格式使用8个字节保存毫秒数,默认false + public Boolean FullTime { get; set; } + + /// 要忽略的成员 + public ICollection IgnoreMembers { get; set; } = []; + + /// 处理器列表 + public IList Handlers { get; private set; } + #endregion + + #region 构造 + /// 实例化 + public Binary() + { + // 遍历所有处理器实现 + var list = new List + { + new BinaryGeneral { Host = this }, + new BinaryNormal { Host = this }, + new BinaryComposite { Host = this }, + new BinaryList { Host = this }, + new BinaryDictionary { Host = this } + }; + // 根据优先级排序 + Handlers = list.OrderBy(e => e.Priority).ToList(); + } + #endregion + + #region 处理器 + /// 添加处理器 + /// + /// + public Binary AddHandler(IBinaryHandler handler) + { + if (handler != null) + { + handler.Host = this; + Handlers.Add(handler); + // 根据优先级排序 + Handlers = Handlers.OrderBy(e => e.Priority).ToList(); + } + + return this; + } + + /// 添加处理器 + /// + /// + /// + public Binary AddHandler(Int32 priority = 0) where THandler : IBinaryHandler, new() + { + var handler = new THandler + { + Host = this + }; + if (priority != 0) handler.Priority = priority; + + return AddHandler(handler); + } + + /// 获取处理器 + /// + /// + public T? GetHandler() where T : class, IBinaryHandler + { + foreach (var item in Handlers) + { + if (item is T handler) return handler; + } + + return default; + } + #endregion + + #region 写入 + /// 写入一个对象 + /// 目标对象 + /// 类型 + /// + public virtual Boolean Write(Object? value, Type? type = null) + { + if (type == null) + { + if (value == null) return true; + + type = value.GetType(); + + // 一般类型为空是顶级调用 + if (Hosts.Count == 0 && Log != null && Log.Enable) WriteLog("BinaryWrite {0} {1}", type.Name, value); + } + + // 优先 IAccessor 接口 + if (value is IAccessor acc) + { + if (acc.Write(Stream, this)) return true; + } + + foreach (var item in Handlers) + { + if (item.Write(value, type)) return true; + } + return false; + } + + /// 写入字节 + /// + public virtual void Write(Byte value) => Stream.WriteByte(value); + + /// 将字节数组部分写入当前流,不写入数组长度。 + /// 包含要写入的数据的字节数组。 + /// buffer 中开始写入的起始点。 + /// 要写入的字节数。 + public virtual void Write(Byte[] buffer, Int32 offset, Int32 count) + { + if (count < 0) count = buffer.Length - offset; + Stream.Write(buffer, offset, count); + } + + /// 写入数据 + /// + public virtual void Write(ReadOnlySpan buffer) + { +#if NETCOREAPP || NETSTANDARD2_1_OR_GREATER + Stream.Write(buffer); +#else + var array = ArrayPool.Shared.Rent(buffer.Length); + try + { + buffer.CopyTo(array); + + Stream.Write(array, 0, buffer.Length); + } + finally + { + ArrayPool.Shared.Return(array); + } +#endif + } + + /// 写入数据 + /// + public virtual void Write(ReadOnlyMemory buffer) + { + if (MemoryMarshal.TryGetArray(buffer, out var segment)) + { + Stream.Write(segment.Array!, segment.Offset, segment.Count); + + return; + } + + Write(buffer.Span); + } + + /// 写入大小,如果有FieldSize则返回,否则写入编码的大小并返回-1 + /// + /// + public virtual Int32 WriteSize(Int32 size) + { + var sizeWidth = -1; + if (UseFieldSize && TryGetFieldSize(out var fieldsize, out sizeWidth)) return fieldsize; + + if (sizeWidth < 0) sizeWidth = SizeWidth; + switch (sizeWidth) + { + case 1: + Write((Byte)size); + break; + case 2: + Write((Int16)size); + break; + case 4: + Write(size); + break; + case 0: + default: + WriteEncoded(size); + break; + } + + return -1; + } + #endregion + + #region 读取 + /// 读取指定类型对象 + /// + /// + public virtual Object? Read(Type type) + { + Object? value = null; + if (!TryRead(type, ref value)) throw new Exception($"Read failed, type {type} is not supported!"); + + return value; + } + + /// 读取指定类型对象 + /// + /// + public T? Read() => (T?)Read(typeof(T)); + + /// 尝试读取指定类型对象 + /// + /// + /// + public virtual Boolean TryRead(Type type, ref Object? value) + { + if (Hosts.Count == 0 && Log != null && Log.Enable) WriteLog("BinaryRead {0} {1}", type.Name, value); + + // 优先 IAccessor 接口 + if (value is IAccessor acc) + { + if (acc.Read(Stream, this)) return true; + } + if (value == null && type.As()) + { + value = type.CreateInstance(); + if (value is IAccessor acc2) + { + if (acc2.Read(Stream, this)) return true; + } + } + + foreach (var item in Handlers) + { + if (item.TryRead(type, ref value)) return true; + } + return false; + } + + /// 读取字节 + /// + public virtual Byte ReadByte() + { + var b = Stream.ReadByte(); + if (b < 0) throw new Exception("The data stream is out of range!"); + return (Byte)b; + } + + /// 从当前流中将 count 个字节读入字节数组 + /// 要读取的字节数。 + /// + public virtual Byte[] ReadBytes(Int32 count) + { + var buffer = Stream.ReadBytes(count); + //if (n != count) throw new InvalidDataException($"数据不足,需要{count},实际{n}"); + + return buffer; + } + + /// 读取大小 + /// + public virtual Int32 ReadSize() + { + var sizeWidth = -1; + if (UseFieldSize && TryGetFieldSize(out var size, out sizeWidth)) return size; + + if (sizeWidth < 0) sizeWidth = SizeWidth; + return sizeWidth switch + { + 1 => ReadByte(), + 2 => (Int16)(Read(typeof(Int16)) ?? 0), + 4 => (Int32)(Read(typeof(Int32)) ?? 0), + 0 => ReadEncodedInt32(), + _ => -1, + }; + } + + private Boolean TryGetFieldSize(out Int32 size, out Int32 sizeWidth) + { + sizeWidth = -1; + if (Member is MemberInfo member) + { + // 获取FieldSizeAttribute特性 + var atts = member.GetCustomAttributes(); + if (atts != null) + { + foreach (var att in atts) + { + // 检查版本是否匹配 + if (att.Version.IsNullOrEmpty() || att.Version == Version) + { + // 如果指定了引用字段,则找引用字段所表示的长度 + var target = Hosts.Peek(); + if (!att.ReferenceName.IsNullOrEmpty() && target != null && att.TryGetReferenceSize(target, member, out size)) + return true; + + // 如果指定了固定大小,直接返回 + size = att.Size; + if (size > 0) return true; + + // 指定了大小位宽 + if (att.SizeWidth >= 0) + { + sizeWidth = att.SizeWidth; + return false; + } + } + } + } + } + + size = -1; + return false; + } + #endregion + + #region 7位压缩编码整数 + [ThreadStatic] + private static Byte[]? _encodes; + /// 写7位压缩编码整数 + /// + /// 以7位压缩格式写入32位整数,小于7位用1个字节,小于14位用2个字节。 + /// 由每次写入的一个字节的第一位标记后面的字节是否还是当前数据,所以每个字节实际可利用存储空间只有后7位。 + /// + /// 数值 + /// 实际写入字节数 + public Int32 WriteEncoded(Int32 value) + { + _encodes ??= new Byte[16]; + + var count = 0; + var num = (UInt32)value; + while (num >= 0x80) + { + _encodes[count++] = (Byte)(num | 0x80); + num >>= 7; + } + _encodes[count++] = (Byte)num; + + Write(_encodes, 0, count); + + return count; + } + + /// 以压缩格式读取16位整数 + /// + public Int16 ReadEncodedInt16() + { + Byte b; + Int16 rs = 0; + Byte n = 0; + while (true) + { + b = ReadByte(); + // 必须转为Int16,否则可能溢出 + rs += (Int16)((b & 0x7f) << n); + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 16) throw new FormatException("The number value is too large to read in compressed format!"); + } + return rs; + } + + /// 以压缩格式读取32位整数 + /// + public Int32 ReadEncodedInt32() + { + Byte b; + var rs = 0; + Byte n = 0; + while (true) + { + b = ReadByte(); + // 必须转为Int32,否则可能溢出 + rs += (b & 0x7f) << n; + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 32) throw new FormatException("The number value is too large to read in compressed format!"); + } + return rs; + } + + /// 以压缩格式读取64位整数 + /// + public Int64 ReadEncodedInt64() + { + Byte b; + Int64 rs = 0; + Byte n = 0; + while (true) + { + b = ReadByte(); + // 必须转为Int64,否则可能溢出 + rs += (Int64)(b & 0x7f) << n; + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 64) throw new FormatException("The number value is too large to read in compressed format!"); + } + return rs; + } + #endregion + + #region 专用扩展 + /// 读取无符号短整数 + /// + public UInt16 ReadUInt16() => Read(); + + /// 读取短整数 + /// + public Int16 ReadInt16() => Read(); + + /// 读取无符号整数 + /// + public UInt32 ReadUInt32() => Read(); + + /// 读取整数 + /// + public Int32 ReadInt32() => Read(); + + /// 写入字节 + /// + public void WriteByte(Byte value) => Write(value); + + /// 写入无符号短整数 + /// + public void WriteUInt16(UInt16 value) => Write(value); + + /// 写入短整数 + /// + public void WriteInt16(Int16 value) => Write(value); + + /// 写入无符号整数 + /// + public void WriteUInt32(UInt32 value) => Write(value); + + /// 写入整数 + /// + public void WriteInt32(Int32 value) => Write(value); + + /// BCD字节转十进制数字 + /// + /// + public static Int32 FromBCD(Byte b) => (b >> 4) * 10 + (b & 0x0F); + + /// 十进制数字转BCD字节 + /// + /// + public static Byte ToBCD(Int32 n) => (Byte)(((n / 10) << 4) | (n % 10)); + + /// 读取指定长度的BCD字符串。BCD每个字节存放两个数字 + /// + /// + public String ReadBCD(Int32 len) + { + var buf = ReadBytes(len); + var cs = new Char[len * 2]; + for (var i = 0; i < len; i++) + { + cs[i * 2] = (Char)('0' + (buf[i] >> 4)); + cs[i * 2 + 1] = (Char)('0' + (buf[i] & 0x0F)); + } + + return new String(cs).Trim('\0'); + } + + /// 写入指定长度的BCD字符串。BCD每个字节存放两个数字 + /// + /// + public void WriteBCD(String value, Int32 max) + { + var buf = Pool.Shared.Rent(max); + for (Int32 i = 0, j = 0; i < max && j + 1 < value.Length; i++, j += 2) + { + var a = (Byte)(value[j] - '0'); + var b = (Byte)(value[j + 1] - '0'); + buf[i] = (Byte)((a << 4) | (b & 0x0F)); + } + + Write(buf, 0, max); + Pool.Shared.Return(buf); + } + + /// 写入定长字符串。多余截取,少则补零 + /// + /// + public void WriteFixedString(String? value, Int32 max) + { + var buf = Pool.Shared.Rent(max); + if (!value.IsNullOrEmpty()) Encoding.GetBytes(value, 0, value.Length, buf, 0); + + Write(buf, 0, max); + Pool.Shared.Return(buf); + } + + /// 读取定长字符串。多余截取,少则补零 + /// + /// + public String ReadFixedString(Int32 len) + { + var buf = ReadBytes(len); + + // 得到实际长度,在读取-1全部字符串时也能剔除首尾的0x00和0xFF + if (len < 0) len = buf.Length; + + // 剔除头尾非法字符 + Int32 s, e; + for (s = 0; s < len && (buf[s] == 0x00 || buf[s] == 0xFF); s++) ; + for (e = len - 1; e >= 0 && (buf[e] == 0x00 || buf[e] == 0xFF); e--) ; + + if (s >= len || e < 0) return String.Empty; + + var str = Encoding.GetString(buf, s, e - s + 1); + if (TrimZero && str != null) str = str.Trim('\0'); + + return str ?? String.Empty; + } + #endregion + + #region 辅助 + /// 是否已达到末尾 + /// + public Boolean EndOfStream() => Stream.Position >= Stream.Length; + + /// 检查剩余量是否足够 + /// + /// + public Boolean CheckRemain(Int32 size) => Stream.Position + size <= Stream.Length; + #endregion + + #region 快捷方法 + /// 快速读取 + /// + /// 数据流 + /// 使用7位编码整数 + /// + public static T? FastRead(Stream stream, Boolean encodeInt = true) + { + var bn = new Binary() { Stream = stream, EncodeInt = encodeInt }; + return bn.Read(); + } + + /// 快速写入 + /// 对象 + /// 目标数据流 + /// 使用7位编码整数 + /// + public static void FastWrite(Object value, Stream stream, Boolean encodeInt = true) + { + var bn = new Binary + { + Stream = stream, + EncodeInt = encodeInt, + }; + bn.Write(value); + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryColor.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryColor.cs new file mode 100644 index 000000000..950d2ac49 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryColor.cs @@ -0,0 +1,48 @@ +using System.Drawing; + +namespace ThingsGateway.NewLife.Serialization; + +/// 颜色处理器。 +public class BinaryColor : BinaryHandlerBase +{ + /// 实例化 + public BinaryColor() => Priority = 50; + + /// 写入对象 + /// 目标对象 + /// 类型 + /// + public override Boolean Write(Object? value, Type type) + { + if (type != typeof(Color)) return false; + + var color = (Color)(value ?? Color.Empty); + WriteLog("WriteColor {0}", color); + + Host.Write(color.A); + Host.Write(color.R); + Host.Write(color.G); + Host.Write(color.B); + + return true; + } + + /// 尝试读取指定类型对象 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (type != typeof(Color)) return false; + + var a = Host.ReadByte(); + var r = Host.ReadByte(); + var g = Host.ReadByte(); + var b = Host.ReadByte(); + var color = Color.FromArgb(a, r, g, b); + WriteLog("ReadColor {0}", color); + value = color; + + return true; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryComposite.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryComposite.cs new file mode 100644 index 000000000..08f591f47 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryComposite.cs @@ -0,0 +1,253 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +using ThingsGateway.NewLife.Reflection; +using ThingsGateway.NewLife.Serialization.Interface; + +namespace ThingsGateway.NewLife.Serialization; + +/// 复合对象处理器 +public class BinaryComposite : BinaryHandlerBase +{ + /// 实例化 + public BinaryComposite() => Priority = 100; + + /// 写入对象 + /// 目标对象 + /// 类型 + /// + public override Boolean Write(Object? value, Type type) + { + // 不支持基本类型 + if (type.IsBaseType()) return false; + + var ims = Host.IgnoreMembers; + + var ms = GetMembers(type).Where(e => !ims.Contains(e.Name)).ToList(); + WriteLog("BinaryWrite类{0} 共有成员{1}个", type.Name, ms.Count); + + if (Host is Binary b && b.UseFieldSize && value != null) + { + // 遍历成员,寻找FieldSizeAttribute特性,重新设定大小字段的值 + foreach (var member in ms) + { + // 获取FieldSizeAttribute特性 + var atts = member.GetCustomAttributes(); + if (atts != null) + { + foreach (var att in atts) + { + if (!att.ReferenceName.IsNullOrEmpty() && + (att.Version.IsNullOrEmpty() || att.Version == b.Version)) + att.SetReferenceSize(value, member, Host.Encoding); + } + } + } + } + + // 如果不是第一层,这里开始必须写对象引用 + if (WriteRef(value)) return true; + + if (value == null) return true; + + Host.Hosts.Push(value); + + var context = new AccessorContext + { + Host = Host, + Type = type, + Value = value, + UserState = Host.UserState + }; + + // 获取成员 + foreach (var member in ms) + { + var mtype = GetMemberType(member); + context.Member = Host.Member = member; + + var v = value.GetValue(member); + WriteLog(" {0}.{1} {2}", type.Name, member.Name, v); + + // 成员访问器优先 + if (value is IMemberAccessor ac && ac.Write(Host, context)) continue; + if (TryGetAccessor(member, out var acc) && acc.Write(Host, context)) continue; + + if (!Host.Write(v, mtype)) + { + Host.Hosts.Pop(); + return false; + } + } + Host.Hosts.Pop(); + + return true; + } + + private Boolean WriteRef(Object? value) + { + if (Host is Binary bn && !bn.UseRef) return false; + if (Host.Hosts.Count == 0) return false; + + if (value == null) + { + Host.Write(0); + return true; + } + + // 找到对象索引,并写入 + var hs = Host.Hosts.ToArray(); + for (var i = 0; i < hs.Length; i++) + { + if (value == hs[i]) + { + Host.WriteSize(i + 1); + return true; + } + } + + // 埋下自己 + Host.WriteSize(Host.Hosts.Count + 1); + + return false; + } + + /// 尝试读取指定类型对象 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (type == typeof(Object)) return false; + if (type == null) + { + if (value == null) return false; + type = value.GetType(); + } + + // 不支持基本类型 + if (type.IsBaseType()) return false; + + // 不支持基类不是Object的特殊类型 + if (!type.As()) return false; + + var ims = Host.IgnoreMembers; + + var ms = GetMembers(type).Where(e => !ims.Contains(e.Name)).ToList(); + WriteLog("BinaryRead类{0} 共有成员{1}个", type.Name, ms.Count); + + // 读取对象引用 + if (ReadRef(ref value)) return true; + + value ??= type.CreateInstance(); + if (value == null) return true; + + Host.Hosts.Push(value); + + var context = new AccessorContext + { + Host = Host, + Type = type, + Value = value, + UserState = Host.UserState + }; + + // 获取成员 + for (var i = 0; i < ms.Count; i++) + { + var member = ms[i]; + + var mtype = GetMemberType(member); + context.Member = Host.Member = member; + WriteLog(" {0}.{1}", member.DeclaringType?.Name, member.Name); + + // 成员访问器优先 + if (value is IMemberAccessor ac && ac.Read(Host, context)) continue; + if (TryGetAccessor(member, out var acc) && acc.Read(Host, context)) continue; + + // 数据流不足时,放弃读取目标成员,并认为整体成功 + var hs = Host.Stream; + if (hs.CanSeek && hs.Position >= hs.Length) break; + + var v = value.GetValue(member); + if (!Host.TryRead(mtype, ref v)) + { + Host.Hosts.Pop(); + return false; + } + + value.SetValue(member, v); + } + Host.Hosts.Pop(); + + return true; + } + + private Boolean ReadRef(ref Object? value) + { + if (Host.Hosts.Count == 0) return false; + if (Host is not Binary bn) return false; + + if (!bn.UseRef) return false; + + var rf = bn.ReadEncodedInt32(); + if (rf == 0) + { + //value = null; + return true; + } + + // 找到对象索引 + var hs = Host.Hosts.ToArray(); + // 如果引用是对象数加一,说明有对象紧跟着 + if (rf == hs.Length + 1) return false; + + if (rf < 0 || rf > hs.Length) throw new XException("Unable to find reference {1} in {0} objects", hs.Length, rf); + + value = hs[rf - 1]; + + return true; + } + + #region 获取成员 + /// 获取成员 + /// + /// + /// + protected virtual List GetMembers(Type type, Boolean baseFirst = true) + { + if (Host.UseProperty) + return type.GetProperties(baseFirst).Cast().ToList(); + else + return type.GetFields(baseFirst).Cast().ToList(); + } + + private static Type GetMemberType(MemberInfo member) + { + //return member.MemberType switch + //{ + // MemberTypes.Field => (member as FieldInfo).FieldType, + // MemberTypes.Property => (member as PropertyInfo).PropertyType, + // _ => throw new NotSupportedException(), + //}; + if (member is FieldInfo fi) return fi.FieldType; + if (member is PropertyInfo pi) return pi.PropertyType; + + throw new NotSupportedException(); + } + + private static readonly ConcurrentDictionary _cache = new(); + private static Boolean TryGetAccessor(MemberInfo member, [NotNullWhen(true)] out IMemberAccessor? acc) + { + if (_cache.TryGetValue(member, out acc)) return acc != null; + + var atts = member.GetCustomAttributes(); + acc = atts.FirstOrDefault(e => e is IMemberAccessor) as IMemberAccessor; + + _cache[member] = acc; + + return acc != null; + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryDictionary.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryDictionary.cs new file mode 100644 index 000000000..0400d486f --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryDictionary.cs @@ -0,0 +1,83 @@ +using System.Collections; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 字典数据编码 +public class BinaryDictionary : BinaryHandlerBase +{ + /// 初始化 + public BinaryDictionary() => Priority = 30; + + /// 写入一个对象 + /// 目标对象 + /// 类型 + /// + public override Boolean Write(Object? value, Type type) + { + if (value is not IDictionary dic) return false; + + // 先写入长度 + if (dic.Count == 0) + { + Host.WriteSize(0); + return true; + } + + Host.WriteSize(dic.Count); + + // 循环写入数据 + foreach (var item in dic) + { + if (item is DictionaryEntry de) + { + Host.Write(de.Key); + Host.Write(de.Value); + } + } + + return true; + } + + /// 尝试读取指定类型对象 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (!type.As() && !type.As(typeof(IDictionary<,>))) return false; + + // 子元素类型 + var gs = type.GetGenericArguments(); + if (gs.Length != 2) throw new NotSupportedException($"Dictionary types only support {typeof(Dictionary<,>).FullName}"); + + var keyType = gs[0]; + var valType = gs[1]; + + // 先读取长度 + var count = Host.ReadSize(); + if (count == 0) return true; + + // 创建字典 + if (value == null && type != null) + { + value = type.CreateInstance(); + } + + if (value is IDictionary dic) + { + for (var i = 0; i < count; i++) + { + Object? key = null; + Object? val = null; + if (!Host.TryRead(keyType, ref key) || key == null) return false; + if (!Host.TryRead(valType, ref val)) return false; + + dic[key] = val; + } + } + + return true; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryFont.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryFont.cs new file mode 100644 index 000000000..b4bb8f37e --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryFont.cs @@ -0,0 +1,60 @@ +#if WIN +using System; +using System.Drawing; + +namespace ThingsGateway.NewLife.Serialization; + +/// 字体处理器。 +public class BinaryFont : BinaryHandlerBase +{ + /// 实例化 + public BinaryFont() => Priority = 50; + + /// 写入对象 + /// 目标对象 + /// 类型 + /// + public override Boolean Write(Object? value, Type type) + { + if (type != typeof(Font)) return false; + + // 写入引用 + if (value == null || value is not Font font) + { + Host.WriteSize(0); + return true; + } + Host.WriteSize(1); + + //var font = value as Font; + WriteLog("WriteFont {0}", font); + + Host.Write(font.Name); + Host.Write(font.Size); + Host.Write((Byte)font.Style); + + return true; + } + + /// 尝试读取指定类型对象 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (type != typeof(Font)) return false; + + // 读引用 + var size = Host.ReadSize(); + if (size == 0) return true; + + if (size != 1) WriteLog("读取引用应该是1,而实际是{0}", size); + + var font = new Font(Host.Read() ?? String.Empty, Host.Read(), (FontStyle)Host.ReadByte()); + value = font; + WriteLog("ReadFont {0}", font); + + return true; + } +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryGeneral.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryGeneral.cs new file mode 100644 index 000000000..6adf71c93 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryGeneral.cs @@ -0,0 +1,674 @@ +using System.Text; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 二进制基础类型处理器 +public class BinaryGeneral : BinaryHandlerBase +{ + private static readonly DateTime _dt1970 = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// 实例化 + public BinaryGeneral() => Priority = 10; + + /// 写入一个对象 + /// 目标对象 + /// 类型 + /// 是否处理成功 + public override Boolean Write(Object? value, Type type) + { + //if (value == null && type != typeof(String)) return false; + + // 可空类型,先写入一个字节表示是否为空 + if (type.IsNullable()) + { + if (value == null) + { + Host.Write((Byte)0); + return true; + } + else + Host.Write((Byte)1); + } + + switch (type.GetTypeCode()) + { + case TypeCode.Boolean: + Host.Write((Byte)(value != null && (Boolean)value ? 1 : 0)); + return true; + case TypeCode.Byte: + case TypeCode.SByte: + Host.Write(Convert.ToByte(value)); + return true; + case TypeCode.Char: + Write((Char)(value ?? 0)); + return true; + case TypeCode.DBNull: + case TypeCode.Empty: + Host.Write(0); + return true; + case TypeCode.DateTime: + if (Host is Binary bn && bn.FullTime) + { + if (value is DateTime dt) + Write(dt.ToBinary()); + else + Write((Int64)0); + } + else + { + if (value is DateTime dt && dt > DateTime.MinValue) + { + var seconds = (dt - _dt1970).TotalSeconds; + if (seconds >= UInt32.MaxValue) throw new InvalidDataException("Cannot serialize time less than 1970, please use FullTime"); + + Write((UInt32)seconds); + } + else + Write((UInt32)0); + } + return true; + case TypeCode.Decimal: + Write((Decimal)(value ?? 0)); + return true; + case TypeCode.Double: + Write((Double)(value ?? 0)); + return true; + case TypeCode.Int16: + Write((Int16)(value ?? 0)); + return true; + case TypeCode.Int32: + Write((Int32)(value ?? 0)); + return true; + case TypeCode.Int64: + Write((Int64)(value ?? 0)); + return true; + case TypeCode.Object: + break; + case TypeCode.Single: + Write((Single)(value ?? 0)); + return true; + case TypeCode.String: + Write((String)(value ?? String.Empty)); + return true; + case TypeCode.UInt16: + Write((UInt16)(value ?? 0)); + return true; + case TypeCode.UInt32: + Write((UInt32)(value ?? 0)); + return true; + case TypeCode.UInt64: + Write((UInt64)(value ?? 0)); + return true; + default: + break; + } + + return false; + } + + /// 尝试读取指定类型对象 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (type == null) + { + if (value == null) return false; + type = value.GetType(); + } + + // 可空类型,先写入一个字节表示是否为空 + if (type.IsNullable()) + { + var v = Host.ReadByte(); + if (v == 0) + { + value = null; + return true; + } + } + + var code = type.GetTypeCode(); + switch (code) + { + case TypeCode.Boolean: + value = Host.ReadByte() > 0; + return true; + case TypeCode.Byte: + case TypeCode.SByte: + value = Host.ReadByte(); + return true; + case TypeCode.Char: + value = ReadChar(); + return true; + case TypeCode.DBNull: + value = DBNull.Value; + return true; + case TypeCode.DateTime: + if (Host is Binary bn && bn.FullTime) + { + var n = ReadInt64(); + value = DateTime.FromBinary(n); + } + else + { + var n = ReadUInt32(); + if (n == 0) + value = DateTime.MinValue; + else + value = _dt1970.AddSeconds(n); + } + return true; + case TypeCode.Decimal: + value = ReadDecimal(); + return true; + case TypeCode.Double: + value = ReadDouble(); + return true; + case TypeCode.Empty: + value = null; + return true; + case TypeCode.Int16: + value = ReadInt16(); + return true; + case TypeCode.Int32: + value = ReadInt32(); + return true; + case TypeCode.Int64: + value = ReadInt64(); + return true; + case TypeCode.Object: + break; + case TypeCode.Single: + value = ReadSingle(); + return true; + case TypeCode.String: + value = ReadString(); + return true; + case TypeCode.UInt16: + value = ReadUInt16(); + return true; + case TypeCode.UInt32: + value = ReadUInt32(); + return true; + case TypeCode.UInt64: + value = ReadUInt64(); + return true; + default: + break; + } + + return false; + } + + #region 基元类型写入 + #region 字节 + /// 将一个无符号字节写入 + /// 要写入的无符号字节。 + public virtual void Write(Byte value) => Host.Write(value); + + /// 将字节数组写入,如果设置了UseSize,则先写入数组长度。 + /// 包含要写入的数据的字节数组。 + public virtual void Write(Byte[] buffer) + { + // 可能因为FieldSize设定需要补充0字节 + if (buffer == null || buffer.Length == 0) + { + var size = Host.WriteSize(0); + if (size > 0) Host.Write(new Byte[size], 0, -1); + } + else + { + var size = Host.WriteSize(buffer.Length); + if (size > 0) + { + // 写入数据,超长截断,不足补0 + if (buffer.Length >= size) + Host.Write(buffer, 0, size); + else + { + Host.Write(buffer, 0, buffer.Length); + Host.Write(new Byte[size - buffer.Length], 0, -1); + } + } + else + { + // 非FieldSize写入 + Host.Write(buffer, 0, buffer.Length); + } + } + } + + /// 将字节数组部分写入当前流,不写入数组长度。 + /// 包含要写入的数据的字节数组。 + /// buffer 中开始写入的起始点。 + /// 要写入的字节数。 + public virtual void Write(Byte[] buffer, Int32 offset, Int32 count) + { + if (buffer == null || buffer.Length <= 0 || count <= 0 || offset >= buffer.Length) return; + + Host.Write(buffer, offset, count); + } + + /// 写入字节数组,自动计算长度 + /// 缓冲区 + /// 数量 + private void Write(Byte[] buffer, Int32 count) + { + if (buffer == null) return; + + if (count < 0 || count > buffer.Length) count = buffer.Length; + + Write(buffer, 0, count); + } + #endregion + + #region 有符号整数 + /// 将 2 字节有符号整数写入当前流,并将流的位置提升 2 个字节。 + /// 要写入的 2 字节有符号整数。 + public virtual void Write(Int16 value) + { + if (Host.EncodeInt) + WriteEncoded(value); + else + WriteIntBytes(BitConverter.GetBytes(value)); + } + + /// 将 4 字节有符号整数写入当前流,并将流的位置提升 4 个字节。 + /// 要写入的 4 字节有符号整数。 + public virtual void Write(Int32 value) + { + if (Host.EncodeInt) + WriteEncoded(value); + else + WriteIntBytes(BitConverter.GetBytes(value)); + } + + /// 将 8 字节有符号整数写入当前流,并将流的位置提升 8 个字节。 + /// 要写入的 8 字节有符号整数。 + public virtual void Write(Int64 value) + { + if (Host.EncodeInt) + WriteEncoded(value); + else + WriteIntBytes(BitConverter.GetBytes(value)); + } + + /// 判断字节顺序 + /// 缓冲区 + private void WriteIntBytes(Byte[] buffer) + { + if (buffer == null || buffer.Length <= 0) return; + + // 如果不是小端字节顺序,则倒序 + if (!Host.IsLittleEndian) Array.Reverse(buffer); + + Write(buffer, 0, buffer.Length); + } + #endregion + + #region 无符号整数 + /// 将 2 字节无符号整数写入当前流,并将流的位置提升 2 个字节。 + /// 要写入的 2 字节无符号整数。 + //[CLSCompliant(false)] + public virtual void Write(UInt16 value) => Write((Int16)value); + + /// 将 4 字节无符号整数写入当前流,并将流的位置提升 4 个字节。 + /// 要写入的 4 字节无符号整数。 + //[CLSCompliant(false)] + public virtual void Write(UInt32 value) => Write((Int32)value); + + /// 将 8 字节无符号整数写入当前流,并将流的位置提升 8 个字节。 + /// 要写入的 8 字节无符号整数。 + //[CLSCompliant(false)] + public virtual void Write(UInt64 value) => Write((Int64)value); + #endregion + + #region 浮点数 + /// 将 4 字节浮点值写入当前流,并将流的位置提升 4 个字节。 + /// 要写入的 4 字节浮点值。 + public virtual void Write(Single value) => Write(BitConverter.GetBytes(value), -1); + + /// 将 8 字节浮点值写入当前流,并将流的位置提升 8 个字节。 + /// 要写入的 8 字节浮点值。 + public virtual void Write(Double value) => Write(BitConverter.GetBytes(value), -1); + + /// 将一个十进制值写入当前流,并将流位置提升十六个字节。 + /// 要写入的十进制值。 + protected virtual void Write(Decimal value) + { + var data = Decimal.GetBits(value); + for (var i = 0; i < data.Length; i++) + { + Write(data[i]); + } + } + #endregion + + #region 字符串 + /// 将 Unicode 字符写入当前流,并根据所使用的 Encoding 和向流中写入的特定字符,提升流的当前位置。 + /// 要写入的非代理项 Unicode 字符。 + public virtual void Write(Char ch) => Write(Convert.ToByte(ch)); + + /// 将字符数组部分写入当前流,并根据所使用的 Encoding(可能还根据向流中写入的特定字符),提升流的当前位置。 + /// 包含要写入的数据的字符数组。 + /// chars 中开始写入的起始点。 + /// 要写入的字符数。 + public virtual void Write(Char[] chars, Int32 index, Int32 count) + { + if (chars == null) + { + //Host.WriteSize(0); + // 可能因为FieldSize设定需要补充0字节 + Write([]); + return; + } + + if (chars.Length <= 0 || count <= 0 || index >= chars.Length) + { + //Host.WriteSize(0); + // 可能因为FieldSize设定需要补充0字节 + Write([]); + return; + } + + // 先用写入字节长度 + var buffer = Host.Encoding.GetBytes(chars, index, count); + Write(buffer); + } + + /// 写入字符串 + /// 要写入的值。 + public virtual void Write(String value) + { + if (value == null || value.Length == 0) + { + //Host.WriteSize(0); + Write([]); + return; + } + + // 先用写入字节长度 + var buffer = Host.Encoding.GetBytes(value); + Write(buffer); + } + #endregion + #endregion + + #region 基元类型读取 + #region 字节 + /// 从当前流中读取下一个字节,并使流的当前位置提升 1 个字节。 + /// + public virtual Byte ReadByte() => Host.ReadByte(); + + /// 从当前流中将 count 个字节读入字节数组,如果count小于0,则先读取字节数组长度。 + /// 要读取的字节数。 + /// + public virtual Byte[] ReadBytes(Int32 count) + { + if (count < 0) count = Host.ReadSize(); + + if (count <= 0) return []; + + var max = IOHelper.MaxSafeArraySize; + if (count > max) throw new XException("Security required, reading large variable length arrays is not allowed {0:n0}>{1:n0}", count, max); + + var buffer = Host.ReadBytes(count); + + return buffer; + } + #endregion + + #region 有符号整数 + /// 读取整数的字节数组,某些写入器(如二进制写入器)可能需要改变字节顺序 + /// 数量 + /// + protected virtual Byte[] ReadIntBytes(Int32 count) + { + var buffer = ReadBytes(count); + + // 如果不是小端字节顺序,则倒序 + if (!Host.IsLittleEndian) Array.Reverse(buffer); + + return buffer; + } + + /// 从当前流中读取 2 字节有符号整数,并使流的当前位置提升 2 个字节。 + /// + public virtual Int16 ReadInt16() + { + if (Host.EncodeInt) + return ReadEncodedInt16(); + else + return BitConverter.ToInt16(ReadIntBytes(2), 0); + } + + /// 从当前流中读取 4 字节有符号整数,并使流的当前位置提升 4 个字节。 + /// + public virtual Int32 ReadInt32() + { + if (Host.EncodeInt) + return ReadEncodedInt32(); + else + return BitConverter.ToInt32(ReadIntBytes(4), 0); + } + + /// 从当前流中读取 8 字节有符号整数,并使流的当前位置向前移动 8 个字节。 + /// + public virtual Int64 ReadInt64() + { + if (Host.EncodeInt) + return ReadEncodedInt64(); + else + return BitConverter.ToInt64(ReadIntBytes(8), 0); + } + #endregion + + #region 无符号整数 + /// 使用 Little-Endian 编码从当前流中读取 2 字节无符号整数,并将流的位置提升 2 个字节。 + /// + //[CLSCompliant(false)] + public virtual UInt16 ReadUInt16() => (UInt16)ReadInt16(); + + /// 从当前流中读取 4 字节无符号整数并使流的当前位置提升 4 个字节。 + /// + //[CLSCompliant(false)] + public virtual UInt32 ReadUInt32() => (UInt32)ReadInt32(); + + /// 从当前流中读取 8 字节无符号整数并使流的当前位置提升 8 个字节。 + /// + //[CLSCompliant(false)] + public virtual UInt64 ReadUInt64() => (UInt64)ReadInt64(); + #endregion + + #region 浮点数 + /// 从当前流中读取 4 字节浮点值,并使流的当前位置提升 4 个字节。 + /// + public virtual Single ReadSingle() => BitConverter.ToSingle(ReadBytes(4), 0); + + /// 从当前流中读取 8 字节浮点值,并使流的当前位置提升 8 个字节。 + /// + public virtual Double ReadDouble() => BitConverter.ToDouble(ReadBytes(8), 0); + #endregion + + #region 字符串 + /// 从当前流中读取下一个字符,并根据所使用的 Encoding 和从流中读取的特定字符,提升流的当前位置。 + /// + public virtual Char ReadChar() => Convert.ToChar(ReadByte()); + + /// 从当前流中读取一个字符串。字符串有长度前缀,7位压缩编码整数。 + /// + public virtual String ReadString() + { + // 先读长度 + var n = Host.ReadSize(); + //if (n > 1000) n = Host.ReadSize(); + if (n <= 0) return String.Empty; + //if (n == 0) return String.Empty; + + var buffer = ReadBytes(n); + var enc = Host.Encoding ?? Encoding.UTF8; + + var str = enc.GetString(buffer); + if (Host is Binary bn && bn.TrimZero && str != null) str = str.Trim('\0'); + + return str ?? String.Empty; + } + #endregion + + #region 其它 + /// 从当前流中读取十进制数值,并将该流的当前位置提升十六个字节。 + /// + public virtual Decimal ReadDecimal() + { + var data = new Int32[4]; + for (var i = 0; i < data.Length; i++) + { + data[i] = ReadInt32(); + } + return new Decimal(data); + } + #endregion + + #region 7位压缩编码整数 + /// 以压缩格式读取16位整数 + /// + public Int16 ReadEncodedInt16() + { + Byte b; + Int16 rs = 0; + Byte n = 0; + while (true) + { + b = ReadByte(); + // 必须转为Int16,否则可能溢出 + rs += (Int16)((b & 0x7f) << n); + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 16) throw new FormatException("The number value is too large to read in compressed format!"); + } + return rs; + } + + /// 以压缩格式读取32位整数 + /// + public Int32 ReadEncodedInt32() + { + Byte b; + var rs = 0; + Byte n = 0; + while (true) + { + b = ReadByte(); + // 必须转为Int32,否则可能溢出 + rs += (b & 0x7f) << n; + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 32) throw new FormatException("The number value is too large to read in compressed format!"); + } + return rs; + } + + /// 以压缩格式读取64位整数 + /// + public Int64 ReadEncodedInt64() + { + Byte b; + Int64 rs = 0; + Byte n = 0; + while (true) + { + b = ReadByte(); + // 必须转为Int64,否则可能溢出 + rs += (Int64)(b & 0x7f) << n; + if ((b & 0x80) == 0) break; + + n += 7; + if (n >= 64) throw new FormatException("The number value is too large to read in compressed format!"); + } + return rs; + } + #endregion + #endregion + + #region 7位压缩编码整数 + [ThreadStatic] + private static Byte[]? _encodes; + /// + /// 以7位压缩格式写入16位整数,小于7位用1个字节,小于14位用2个字节。 + /// 由每次写入的一个字节的第一位标记后面的字节是否还是当前数据,所以每个字节实际可利用存储空间只有后7位。 + /// + /// 数值 + /// 实际写入字节数 + public Int32 WriteEncoded(Int16 value) + { + _encodes ??= new Byte[16]; + + var count = 0; + var num = (UInt16)value; + while (num >= 0x80) + { + _encodes[count++] = (Byte)(num | 0x80); + num = (UInt16)(num >> 7); + } + _encodes[count++] = (Byte)num; + + Write(_encodes, 0, count); + + return count; + } + + /// + /// 以7位压缩格式写入32位整数,小于7位用1个字节,小于14位用2个字节。 + /// 由每次写入的一个字节的第一位标记后面的字节是否还是当前数据,所以每个字节实际可利用存储空间只有后7位。 + /// + /// 数值 + /// 实际写入字节数 + public Int32 WriteEncoded(Int32 value) + { + _encodes ??= new Byte[16]; + + var count = 0; + var num = (UInt32)value; + while (num >= 0x80) + { + _encodes[count++] = (Byte)(num | 0x80); + num >>= 7; + } + _encodes[count++] = (Byte)num; + + Write(_encodes, 0, count); + + return count; + } + + /// + /// 以7位压缩格式写入64位整数,小于7位用1个字节,小于14位用2个字节。 + /// 由每次写入的一个字节的第一位标记后面的字节是否还是当前数据,所以每个字节实际可利用存储空间只有后7位。 + /// + /// 数值 + /// 实际写入字节数 + public Int32 WriteEncoded(Int64 value) + { + _encodes ??= new Byte[16]; + + var count = 0; + var num = (UInt64)value; + while (num >= 0x80) + { + _encodes[count++] = (Byte)(num | 0x80); + num >>= 7; + } + _encodes[count++] = (Byte)num; + + Write(_encodes, 0, count); + + return count; + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryList.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryList.cs new file mode 100644 index 000000000..ac46ee39d --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryList.cs @@ -0,0 +1,83 @@ +using System.Collections; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 列表数据编码 +public class BinaryList : BinaryHandlerBase +{ + /// 初始化 + public BinaryList() => Priority = 20; + + /// 写入 + /// + /// + /// + public override Boolean Write(Object? value, Type type) + { + if (!type.As() && value is not IList) return false; + + // 先写入长度 + if (value is not IList list || list.Count == 0) + { + Host.WriteSize(0); + return true; + } + + Host.WriteSize(list.Count); + + // 循环写入数据 + foreach (var item in list) + { + Host.Write(item); + } + + return true; + } + + /// 读取 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (!type.As() && !type.As(typeof(IList<>))) return false; + + // 先读取长度 + var count = Host.ReadSize(); + if (count == 0) return true; + + // 子元素类型 + var elmType = type.GetElementTypeEx(); + + if (value == null) + { + // 数组的创建比较特别 + if (type.As() && elmType != null) + { + value = Array.CreateInstance(elmType, count); + } + else + value = type.CreateInstance(); + } + + if (elmType == null) return false; + if (value is not IList list) return false; + + // 如果是数组,则需要先加起来,再 + //if (value is Array) list = typeof(IList<>).MakeGenericType(value.GetType().GetElementTypeEx()).CreateInstance() as IList; + for (var i = 0; i < count; i++) + { + Object? obj = null; + if (!Host.TryRead(elmType, ref obj)) return false; + + if (value is Array) + list[i] = obj; + else + list.Add(obj); + } + + return true; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryNormal.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryNormal.cs new file mode 100644 index 000000000..e89753148 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/BinaryNormal.cs @@ -0,0 +1,169 @@ +using System.Net; + +namespace ThingsGateway.NewLife.Serialization; + +/// 常用类型编码 +public class BinaryNormal : BinaryHandlerBase +{ + /// 初始化 + public BinaryNormal() => Priority = 12; + + /// 写入 + /// + /// + /// + public override Boolean Write(Object? value, Type type) + { + if (type == typeof(Guid)) + { + if (value is not Guid guid) guid = Guid.Empty; + Write(guid.ToByteArray(), -1); + return true; + } + else if (type == typeof(Byte[]) && value is Byte[] buf) + { + //Write((Byte[])value); + if (Host is Binary bn) + { + var bc = bn.GetHandler(); + bc?.Write(buf); + } + + return true; + } + else if (type == typeof(Char[]) && value is Char[] cs) + { + //Write((Char[])value); + if (Host is Binary bn) + { + var bc = bn.GetHandler(); + bc?.Write(cs, 0, -1); + } + + return true; + } + else if (type == typeof(DateTimeOffset) && value is DateTimeOffset dto) + { + Host.Write(dto.DateTime); + Host.Write(dto.Offset); + return true; + } +#if NET6_0_OR_GREATER + else if (type == typeof(DateOnly) && value is DateOnly date) + { + Host.Write(date.DayNumber); + return true; + } + else if (type == typeof(TimeOnly) && value is TimeOnly time) + { + Host.Write(time.Ticks); + return true; + } +#endif + else if (type == typeof(IPAddress) && value is IPAddress addr) + { + Host.Write(addr.GetAddressBytes()); + return true; + } + else if (type == typeof(IPEndPoint) && value is IPEndPoint ep) + { + Host.Write(ep.Address.GetAddressBytes()); + Host.Write((UInt16)ep.Port); + return true; + } + + return false; + } + + /// 写入字节数组,自动计算长度 + /// 缓冲区 + /// 数量 + private void Write(Byte[] buffer, Int32 count) + { + if (buffer == null) return; + + if (count < 0 || count > buffer.Length) count = buffer.Length; + + Host.Write(buffer, 0, count); + } + + /// 读取 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (type == typeof(Guid)) + { + value = new Guid(ReadBytes(16)); + return true; + } + else if (type == typeof(Byte[])) + { + value = ReadBytes(-1); + return true; + } + else if (type == typeof(Char[])) + { + value = ReadChars(-1); + return true; + } + else if (type == typeof(DateTimeOffset)) + { + value = new DateTimeOffset(Host.Read(), Host.Read()); + return true; + } +#if NET6_0_OR_GREATER + else if (type == typeof(DateOnly)) + { + value = DateOnly.FromDayNumber(Host.Read()); + return true; + } + else if (type == typeof(TimeOnly)) + { + value = new TimeOnly(Host.Read()); + return true; + } +#endif + else if (type == typeof(IPAddress)) + { + value = new IPAddress(ReadBytes(-1)); + return true; + } + else if (type == typeof(IPEndPoint)) + { + var ip = new IPAddress(ReadBytes(-1)); + var port = Host.Read(); + value = new IPEndPoint(ip, port); + return true; + } + + return false; + } + + /// 从当前流中将 count 个字节读入字节数组,如果count小于0,则先读取字节数组长度。 + /// 要读取的字节数。 + /// + protected virtual Byte[] ReadBytes(Int32 count) + { + if (Host is not Binary bn) throw new NotSupportedException(); + + var bc = bn.GetHandler(); + if (bc == null) throw new NotSupportedException(); + + return bc.ReadBytes(count); + } + + /// 从当前流中读取 count 个字符,以字符数组的形式返回数据,并根据所使用的 Encoding 和从流中读取的特定字符,提升当前位置。 + /// 要读取的字符数。 + /// + public virtual Char[] ReadChars(Int32 count) + { + if (count < 0) count = Host.ReadSize(); + + // 首先按最小值读取 + var data = ReadBytes(count); + + return Host.Encoding.GetChars(data); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/FieldSizeAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/FieldSizeAttribute.cs new file mode 100644 index 000000000..fd5996501 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/FieldSizeAttribute.cs @@ -0,0 +1,164 @@ +using System.Collections; +using System.Reflection; +using System.Text; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 字段大小特性。 +/// +/// 可以通过Size指定字符串或数组的固有大小,为0表示自动计算; +/// 也可以通过指定参考字段ReferenceName,然后从其中获取大小。 +/// 支持_Header._Questions形式的多层次引用字段。 +/// +/// 支持针对单个成员使用多个FieldSize特性,各自指定不同Version版本,以支持不同版本协议的序列化。 +/// 例如JT/T808协议,2011/2019版的相同字段使用不同长度。 +/// +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Enum, AllowMultiple = true)] +public sealed class FieldSizeAttribute : Attribute +{ + /// 大小。使用时,作为偏移量;0表示自动计算大小 + public Int32 Size { get; set; } + + /// 大小宽度。特定个数的字节表示长度,自动计算时(Size=0)使用,可选0/1/2/4 + public Int32 SizeWidth { get; set; } = -1; + + /// 参考大小字段名,其中存储了实际大小,使用时获取 + public String? ReferenceName { get; set; } + + /// 协议版本。用于支持多版本协议序列化。例如JT/T808的2011/2019 + public String? Version { get; set; } + + /// 通过Size指定字符串或数组的固有大小,为0表示自动计算 + /// + public FieldSizeAttribute(Int32 size) => Size = size; + + /// 指定参考字段ReferenceName,然后从其中获取大小 + /// + public FieldSizeAttribute(String referenceName) => ReferenceName = referenceName; + + /// 指定参考字段ReferenceName,然后从其中获取大小 + /// + /// 在参考字段值基础上的增量,可以是正数负数 + public FieldSizeAttribute(String referenceName, Int32 size) { ReferenceName = referenceName; Size = size; } + + /// 指定大小,指定协议版本,用于支持多版本协议序列化 + /// + /// + public FieldSizeAttribute(Int32 size, String version) + { + Size = size; + Version = version; + } + + #region 方法 + /// 找到所引用的参考字段 + /// 目标对象 + /// 目标对象的成员 + /// 数值 + /// + private MemberInfo? FindReference(Object target, MemberInfo member, out Object? value) + { + value = null; + + if (member == null) return null; + var name = ReferenceName; + if (name.IsNullOrEmpty()) return null; + + // 考虑ReferenceName可能是圆点分隔的多重结构 + MemberInfo? mi = null; + var type = member.DeclaringType; + if (type == null) return null; + + value = target; + var ss = name.Split('.'); + if (ss == null) return null; + + for (var i = 0; i < ss.Length; i++) + { + var pi = type.GetPropertyEx(ss[i]); + if (pi != null) + { + mi = pi; + type = pi.PropertyType; + } + else + { + var fi = type.GetFieldEx(ss[i]); + if (fi != null) + { + mi = fi; + type = fi.FieldType; + } + } + + // 最后一个不需要计算 + if (i < ss.Length - 1) + { + if (mi != null) value = value?.GetValue(mi); + } + } + + // 目标字段必须是整型 + var tc = type.GetTypeCode(); + if (tc is >= TypeCode.SByte and <= TypeCode.UInt64) return mi; + + return null; + } + + /// 设置目标对象的引用大小值 + /// 目标对象 + /// + /// + internal void SetReferenceSize(Object target, MemberInfo member, Encoding encoding) + { + var mi = FindReference(target, member, out var v); + if (mi == null || v == null) return; + + // 获取当前成员(加了特性)的值 + var value = target.GetValue(member); + if (value == null) return; + + // 尝试计算大小 + var size = 0; + if (value is String) + { + encoding ??= Encoding.UTF8; + + size = encoding.GetByteCount("" + value); + } + else if (value.GetType().IsArray && value is Array arr) + { + size = arr.Length; + } + else if (value is IEnumerable && value is IEnumerable em) + { + foreach (var item in em) + { + size++; + } + } + + // 给参考字段赋值 + v.SetValue(mi, size - Size); + } + + /// 获取目标对象的引用大小值 + /// 目标对象 + /// + /// + /// + internal Boolean TryGetReferenceSize(Object target, MemberInfo member, out Int32 size) + { + size = -1; + + var mi = FindReference(target, member, out var v); + if (mi == null || v == null) return false; + + size = Convert.ToInt32(v.GetValue(mi) ?? 0) + Size; + + return true; + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/IBinary.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/IBinary.cs new file mode 100644 index 000000000..e3f9cea3a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Binary/IBinary.cs @@ -0,0 +1,64 @@ +namespace ThingsGateway.NewLife.Serialization; + +/// 二进制序列化接口 +public interface IBinary : IFormatterX +{ + #region 属性 + /// 编码整数 + Boolean EncodeInt { get; set; } + + /// 小端字节序。默认false大端 + Boolean IsLittleEndian { get; set; } + + ///// 使用指定大小的FieldSizeAttribute特性,默认false + //Boolean UseFieldSize { get; set; } + + /// 要忽略的成员 + ICollection IgnoreMembers { get; set; } + + /// 处理器列表 + IList Handlers { get; } + #endregion + + #region 写入 + /// 写入字节 + /// + void Write(Byte value); + + /// 将字节数组部分写入当前流,不写入数组长度。 + /// 包含要写入的数据的字节数组。 + /// buffer 中开始写入的起始点。 + /// 要写入的字节数。 + void Write(Byte[] buffer, Int32 offset, Int32 count); + + /// 写入大小 + /// 要写入的大小值 + /// 返回特性指定的固定长度,如果没有则返回-1 + Int32 WriteSize(Int32 size); + #endregion + + #region 读取 + /// 读取字节 + /// + Byte ReadByte(); + + /// 从当前流中将 count 个字节读入字节数组 + /// 要读取的字节数。 + /// + Byte[] ReadBytes(Int32 count); + + /// 读取大小 + /// + Int32 ReadSize(); + #endregion +} + +/// 二进制读写处理器接口 +public interface IBinaryHandler : IHandler +{ +} + +/// 二进制读写处理器基类 +public abstract class BinaryHandlerBase : HandlerBase, IBinaryHandler +{ +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/DataMemberResolver.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/DataMemberResolver.cs new file mode 100644 index 000000000..b39fda228 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/DataMemberResolver.cs @@ -0,0 +1,59 @@ +#if NET7_0_OR_GREATER +using System.Reflection; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Xml.Serialization; + +namespace ThingsGateway.NewLife.Serialization; + +/// 数据成员解析器。让System.Text.Json增加对DataMemberAttribute和IgnoreDataMemberAttribute的支持 +public class DataMemberResolver : DefaultJsonTypeInfoResolver +{ + /// 默认解析器实例 + public static DataMemberResolver Default { get; } = new DataMemberResolver(); + + /// 获取类型信息 + /// + /// + /// + public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options) + { + var typeInfo = base.GetTypeInfo(type, options); + + if (typeInfo.Kind == JsonTypeInfoKind.Object && !type.IsArray) + { + Modifier(typeInfo); + } + + return typeInfo; + } + + /// 检测并修改成员信息 + /// + public static void Modifier(JsonTypeInfo typeInfo) + { + if (typeInfo.Kind != JsonTypeInfoKind.Object) return; + + foreach (var propertyInfo in typeInfo.Properties) + { + var provider = propertyInfo.AttributeProvider; + if (provider == null) continue; + + if (provider.IsDefined(typeof(IgnoreDataMemberAttribute), true) || + provider.IsDefined(typeof(XmlIgnoreAttribute), false)) + { + // 禁用 + propertyInfo.Get = null; + propertyInfo.Set = null; + } + else + { + var attr = provider.GetCustomAttributes(typeof(DataMemberAttribute), false)?.FirstOrDefault() as DataMemberAttribute; + if (attr != null && !attr.Name.IsNullOrEmpty()) + propertyInfo.Name = attr.Name; + } + } + } +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/AccessorAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/AccessorAttribute.cs new file mode 100644 index 000000000..21c362656 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/AccessorAttribute.cs @@ -0,0 +1,20 @@ +using ThingsGateway.NewLife.Serialization.Interface; + +namespace ThingsGateway.NewLife.Serialization +{ + /// 成员访问特性。使用自定义逻辑序列化成员 + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + public abstract class AccessorAttribute : Attribute, IMemberAccessor + { + /// 从数据流中读取消息 + /// 序列化 + /// 上下文 + /// 是否成功 + public virtual Boolean Read(IFormatterX formatter, AccessorContext context) => false; + + /// 把消息写入到数据流中 + /// 序列化 + /// 上下文 + public virtual Boolean Write(IFormatterX formatter, AccessorContext context) => false; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/AccessorContext.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/AccessorContext.cs new file mode 100644 index 000000000..dfe1ebcbc --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/AccessorContext.cs @@ -0,0 +1,22 @@ +using System.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 序列化访问上下文 +public class AccessorContext +{ + /// 宿主 + public IFormatterX? Host { get; set; } + + /// 对象类型 + public Type? Type { get; set; } + + /// 目标对象 + public Object? Value { get; set; } + + /// 成员 + public MemberInfo? Member { get; set; } + + /// 用户对象。存放序列化过程中使用的用户自定义对象 + public Object? UserState { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/FixedStringAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/FixedStringAttribute.cs new file mode 100644 index 000000000..57cc17094 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/FixedStringAttribute.cs @@ -0,0 +1,49 @@ +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 定长字符串序列化特性 +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class FixedStringAttribute : AccessorAttribute +{ + /// 长度 + public Int32 Length { get; set; } + + /// 定长字符串序列化 + /// + public FixedStringAttribute(Int32 length) => Length = length; + + /// 从数据流中读取消息 + /// 序列化 + /// 上下文 + /// 是否成功 + public override Boolean Read(IFormatterX formatter, AccessorContext context) + { + if (formatter is Binary bn && context.Value != null && context.Member != null) + { + var str = bn.ReadFixedString(Length); + context.Value.SetValue(context.Member, str); + + return true; + } + + return false; + } + + /// 把消息写入到数据流中 + /// 序列化 + /// 上下文 + public override Boolean Write(IFormatterX formatter, AccessorContext context) + { + if (formatter is Binary bn && context.Value != null && context.Member != null) + { + var str = context.Value.GetValue(context.Member) as String; + bn.WriteFixedString(str, Length); + + return true; + } + + return false; + } + +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/FullStringAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/FullStringAttribute.cs new file mode 100644 index 000000000..3da6e257f --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/FullStringAttribute.cs @@ -0,0 +1,50 @@ +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 完全字符串序列化特性。指示数据流剩下部分全部作为字符串读写 +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] +public sealed class FullStringAttribute : AccessorAttribute +{ + /// 从数据流中读取消息 + /// 序列化 + /// 上下文 + /// 是否成功 + public override Boolean Read(IFormatterX formatter, AccessorContext context) + { + if (formatter is Binary bn && context.Value != null && context.Member != null) + { + //var buf = bn.Stream.ReadBytes(-1); + //var str = bn.Encoding.GetString(buf); + var str = bn.Stream.ToStr(bn.Encoding); + if (bn.TrimZero && str != null) str = str.Trim('\0'); + + context.Value.SetValue(context.Member, str); + + return true; + } + + return false; + } + + /// 把消息写入到数据流中 + /// 序列化 + /// 上下文 + public override Boolean Write(IFormatterX formatter, AccessorContext context) + { + if (formatter is Binary bn && context.Value != null && context.Member != null) + { + var str = context.Value.GetValue(context.Member) as String; + if (!str.IsNullOrEmpty()) + { + var buf = bn.Encoding.GetBytes(str); + bn.Write(buf, 0, buf.Length); + } + + return true; + } + + return false; + } + +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/IAccessor.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/IAccessor.cs new file mode 100644 index 000000000..489df8711 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/IAccessor.cs @@ -0,0 +1,34 @@ +namespace ThingsGateway.NewLife.Serialization; + +/// 数据流序列化访问器。接口实现者可以在这里完全自定义序列化行为 +public interface IAccessor +{ + /// 从数据流中读取消息 + /// 数据流 + /// 上下文 + /// 是否成功 + Boolean Read(Stream stream, Object? context); + + /// 把消息写入到数据流中 + /// 数据流 + /// 上下文 + /// 是否成功 + Boolean Write(Stream stream, Object? context); +} + +/// 自定义数据序列化访问器。数据T支持Span/Memory等,接口实现者可以在这里完全自定义序列化行为 +public interface IAccessor +{ + /// 从数据中读取消息 + /// 数据 + /// 上下文 + /// 是否成功 + Boolean Read(T data, Object? context); + + /// 把消息写入到数据中 + /// 数据 + /// 上下文 + /// 是否成功 + Boolean Write(T data, Object? context); +} + diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/IFormatterX.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/IFormatterX.cs new file mode 100644 index 000000000..0851a866e --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/IFormatterX.cs @@ -0,0 +1,169 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text; + +using ThingsGateway.NewLife.Log; + +namespace ThingsGateway.NewLife.Serialization; + +/// 序列化接口 +public interface IFormatterX +{ + #region 属性 + /// 数据流 + Stream Stream { get; set; } + + /// 主对象 + Stack Hosts { get; } + + /// 成员 + MemberInfo? Member { get; set; } + + /// 字符串编码,默认utf-8 + Encoding Encoding { get; set; } + + /// 序列化属性而不是字段。默认true + Boolean UseProperty { get; set; } + + /// 用户对象。存放序列化过程中使用的用户自定义对象 + Object? UserState { get; set; } + #endregion + + #region 方法 + /// 写入一个对象 + /// 目标对象 + /// 类型 + /// + Boolean Write(Object? value, Type? type = null); + + /// 读取指定类型对象 + /// + /// + Object? Read(Type type); + + /// 读取指定类型对象 + /// + /// + T? Read(); + + /// 尝试读取指定类型对象 + /// + /// + /// + Boolean TryRead(Type type, ref Object? value); + #endregion + + #region 调试日志 + /// 日志提供者 + ILog Log { get; set; } + #endregion +} + +/// 序列化处理器接口 +/// +public interface IHandler where THost : IFormatterX +{ + /// 宿主读写器 + THost Host { get; set; } + + /// 优先级 + Int32 Priority { get; set; } + + /// 写入一个对象 + /// 目标对象 + /// 类型 + /// + Boolean Write(Object? value, Type type); + + /// 尝试读取指定类型对象 + /// + /// + /// + Boolean TryRead(Type type, ref Object? value); +} + +/// 序列化接口 +public abstract class FormatterBase //: IFormatterX +{ + #region 属性 + /// 数据流。默认实例化一个内存数据流 + public virtual Stream Stream { get; set; } = new MemoryStream(); + + /// 主对象 + public Stack Hosts { get; private set; } = new Stack(); + + /// 成员 + public MemberInfo? Member { get; set; } + + /// 字符串编码,默认utf-8 + public Encoding Encoding { get; set; } = Encoding.UTF8; + + /// 序列化属性而不是字段。默认true + public Boolean UseProperty { get; set; } = true; + + /// 用户对象。存放序列化过程中使用的用户自定义对象 + public Object? UserState { get; set; } + #endregion + + #region 方法 + /// 获取流里面的数据 + /// + public Byte[] GetBytes() + { + var ms = Stream; + var pos = ms.Position; + var start = 0; + if (pos == 0 || pos == start) return []; + + if (ms is MemoryStream ms2 && pos == ms.Length && start == 0) + return ms2.ToArray(); + + ms.Position = start; + + var buf = new Byte[pos - start]; + ms.ReadExactly(buf, 0, buf.Length); + return buf; + } + + #endregion + + #region 跟踪日志 + /// 日志提供者 + public ILog Log { get; set; } = Logger.Null; + + /// 输出日志 + /// + /// + public virtual void WriteLog(String format, params Object?[] args) => Log?.Info(format, args); + #endregion +} + +/// 读写处理器基类 +public abstract class HandlerBase : IHandler + where THost : IFormatterX + where THandler : IHandler +{ + /// 宿主读写器 + [NotNull] + public THost? Host { get; set; } + + /// 优先级 + public Int32 Priority { get; set; } + + /// 写入一个对象 + /// 目标对象 + /// 类型 + /// + public abstract Boolean Write(Object? value, Type type); + + /// 尝试读取指定类型对象 + /// + /// + /// + public abstract Boolean TryRead(Type type, ref Object? value); + + /// 输出日志 + /// + /// + public void WriteLog(String format, params Object?[] args) => Host?.Log.Info(format, args); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/IMemberAccessor.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/IMemberAccessor.cs new file mode 100644 index 000000000..8a34e5f12 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Interface/IMemberAccessor.cs @@ -0,0 +1,16 @@ +namespace ThingsGateway.NewLife.Serialization.Interface; + +/// 成员序列化访问器。接口实现者可以在这里完全自定义序列化行为 +public interface IMemberAccessor +{ + /// 从数据流中读取消息 + /// 序列化 + /// 上下文 + /// 是否成功 + Boolean Read(IFormatterX formatter, AccessorContext context); + + /// 把消息写入到数据流中 + /// 序列化 + /// 上下文 + Boolean Write(IFormatterX formatter, AccessorContext context); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/LocalTimeConverter.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/LocalTimeConverter.cs new file mode 100644 index 000000000..48a1a64f0 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/LocalTimeConverter.cs @@ -0,0 +1,49 @@ +#if NETCOREAPP +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ThingsGateway.NewLife.Serialization; + +/// 本地时间列化 +/// +/// 符合标准格式 yyyy-MM-dd HH:mm:ss +/// 序列化时,忽略时区信息,上层应用需要自己处理好; +/// 反序列化时,如果对方带有时区,也能转为本地时区。 +/// Json传输DateTime一般是不带时区的,有些框架带有时区,这里无差别去掉时区转为本地时间,避免时间偏差。 +/// 如果非要传输带有时区的时间,推荐使用DateTimeOffset。 +/// +public class LocalTimeConverter : JsonConverter +{ + /// 时间日期格式 + public String DateTimeFormat { get; set; } = "O"; + + /// 读取 + /// + /// + /// + /// + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var str = reader.GetString(); + if (DateTimeOffset.TryParse(str, out var dto)) return dto.LocalDateTime; + + var utc = false; + if (!str.IsNullOrEmpty() && str.EndsWith("UTC")) + { + str = str.TrimEnd("UTC").Trim(); + utc = true; + } + if (!DateTime.TryParse(str, out var dt)) return DateTime.MinValue; + + if (utc) dt = dt.ToLocalTime(); + + return dt; + } + + /// 写入 + /// + /// + /// + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) => writer.WriteStringValue(value.ToString(DateTimeFormat)); +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/SerialHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/SerialHelper.cs new file mode 100644 index 000000000..e07cfee60 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/SerialHelper.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; +using System.Reflection; +using System.Runtime.Serialization; +using System.Text; +using System.Xml.Serialization; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 序列化助手 +public static class SerialHelper +{ + private static readonly ConcurrentDictionary _cache = new(); + /// 获取序列化名称 + /// + /// + public static String GetName(PropertyInfo pi) + { + if (_cache.TryGetValue(pi, out var name)) return name; + + if (name.IsNullOrEmpty()) + { + var att = pi.GetCustomAttribute(); + if (att != null && !att.Name.IsNullOrEmpty()) name = att.Name; + } + if (name.IsNullOrEmpty()) + { + var att = pi.GetCustomAttribute(); + if (att != null && !att.ElementName.IsNullOrEmpty()) name = att.ElementName; + } + if (name.IsNullOrEmpty()) name = pi.Name; + + _cache.TryAdd(pi, name); + + return name; + } + + /// 依据 Json/Xml 字典生成实体模型类 + /// + /// + /// + public static String? BuildModelClass(this IDictionary dic, String className = "Model") + { + if (dic == null || dic.Count == 0) return null; + + var sb = new StringBuilder(); + + BuildModel(sb, dic, className, null); + + return sb.ToString(); + } + + private static void BuildModel(StringBuilder sb, IDictionary dic, String className, String? prefix) + { + sb.AppendLine($"{prefix}public class {className}"); + sb.AppendLine($"{prefix}{{"); + + var line = 0; + foreach (var item in dic) + { + var name = item.Key; + if (Char.IsLower(name[0])) name = Char.ToUpper(name[0]) + name[1..]; + + if (line++ > 0) sb.AppendLine(); + + var type = item.Value?.GetType() ?? typeof(Object); + if (type.IsBaseType()) + sb.AppendLine($"{prefix}\tpublic {type.Name} {name} {{ get; set; }}"); + else if (item.Value is IDictionary sub) + { + var subclassName = name + "Model"; + sb.AppendLine($"{prefix}\tpublic {subclassName} {name} {{ get; set; }}"); + sb.AppendLine(); + + BuildModel(sb, sub, subclassName, prefix + "\t"); + } + else if (item.Value is IList list) + { + var elmType = list.Count > 0 ? list[0].GetType() : type.GetElementTypeEx(); + sb.AppendLine($"{prefix}\tpublic {elmType?.Name}[] {name} {{ get; set; }}"); + } + } + + sb.AppendLine($"{prefix}}}"); + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/TypeConverter.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/TypeConverter.cs new file mode 100644 index 000000000..3918401a1 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/TypeConverter.cs @@ -0,0 +1,31 @@ +#if NETCOREAPP +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 面向Type的Json序列化转换器 +/// 借助字符串序列化Type.FullName +public class TypeConverter : JsonConverter +{ + /// 读取类型 + /// + /// + /// + /// +#if NETCOREAPP3_1 + public override Type Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetString()!.GetTypeEx()!; +#else + public override Type? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetString()?.GetTypeEx(); +#endif + + /// 写入类型 + /// + /// + /// + public override void Write(Utf8JsonWriter writer, Type value, JsonSerializerOptions options) => writer.WriteStringValue(value.FullName); +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/IXml.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/IXml.cs new file mode 100644 index 000000000..279368482 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/IXml.cs @@ -0,0 +1,62 @@ +using System.Xml; + +namespace ThingsGateway.NewLife.Serialization; + +/// 二进制序列化接口 +public interface IXml : IFormatterX +{ + #region 属性 + ///// 编码 + //Encoding Encoding { get; set; } + + /// 处理器列表 + List Handlers { get; } + + /// 使用注释 + Boolean UseComment { get; set; } + #endregion + + #region 方法 + /// 写入一个对象 + /// 目标对象 + /// 名称 + /// 类型 + /// + Boolean Write(Object? value, String? name = null, Type? type = null); + + /// 获取Xml写入器 + /// + XmlWriter GetWriter(); + + /// 获取Xml读取器 + /// + XmlReader GetReader(); + #endregion +} + +/// 二进制读写处理器接口 +public interface IXmlHandler : IHandler +{ + ///// 读取一个对象 + ///// + ///// + //Boolean Read(Object value); +} + +/// Xml读写处理器基类 +public abstract class XmlHandlerBase : HandlerBase, IXmlHandler +{ + //private IXml _Host; + ///// 宿主读写器 + //public IXml Host { get { return _Host; } set { _Host = value; } } + + //private Int32 _Priority; + ///// 优先级 + //public Int32 Priority { get { return _Priority; } set { _Priority = value; } } + + ///// 写入一个对象 + ///// 目标对象 + ///// 类型 + ///// + //public abstract Boolean Write(Object value, Type type); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/Xml.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/Xml.cs new file mode 100644 index 000000000..425b3a217 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/Xml.cs @@ -0,0 +1,346 @@ +using System.Reflection; +using System.Xml; +using System.Xml.Serialization; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// Xml序列化 +public class Xml : FormatterBase, IXml +{ + #region 属性 + /// 深度 + public Int32 Depth { get; set; } + + /// 处理器列表 + public List Handlers { get; private set; } + + /// 使用特性 + public Boolean UseAttribute { get; set; } + + /// 使用注释 + public Boolean UseComment { get; set; } + + /// 枚举使用字符串。false时使用数字,默认true + public Boolean EnumString { get; set; } = true; + + /// XML写入设置 + public XmlWriterSettings? Setting { get; set; } + + /// 当前名称 + public String? CurrentName { get; set; } + #endregion + + #region 构造 + /// 实例化 + public Xml() + { + // 遍历所有处理器实现 + var list = new List + { + new XmlGeneral { Host = this }, + new XmlList { Host = this }, + new XmlComposite { Host = this } + }; + // 根据优先级排序 + Handlers = list.OrderBy(e => e.Priority).ToList(); + } + #endregion + + #region 处理器 + /// 添加处理器 + /// + /// + public Xml AddHandler(IXmlHandler handler) + { + if (handler != null) + { + handler.Host = this; + Handlers.Add(handler); + // 根据优先级排序 + Handlers = Handlers.OrderBy(e => e.Priority).ToList(); + } + + return this; + } + + /// 添加处理器 + /// + /// + /// + public Xml AddHandler(Int32 priority = 0) where THandler : IXmlHandler, new() + { + var handler = new THandler + { + Host = this + }; + if (priority != 0) handler.Priority = priority; + + return AddHandler(handler); + } + #endregion + + #region 写入 + /// 写入一个对象 + /// 目标对象 + /// 名称 + /// 类型 + /// + public Boolean Write(Object? value, String? name = null, Type? type = null) + { + if (type == null) + { + if (value == null) return true; + + type = value.GetType(); + } + + var writer = GetWriter(); + + // 检查接口 + if (value is IXmlSerializable xml) + { + xml.WriteXml(writer); + return true; + } + + if (name.IsNullOrEmpty()) + { + // 优先采用类型上的XmlRoot特性 + name = type.GetCustomAttributeValue(true); + if (name.IsNullOrEmpty()) name = GetName(type); + } + + name = name.Replace('<', '_'); + name = name.Replace('>', '_'); + name = name.Replace('`', '_'); + CurrentName = name; + + // 一般类型为空是顶级调用 + if (Hosts.Count == 0 && Log != null && Log.Enable) WriteLog("XmlWrite {0} {1}", name ?? type.Name, value); + + // 要先写入根 + Depth++; + if (Depth == 1) writer.WriteStartDocument(); + + var rs = WriteStart(type); + try + { + if (rs /*&& value != null*/) + { + foreach (var item in Handlers) + { + if (item.Write(value, type)) return true; + } + + if (value != null) + writer.WriteValue(value); + + return true; + } + + return false; + } + finally + { + if (rs) WriteEnd(); + if (Depth == 1) + { + writer.WriteEndDocument(); + writer.Flush(); + } + Depth--; + } + } + + Boolean IFormatterX.Write(Object? value, Type? type) => Write(value, null, type); + + /// 写入开头 + /// + public Boolean WriteStart(Type type) + { + var name = CurrentName; + if (name.IsNullOrEmpty()) return false; + + var att = UseAttribute; + if (!att && Member?.GetCustomAttribute() != null) att = true; + if (att && !type.IsValueType && !type.IsBaseType()) att = false; + + var writer = GetWriter(); + + // 写入注释。写特性时忽略注释 + if (UseComment && !att) + { + var des = ""; + if (Member != null) des = Member.GetDisplayName() ?? Member.GetDescription(); + if (des.IsNullOrEmpty() && type != null) des = type.GetDisplayName() ?? type.GetDescription(); + + if (!des.IsNullOrEmpty()) writer.WriteComment(des); + } + + if (att) + writer.WriteStartAttribute(name); + else + writer.WriteStartElement(name); + + return true; + } + + /// 写入结尾 + public void WriteEnd() + { + var writer = GetWriter(); + + if (writer.WriteState != WriteState.Start) + { + if (writer.WriteState == WriteState.Attribute) + writer.WriteEndAttribute(); + else + { + writer.WriteEndElement(); + //替换成WriteFullEndElement方法,写入完整的结束标记。解决读取空节点(短结束标记"/ >")发生错误。 + //writer.WriteFullEndElement(); + } + } + } + + private XmlWriter? _Writer; + /// 获取Xml写入器 + /// + public XmlWriter GetWriter() + { + if (_Writer == null) + { + var set = Setting?.Clone() ?? new XmlWriterSettings + { + OmitXmlDeclaration = true, + Indent = true + }; + set.Encoding = Encoding; + if (set.OmitXmlDeclaration) set.ConformanceLevel = ConformanceLevel.Auto; + + _Writer = XmlWriter.Create(Stream, set); + } + + return _Writer; + } + #endregion + + #region 读取 + /// 读取指定类型对象 + /// + /// + public Object? Read(Type type) + { + var value = type.As() ? null : type.CreateInstance(); + if (!TryRead(type, ref value)) throw new Exception("Read failed!"); + + return value; + } + + /// 读取指定类型对象 + /// + /// + public T? Read() => (T?)Read(typeof(T)); + + /// 尝试读取指定类型对象 + /// + /// + /// + public Boolean TryRead(Type type, ref Object? value) + { + var reader = GetReader(); + // 移动到第一个元素 + while (reader.NodeType != XmlNodeType.Element) { if (!reader.Read()) return false; } + + if (Hosts.Count == 0 && Log != null && Log.Enable) WriteLog("XmlRead {0} {1}", type.Name, value); + + // 要先写入根 + Depth++; + + var d = reader.Depth; + ReadStart(type); + + try + { + // 如果读取器层级没有递增,说明这是空节点,需要跳过 + if (reader.Depth == d + 1) + { + foreach (var item in Handlers) + { + if (item.TryRead(type, ref value)) return true; + } + + value = reader.ReadContentAs(type, null); + } + } + finally + { + ReadEnd(); + Depth--; + } + + return true; + } + + /// 读取开始 + /// + public void ReadStart(Type type) + { + var att = UseAttribute; + if (!att && Member?.GetCustomAttribute() != null) _ = true; + + var reader = GetReader(); + while (reader.NodeType == XmlNodeType.Comment) reader.Skip(); + + CurrentName = reader.Name; + if (reader.HasAttributes) + reader.MoveToFirstAttribute(); + else + reader.ReadStartElement(); + + while (reader.NodeType is XmlNodeType.Comment or XmlNodeType.Whitespace) reader.Skip(); + } + + /// 读取结束 + public void ReadEnd() + { + var reader = GetReader(); + if (reader.NodeType == XmlNodeType.Attribute) reader.Read(); + if (reader.NodeType == XmlNodeType.EndElement) reader.ReadEndElement(); + } + + private XmlReader? _Reader; + /// 获取Xml读取器 + /// + public XmlReader GetReader() + { + _Reader ??= XmlReader.Create(Stream); + + return _Reader; + } + #endregion + + #region 辅助方法 + private static String GetName(Type type) + { + if (type.HasElementType) + { + var elmType = type.GetElementTypeEx(); + return elmType == null ? "Array" : "ArrayOf" + GetName(elmType); + } + + var name = type.GetName(); + name = name.Replace("<", "_"); + //name = name.Replace(">", "_"); + name = name.Replace(",", "_"); + name = name.Replace(">", ""); + return name; + } + + /// 获取字符串 + /// + public String GetString() => GetBytes().ToStr(Encoding); + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlComposite.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlComposite.cs new file mode 100644 index 000000000..4020dc688 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlComposite.cs @@ -0,0 +1,173 @@ +using System.Reflection; +using System.Xml; +using System.Xml.Serialization; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// Xml复合对象处理器 +public class XmlComposite : XmlHandlerBase +{ + /// 实例化 + public XmlComposite() + { + Priority = 100; + } + + /// 写入对象 + /// 目标对象 + /// 类型 + /// + public override Boolean Write(Object? value, Type type) + { + if (value == null) return false; + + // 不支持基本类型 + if (type.IsBaseType()) return false; + + var ms = GetMembers(type); + WriteLog("XmlWrite {0} 成员{1}个", type.Name, ms.Count); + + Host.Hosts.Push(value); + + //var xml = Host as Xml; + //xml.WriteStart(type); + try + { + // 获取成员 + foreach (var member in GetMembers(type)) + { + var mtype = member.GetMemberType(); + Host.Member = member; + + var name = SerialHelper.GetName(member); + var v = value.GetValue(member); + WriteLog(" {0}.{1} {2}", type.Name, member.Name, v); + + if (!Host.Write(v, name, mtype)) return false; + } + } + finally + { + //xml.WriteEnd(); + + Host.Hosts.Pop(); + } + + return true; + } + + /// 尝试读取 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (type == null) + { + if (value == null) return false; + type = value.GetType(); + } + + // 不支持基本类型 + if (type.IsBaseType()) return false; + // 不支持基类不是Object的特殊类型 + //if (type.BaseType != typeof(Object)) return false; + if (!type.As()) return false; + + var reader = Host.GetReader(); + var xml = Host as Xml; + if (xml == null) return false; + + // 判断类名是否一致 + var name = xml.CurrentName; + if (!CheckName(name, type)) return false; + + var ms = GetMembers(type); + WriteLog("XmlRead {0} 成员{1}个", type.Name, ms.Count); + var dic = ms.ToDictionary(e => SerialHelper.GetName(e), e => e); + + value ??= type.CreateInstance(); + if (value == null) return false; + + Host.Hosts.Push(value); + + try + { + if (reader.NodeType == XmlNodeType.Attribute) + { + foreach (var item in dic) + { + var member = item.Value; + var v = reader.GetAttribute(item.Key); + WriteLog(" {0}.{1} {2}", type.Name, member.Name, v); + + value.SetValue(member, v); + } + } + else + { + // 获取成员 + var member = ms[0]; + while (reader.NodeType != XmlNodeType.None && reader.IsStartElement()) + { + // 找到匹配的元素,否则跳过 + if (!dic.TryGetValue(reader.Name, out member) || !member.CanWrite) + { + reader.Skip(); + continue; + } + + var mtype = member.GetMemberType(); + if (mtype == null) continue; + + Host.Member = member; + + var v = value.GetValue(member); + WriteLog(" {0}.{1} {2}", type.Name, member.Name, v); + + if (!Host.TryRead(mtype, ref v)) return false; + + value.SetValue(member, v); + } + } + } + finally + { + Host.Hosts.Pop(); + } + + return true; + } + + #region 辅助 + private Boolean CheckName(String? name, Type type) + { + if (type.Name.EqualIgnoreCase(name)) return true; + + // 当前正在序列化的成员 + var mb = Host.Member; + if (mb != null) + { + var elm = mb.GetCustomAttribute(); + if (elm != null) return elm.ElementName.EqualIgnoreCase(name); + + if (mb.Name.EqualIgnoreCase(name)) return true; + } + + // 检查类型的Root + var att = type.GetCustomAttribute(); + if (att != null) return att.ElementName.EqualIgnoreCase(name); + + return false; + } + #endregion + + #region 获取成员 + /// 获取成员 + /// + /// + protected virtual List GetMembers(Type type) => type.GetProperties(true).ToList(); + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlGeneral.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlGeneral.cs new file mode 100644 index 000000000..2f19e826e --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlGeneral.cs @@ -0,0 +1,253 @@ +using System.Globalization; +using System.Xml; + +using ThingsGateway.NewLife.Collections; +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// Xml基础类型处理器 +public class XmlGeneral : XmlHandlerBase +{ + /// 实例化 + public XmlGeneral() + { + Priority = 10; + } + + /// 写入一个对象 + /// 目标对象 + /// 类型 + /// 是否处理成功 + public override Boolean Write(Object? value, Type type) + { + //if (value == null && type != typeof(String)) return false; + + var writer = Host.GetWriter(); + + // 枚举 写入字符串 + if (type.IsEnum) + { + if (Host is Xml xml && xml.EnumString) + writer.WriteValue(value + ""); + else + writer.WriteValue(value.ToLong()); + + return true; + } + + switch (type.GetTypeCode()) + { + case TypeCode.Boolean: + writer.WriteValue((Boolean)(value ?? false)); + return true; + case TypeCode.Byte: + case TypeCode.SByte: + case TypeCode.Char: + writer.WriteValue(Convert.ToChar(value)); + return true; + case TypeCode.DBNull: + case TypeCode.Empty: + writer.WriteValue(0); + return true; + case TypeCode.DateTime: + writer.WriteValue(((DateTime)(value ?? DateTime.MinValue)).ToFullString()); + return true; + case TypeCode.Decimal: + writer.WriteValue((Decimal)(value ?? 0m)); + return true; + case TypeCode.Double: + writer.WriteValue((Double)(value ?? 0d)); + return true; + case TypeCode.Single: + writer.WriteValue((Single)(value ?? 0f)); + return true; + case TypeCode.Int16: + case TypeCode.UInt16: + case TypeCode.Int32: + case TypeCode.UInt32: + writer.WriteValue(Convert.ToInt32(value)); + return true; + case TypeCode.Int64: + case TypeCode.UInt64: + writer.WriteValue(Convert.ToInt64(value)); + return true; + case TypeCode.String: + writer.WriteValue(value + ""); + return true; + case TypeCode.Object: + break; + default: + break; + } + + if (type == typeof(Guid)) + { + if (value is Guid guid) writer.WriteValue((guid).ToString()); + return true; + } + + if (type == typeof(DateTimeOffset)) + { + //writer.WriteValue((DateTimeOffset)value); + if (value is DateTimeOffset dto) writer.WriteValue(dto + ""); + return true; + } + + if (type == typeof(TimeSpan)) + { + if (value is TimeSpan ts) writer.WriteValue(ts + ""); + return true; + } + + if (type == typeof(Byte[])) + { + if (value is Byte[] buf) writer.WriteBase64(buf, 0, buf.Length); + return true; + } + + if (type == typeof(Char[])) + { + if (value is Char[] cs) writer.WriteValue(new String(cs)); + return true; + } + + // 支持格式化的类型,有去有回 + if (type.As()) + { + if (value is IFormattable ft) writer.WriteValue(ft + ""); + return true; + } + + return false; + } + + /// 尝试读取 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (type == null) + { + if (value == null) return false; + type = value.GetType(); + } + + var reader = Host.GetReader(); + + if (type == typeof(Guid)) + { + value = new Guid(reader.ReadContentAsString()); + return true; + } + else if (type == typeof(Byte[])) + { + // 用字符串长度作为预设缓冲区的长度 + var buf = Pool.Shared.Rent(reader.Value.Length); + var count = reader.ReadContentAsBase64(buf, 0, buf.Length); + value = buf.ReadBytes(0, count); + Pool.Shared.Return(buf); + + return true; + } + else if (type == typeof(Char[])) + { + value = reader.ReadContentAsString().ToCharArray(); + return true; + } + else if (type == typeof(DateTimeOffset)) + { + //value = reader.ReadContentAs(type, null); + value = DateTimeOffset.Parse(reader.ReadContentAsString()); + return true; + } + else if (type == typeof(TimeSpan)) + { + value = TimeSpan.Parse(reader.ReadContentAsString()); + return true; + } + + type = Nullable.GetUnderlyingType(type) ?? type; + if (!type.IsBaseType()) return false; + + // 读取异构Xml时可能报错 + var v = (reader.NodeType == XmlNodeType.Element ? reader.ReadElementContentAsString() : reader.ReadContentAsString()) + ""; + + // 枚举 + if (type.IsEnum) + { + value = Enum.Parse(type, v); + return true; + } + + switch (type.GetTypeCode()) + { + case TypeCode.Boolean: + value = v.ToBoolean(); + return true; + case TypeCode.Byte: + value = Byte.Parse(v, NumberStyles.HexNumber); + return true; + case TypeCode.Char: + if (v.Length > 0) value = v[0]; + return true; + case TypeCode.DBNull: + value = DBNull.Value; + return true; + case TypeCode.DateTime: + value = v.ToDateTime(); + return true; + case TypeCode.Decimal: + value = (Decimal)v.ToDouble(); + return true; + case TypeCode.Double: + value = v.ToDouble(); + return true; + case TypeCode.Empty: + value = null; + return true; + case TypeCode.Int16: + value = (Int16)v.ToInt(); + return true; + case TypeCode.Int32: + value = v.ToInt(); + return true; + case TypeCode.Int64: + value = Int64.Parse(v); + return true; + case TypeCode.Object: + break; + case TypeCode.SByte: + value = SByte.Parse(v, NumberStyles.HexNumber); + return true; + case TypeCode.Single: + value = (Single)v.ToDouble(); + return true; + case TypeCode.String: + value = v; + return true; + case TypeCode.UInt16: + value = (UInt16)v.ToInt(); + return true; + case TypeCode.UInt32: + value = (UInt32)v.ToInt(); + return true; + case TypeCode.UInt64: + value = UInt64.Parse(v); + return true; + default: + break; + } + +#if NET7_0_OR_GREATER + if (type.GetInterfaces().Any(e => e.IsGenericType && e.GetGenericTypeDefinition() == typeof(IParsable<>))) + { + value = reader.ReadContentAsString().ChangeType(type); + return true; + } +#endif + + return false; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlList.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlList.cs new file mode 100644 index 000000000..41beb8384 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlList.cs @@ -0,0 +1,108 @@ +using System.Collections; +using System.Xml; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Serialization; + +/// 列表数据编码 +public class XmlList : XmlHandlerBase +{ + /// 初始化 + public XmlList() + { + // 优先级 + Priority = 20; + } + + /// 写入 + /// + /// + /// + public override Boolean Write(Object? value, Type type) + { + if (!type.As() && value is not IList) return false; + + if (value is not IList list || list.Count == 0) return true; + + WriteLog("XmlWrite {0} 元素{1}项", type.Name, list.Count); + + Host.Hosts.Push(value); + + //var xml = Host as Xml; + //xml.WriteStart(type); + try + { + // 循环写入数据 + foreach (var item in list) + { + if (item != null && !Host.Write(item)) return false; + } + } + finally + { + //xml.WriteEnd(); + + Host.Hosts.Pop(); + } + + return true; + } + + /// 读取 + /// + /// + /// + public override Boolean TryRead(Type type, ref Object? value) + { + if (!type.As() && !type.As(typeof(IList<>))) return false; + + var reader = Host.GetReader(); + + // 读一次开始,移动到内部第一个元素 + if (reader.NodeType == XmlNodeType.Attribute) reader.ReadStartElement(); + if (!reader.IsStartElement()) return true; + + // 子元素类型 + var elmType = type.GetElementTypeEx(); + if (elmType == null) throw new ArgumentNullException(nameof(elmType)); + + if (value is not IList list || value is Array) + { + var obj = typeof(List<>).MakeGenericType(elmType).CreateInstance(); + if (obj is not IList list2) + throw new ArgumentOutOfRangeException(nameof(elmType)); + + list = list2; + } + + // 清空已有数据 + list.Clear(); + + while (reader.IsStartElement()) + { + Object? obj = null; + if (!Host.TryRead(elmType, ref obj)) return false; + + list.Add(obj); + } + + if (value != list) + { + // 数组的创建比较特别 + if (type.As()) + { + var arr = Array.CreateInstance(elmType, list.Count); + list.CopyTo(arr, 0); + value = arr; + } + else + value = list; + } + + // 读一次结束 + if (reader.NodeType == XmlNodeType.EndElement) reader.ReadEndElement(); + + return true; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlParser.cs b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlParser.cs new file mode 100644 index 000000000..1aac7ac87 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Serialization/Xml/XmlParser.cs @@ -0,0 +1,98 @@ +using System.Xml; + +using ThingsGateway.NewLife.Collections; + +namespace ThingsGateway.NewLife.Serialization; + +/// Xml解析器,得到字典和数组 +public class XmlParser +{ + #region 属性 + private readonly XmlReader _reader; + #endregion + + /// 实例化 + /// + public XmlParser(String xml) + { + var set = new XmlReaderSettings(); + _reader = XmlReader.Create(new StringReader(xml), set); + } + + /// 解码 + /// + public static IDictionary Decode(String xml) + { + xml = xml.TrimStart((Char)0xFEFF); + var parser = new XmlParser(xml); + return parser.ParseValue(); + } + + private Dictionary ParseValue() + { + var reader = _reader; + + // 移动到第一个元素 + while (reader.NodeType != XmlNodeType.Element) reader.Read(); + + reader.ReadStartElement(); + while (reader.NodeType == XmlNodeType.Whitespace) reader.Skip(); + + var dic = ParseObject(); + + if (reader.NodeType == XmlNodeType.EndElement) reader.ReadEndElement(); + + return dic; + } + + private Dictionary ParseObject() + { + var reader = _reader; + var dic = new NullableDictionary(StringComparer.OrdinalIgnoreCase); + + while (true) + { + while (reader.NodeType is XmlNodeType.Comment or XmlNodeType.Whitespace) reader.Skip(); + if (reader.NodeType != XmlNodeType.Element) break; + + var name = reader.Name; + + // 读取属性值 + if (reader.HasAttributes) + { + reader.MoveToFirstAttribute(); + do + { + dic[reader.Name] = reader.Value; + } while (reader.MoveToNextAttribute()); + } + else + reader.ReadStartElement(); + while (reader.NodeType == XmlNodeType.Whitespace) reader.Skip(); + + // 遇到下一层节点 + Object? val = null; + if (reader.NodeType is XmlNodeType.Element or XmlNodeType.Comment) + val = ParseObject(); + else if (reader.NodeType == XmlNodeType.Text) + val = reader.ReadContentAsString(); + + // 如果该名字两次或多次出现,则认为是数组 + if (dic.TryGetValue(name, out var val2)) + { + if (val2 is IList list) + list.Add(val); + else + dic[name] = new List { val2, val }; + } + else + dic[name] = val; + + if (reader.NodeType == XmlNodeType.Attribute) reader.Read(); + if (reader.NodeType == XmlNodeType.EndElement) reader.ReadEndElement(); + while (reader.NodeType == XmlNodeType.Whitespace) reader.Skip(); + } + + return dic; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Setting.cs b/src/Admin/ThingsGateway.NewLife.X/Setting.cs new file mode 100644 index 000000000..693cc8224 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Setting.cs @@ -0,0 +1,60 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using System.ComponentModel; + +using ThingsGateway.NewLife.Log; + +//[assembly: InternalsVisibleTo("XUnitTest.Core")] + +namespace ThingsGateway.NewLife; + +/// 核心设置 +/// +/// 文档 https://newlifex.com/core/setting +/// +[DisplayName("核心设置")] +public class Setting +{ + /// 当前实例。 + public static Setting Current = new(); + + #region 属性 + /// 是否启用全局调试。默认启用 + [Description("全局调试。XTrace.Debug")] + public Boolean Debug { get; set; } = true; + + /// 日志等级,只输出大于等于该级别的日志,All/Debug/Info/Warn/Error/Fatal,默认Info + [Description("日志等级。只输出大于等于该级别的日志,All/Debug/Info/Warn/Error/Fatal,默认Info")] + public LogLevel LogLevel { get; set; } = LogLevel.Info; + + /// 文件日志目录。默认Log子目录 + [Description("文件日志目录。默认Log子目录")] + public String LogPath { get; set; } = "Logs/XLog"; + + /// 日志文件上限。超过上限后拆分新日志文件,默认10MB,0表示不限制大小 + [Description("日志文件上限。超过上限后拆分新日志文件,默认10MB,0表示不限制大小")] + public Int32 LogFileMaxBytes { get; set; } = 10; + + /// 日志文件备份。超过备份数后,最旧的文件将被删除,网络安全法要求至少保存6个月日志,默认200,0表示不限制个数 + [Description("日志文件备份。超过备份数后,最旧的文件将被删除,网络安全法要求至少保存6个月日志,默认200,0表示不限制个数")] + public Int32 LogFileBackups { get; set; } = 200; + + /// 日志文件格式。默认{0:yyyy_MM_dd}.log,支持日志等级如 {1}_{0:yyyy_MM_dd}.log + [Description("日志文件格式。默认{0:yyyy_MM_dd}.log,支持日志等级如 {1}_{0:yyyy_MM_dd}.log")] + public String LogFileFormat { get; set; } = "{0:yyyy_MM_dd}.log"; + + /// 日志记录时间UTC校正,单位:小时。默认0表示使用的是本地时间,使用UTC时间的系统转换成本地时间则相差8小时 + [Description("日志记录时间UTC校正,小时")] + public Int32 UtcIntervalHours { get; set; } = 0; + + #endregion + +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Stub/IsExternalInit.cs b/src/Admin/ThingsGateway.NewLife.X/Stub/IsExternalInit.cs new file mode 100644 index 000000000..b2df7d7e4 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Stub/IsExternalInit.cs @@ -0,0 +1,11 @@ +#if NETFRAMEWORK || NETSTANDARD || NETCOREAPP3_1 +using System.ComponentModel; + +namespace System.Runtime.CompilerServices; + +/// 保留供编译器用于跟踪元数据。 开发人员不应在源代码中使用此类。 +[EditorBrowsable(EditorBrowsableState.Never)] +public static class IsExternalInit +{ +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Stub/MaybeNullAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Stub/MaybeNullAttribute.cs new file mode 100644 index 000000000..9a2da1ca3 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Stub/MaybeNullAttribute.cs @@ -0,0 +1,9 @@ +#if NETFRAMEWORK || NETSTANDARD2_0 +namespace System.Diagnostics.CodeAnalysis; + +/// 返回可能为空 +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)] +public sealed class MaybeNullAttribute : Attribute +{ +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Stub/MaybeNullWhenAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Stub/MaybeNullWhenAttribute.cs new file mode 100644 index 000000000..9c1192688 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Stub/MaybeNullWhenAttribute.cs @@ -0,0 +1,15 @@ +#if NETFRAMEWORK || NETSTANDARD2_0 +namespace System.Diagnostics.CodeAnalysis; + +/// 当返回指定值时可能为空 +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +public sealed class MaybeNullWhenAttribute : Attribute +{ + /// 返回值 + public Boolean ReturnValue { get; } + + /// 实例化 + /// + public MaybeNullWhenAttribute(Boolean returnValue) => ReturnValue = returnValue; +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Stub/MemberNotNullAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Stub/MemberNotNullAttribute.cs new file mode 100644 index 000000000..409f0cec6 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Stub/MemberNotNullAttribute.cs @@ -0,0 +1,19 @@ +#if NETFRAMEWORK || NETSTANDARD || NETCOREAPP3_1 +namespace System.Diagnostics.CodeAnalysis; + +/// 执行方法后指定成员不为空 +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] +public sealed class MemberNotNullAttribute : Attribute +{ + /// 不为空的成员 + public String[] Members { get; } + + /// 成员不为空 + /// + public MemberNotNullAttribute(String member) => Members = [member]; + + /// 成员不为空 + /// + public MemberNotNullAttribute(params String[] members) => Members = members; +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Stub/MemberNotNullWhenAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Stub/MemberNotNullWhenAttribute.cs new file mode 100644 index 000000000..990711a89 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Stub/MemberNotNullWhenAttribute.cs @@ -0,0 +1,32 @@ +#if NETFRAMEWORK || NETSTANDARD || NETCOREAPP3_1 +namespace System.Diagnostics.CodeAnalysis; + +/// 执行方法后指定成员不为空(带条件) +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Property, Inherited = false, AllowMultiple = true)] +public sealed class MemberNotNullWhenAttribute : Attribute +{ + /// 返回值 + public Boolean ReturnValue { get; } + + /// 不为空的成员 + public String[] Members { get; } + + /// 成员不为空 + /// + /// + public MemberNotNullWhenAttribute(Boolean returnValue, String member) + { + ReturnValue = returnValue; + Members = [member]; + } + + /// 成员不为空 + /// + /// + public MemberNotNullWhenAttribute(Boolean returnValue, params String[] members) + { + ReturnValue = returnValue; + Members = members; + } +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Stub/NotNullAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Stub/NotNullAttribute.cs new file mode 100644 index 000000000..92d38ba05 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Stub/NotNullAttribute.cs @@ -0,0 +1,9 @@ +#if NETFRAMEWORK || NETSTANDARD2_0 +namespace System.Diagnostics.CodeAnalysis; + +/// 不为空 +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter | AttributeTargets.ReturnValue, Inherited = false)] +public sealed class NotNullAttribute : Attribute +{ +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Stub/NotNullIfNotNullAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Stub/NotNullIfNotNullAttribute.cs new file mode 100644 index 000000000..416e1545d --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Stub/NotNullIfNotNullAttribute.cs @@ -0,0 +1,15 @@ +#if NETFRAMEWORK || NETSTANDARD2_0 +namespace System.Diagnostics.CodeAnalysis; + +/// 指定参数不为空时返回也不为空 +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter | AttributeTargets.ReturnValue, AllowMultiple = true, Inherited = false)] +public sealed class NotNullIfNotNullAttribute : Attribute +{ + /// 指定参数 + public String ParameterName { get; } + + /// 实例化 + /// + public NotNullIfNotNullAttribute(String parameterName) => ParameterName = parameterName; +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Stub/NotNullWhenAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Stub/NotNullWhenAttribute.cs new file mode 100644 index 000000000..a692835aa --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Stub/NotNullWhenAttribute.cs @@ -0,0 +1,15 @@ +#if NETFRAMEWORK || NETSTANDARD2_0 +namespace System.Diagnostics.CodeAnalysis; + +/// 指定在方法返回 ReturnValue 时,即使相应的类型允许,参数也不会为 null。 +[AttributeUsage(AttributeTargets.Parameter, Inherited = false)] +public sealed class NotNullWhenAttribute : Attribute +{ + /// 获取返回值条件。 + public Boolean ReturnValue { get; } + + /// 使用指定的返回值条件初始化属性。 + /// + public NotNullWhenAttribute(Boolean returnValue) => ReturnValue = returnValue; +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Stub/ScriptIgnoreAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Stub/ScriptIgnoreAttribute.cs new file mode 100644 index 000000000..c11c0c627 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Stub/ScriptIgnoreAttribute.cs @@ -0,0 +1,5 @@ +namespace System.Web.Script.Serialization +{ + /// 忽略Json序列化 + public sealed class ScriptIgnoreAttribute : Attribute { } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/ThingsGateway.NewLife.X.csproj b/src/Admin/ThingsGateway.NewLife.X/ThingsGateway.NewLife.X.csproj new file mode 100644 index 000000000..902ecaf16 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/ThingsGateway.NewLife.X.csproj @@ -0,0 +1,55 @@ + + + + + + + net462;netstandard2.0;net6.0;net6.0-windows;net8.0;net8.0-windows; + ThingsGateway.NewLife.X + ThingsGateway.NewLife + 工具核心库 + ThingsGateway.NewLife.X + true + + + + + + + + + + + + WIN + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Admin/ThingsGateway.NewLife.X/Threading/Cron.cs b/src/Admin/ThingsGateway.NewLife.X/Threading/Cron.cs new file mode 100644 index 000000000..df51b02e4 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Threading/Cron.cs @@ -0,0 +1,355 @@ +namespace ThingsGateway.NewLife.Threading; + +/// 轻量级Cron表达式 +/// +/// 基本构成:秒+分+时+天+月+星期+年 +/// 每段构成: +/// * 所有可能的值,该类型片段全部可选 +/// , 列出枚举值 +/// - 范围,横杠表示的一个区间可选 +/// / 指定数值的增量,在上述可选数字内,间隔多少选一个 +/// ? 不指定值,仅日期和星期域支持该字符 +/// # 确定每个月第几个星期几,L表示倒数,仅星期域支持该字符 +/// 数字,具体某个数值可选 +/// 逗号多选,逗号分隔的多个数字或区间可选 +/// +/// +/// */2 每两秒一次 +/// 0,1,2 * * * * 每分钟的0秒1秒2秒各一次 +/// 5/20 * * * * 每分钟的5秒25秒45秒各一次 +/// * 1-10,13,25/3 * * * 每小时的1分4分7分10分13分25分,每一秒各一次 +/// 0 0 0 1 * * 每个月1日的0点整 +/// 0 0 2 * * 1-5 每个工作日的凌晨2点 +/// 0 0 0 ? ? 1-7#1 每月第一周的任意一天(周一~周日)的0点整 +/// 0 0 0 ? ? 3-5#L2 每个月倒数第二个星期三到星期五的0点整 +/// +/// 星期部分采用Linux和.NET风格,0表示周日,1表示周一。 +/// 可设置Sunday为1,1表示周日,2表示周一。 +/// +/// 文档 https://newlifex.com/core/cron +/// 参考文档 https://help.aliyun.com/document_detail/64769.html +/// +public class Cron +{ + #region 属性 + /// 秒数集合 + public Int32[]? Seconds { get; set; } + + /// 分钟集合 + public Int32[]? Minutes { get; set; } + + /// 小时集合 + public Int32[]? Hours { get; set; } + + /// 日期集合 + public Int32[]? DaysOfMonth { get; set; } + + /// 月份集合 + public Int32[]? Months { get; set; } + + /// 星期集合。key是星期数,value是第几个,负数表示倒数 + public IDictionary? DaysOfWeek { get; set; } + + /// 星期天偏移量。周日对应的数字,默认0。1表示周日时,2表示周一 + public Int32 Sunday { get; set; } + + private String? _expression; + #endregion + + #region 构造 + /// 实例化Cron表达式 + public Cron() { } + + /// 实例化Cron表达式 + /// + public Cron(String expression) => Parse(expression); + + /// 已重载。 + /// + public override String ToString() => _expression ?? nameof(Cron); + #endregion + + #region 方法 + /// 指定时间是否位于表达式之内 + /// + /// + public Boolean IsTime(DateTime time) + { + if (Seconds == null || Minutes == null || Hours == null || DaysOfMonth == null || Months == null) return false; + + // 基础时间判断 + if (!Seconds.Contains(time.Second) || + !Minutes.Contains(time.Minute) || + !Hours.Contains(time.Hour) || + !DaysOfMonth.Contains(time.Day) || + !Months.Contains(time.Month) + ) return false; + + var w = (Int32)time.DayOfWeek + Sunday; + if (DaysOfWeek == null || !DaysOfWeek.TryGetValue(w, out var index)) return false; + + // 第几个星期几判断 + if (index > 0) + { + var start = new DateTime(time.Year, time.Month, 1); + for (var dt = start; dt <= time.Date; dt = dt.AddDays(1)) + { + if (dt.DayOfWeek == time.DayOfWeek) index--; + } + if (index != 0) return false; + } + else if (index < 0) + { + var start = new DateTime(time.Year, time.Month, 1); + for (var dt = start.AddMonths(1).AddDays(-1); dt >= time.Date; dt = dt.AddDays(-1)) + { + if (dt.DayOfWeek == time.DayOfWeek) index++; + } + if (index != 0) return false; + } + + return true; + } + + /// 分析表达式 + /// + /// + public Boolean Parse(String expression) + { + var ss = expression.Split([' '], StringSplitOptions.RemoveEmptyEntries); + if (ss.Length == 0) return false; + + if (!TryParse(ss[0], 0, 60, out var vs)) return false; + Seconds = vs; + if (!TryParse(ss.Length > 1 ? ss[1] : "*", 0, 60, out vs)) return false; + Minutes = vs; + if (!TryParse(ss.Length > 2 ? ss[2] : "*", 0, 24, out vs)) return false; + Hours = vs; + if (!TryParse(ss.Length > 3 ? ss[3] : "*", 1, 32, out vs)) return false; + DaysOfMonth = vs; + if (!TryParse(ss.Length > 4 ? ss[4] : "*", 1, 13, out vs)) return false; + Months = vs; + + var weeks = new Dictionary(); + if (!TryParseWeek(ss.Length > 5 ? ss[5] : "*", 0, 7, weeks)) return false; + DaysOfWeek = weeks; + + _expression = expression; + + return true; + } + + private static Boolean TryParse(String value, Int32 start, Int32 max, out Int32[] vs) + { + // 固定值,最为常见,优先计算 + if (Int32.TryParse(value, out var n)) + { + vs = [n]; + return true; + } + + var rs = new List(); + vs = Array.Empty(); + + // 递归处理混合值 + if (value.Contains(',')) + { + foreach (var item in value.Split(',')) + { + if (!TryParse(item, start, max, out var arr)) return false; + if (arr.Length > 0) rs.AddRange(arr); + } + vs = rs.ToArray(); + return true; + } + + // 步进值 + var step = 1; + var p = value.IndexOf('/'); + if (p > 0) + { + step = value[(p + 1)..].ToInt(); + value = value[..p]; + } + + // 连续范围 + var s = start; + if (value is "*" or "?") + s = 0; + else if ((p = value.IndexOf('-')) > 0) + { + s = value[..p].ToInt(); + max = value[(p + 1)..].ToInt() + 1; + } + else if (Int32.TryParse(value, out n)) + s = n; + else + return false; + + for (var i = s; i < max; i += step) + { + if (i >= start) rs.Add(i); + } + + vs = rs.ToArray(); + return true; + } + + private static Boolean TryParseWeek(String value, Int32 start, Int32 max, IDictionary weeks) + { + // 固定值,最为常见,优先计算 + if (Int32.TryParse(value, out var n)) + { + weeks[n] = 0; + return true; + } + + // 递归处理混合值 + if (value.Contains(',')) + { + foreach (var item in value.Split(',')) + { + if (!TryParseWeek(item, start, max, weeks)) return false; + } + return true; + } + + // 步进值 + var step = 1; + var v = value; + var p = value.IndexOf('/'); + if (p > 0) + { + step = value[(p + 1)..].ToInt(); + v = value[..p]; + } + + // 第几个星期几 + var index = 0; + p = v.IndexOf('#'); + if (p > 0) + { + var str = v[(p + 1)..]; + if (str.StartsWithIgnoreCase("L")) + index = -str[1..].ToInt(); + else + index = str.ToInt(); + v = v[..p]; + step = 7; + } + + // 连续范围 + var s = start; + if (v is "*" or "?") + s = 0; + else if ((p = v.IndexOf('-')) > 0) + { + s = v[..p].ToInt(); + max = v[(p + 1)..].ToInt() + 1; + step = 1; + } + else if (Int32.TryParse(v, out n)) + s = n; + else + return false; + + for (var i = s; i < max; i += step) + { + if (i >= start) weeks.Add(i, index); + } + + return true; + } + + /// 获得指定时间之后的下一次执行时间,不含指定时间 + /// + /// 如果指定时间带有毫秒,则向前对齐。如09:14.123的"15 * * *"下一次是10:15而不是09:15 + /// + /// 从该时间秒的下一秒算起的下一个执行时间 + /// 下一次执行时间(秒级),如果没有匹配则返回最小时间 + public DateTime GetNext(DateTime time) + { + // 如果指定时间带有毫秒,则向前对齐。如09:14.123格式化为09:15,计算下一次就从09:16开始 + var start = time.Trim(); + if (start != time) + start = start.AddSeconds(2); + else + start = start.AddSeconds(1); + + // 设置末尾,避免死循环越界 + var end = time.AddYears(1); + for (var dt = start; dt < end; dt = dt.AddSeconds(1)) + { + if (IsTime(dt)) return dt; + } + + return DateTime.MinValue; + } + + /// 获得与指定时间时间符合表达式的最远时间(秒级) + /// + public DateTime GetPrevious(DateTime time) + { + // 如果指定时间带有毫秒,则向前对齐。如09:14.123格式化为09:15,计算下一次就从09:16开始 + var start = time.Trim(); + if (start != time) + start = start.AddSeconds(-1); + else + start = start.AddSeconds(-2); + + // 设置末尾,避免死循环越界 + var end = time.AddYears(-1); + var last = false; + for (var dt = start; dt > end; dt = dt.AddSeconds(-1))//过去一年内 + { + if (last == false) + { + last = IsTime(dt);//找真值 + } + else + { + if (IsTime(dt) == false)//真值找到了找假值 + { + return dt.AddSeconds(1);//减多了,返回真值 + } + } + //if (last == true && IsTime(dt) == false) return dt.AddSeconds(1); + //last = IsTime(dt); + } + + return DateTime.MinValue; + } + + /// 对一批Cron表达式,获取下一次执行时间 + /// + /// + /// + public static DateTime GetNext(String[] crons, DateTime time) + { + var next = DateTime.MaxValue; + foreach (var item in crons) + { + var cron = new Cron(item); + var dt = cron.GetNext(time); + if (dt < next) next = dt; + } + return next; + } + + /// 对一批Cron表达式,获取前一次执行时间 + /// + /// + /// + public static DateTime GetPrevious(String[] crons, DateTime time) + { + var prev = DateTime.MinValue; + foreach (var item in crons) + { + var cron = new Cron(item); + var dt = cron.GetPrevious(time); + if (dt > prev) prev = dt; + } + return prev; + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Threading/ITimer.cs b/src/Admin/ThingsGateway.NewLife.X/Threading/ITimer.cs new file mode 100644 index 000000000..64f2cd5b7 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Threading/ITimer.cs @@ -0,0 +1,13 @@ +namespace System.Threading; + +#if NETFRAMEWORK || NETSTANDARD || NETCOREAPP3_1 || NET5_0 || NET6_0 || NET7_0 +/// 表示可以更改其到期时间和时间段的计时器。 +public interface ITimer : IDisposable +{ + /// 更改计时器的启动时间和方法调用之间的时间间隔,使用 TimeSpan 值度量时间间隔。 + /// 一个 TimeSpan,表示在调用构造 ITimer 时指定的回调方法之前的延迟时间量。 指定 InfiniteTimeSpan 可防止重新启动计时器。 指定 Zero 可立即重新启动计时器。 + /// 构造 Timer 时指定的回调方法调用之间的时间间隔。 指定 InfiniteTimeSpan 可以禁用定期终止。 + /// + Boolean Change(TimeSpan dueTime, TimeSpan period); +} +#endif \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Threading/TaskEx.cs b/src/Admin/ThingsGateway.NewLife.X/Threading/TaskEx.cs new file mode 100644 index 000000000..41bd5896e --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Threading/TaskEx.cs @@ -0,0 +1,11 @@ +namespace ThingsGateway.NewLife.Extension; + +#if NET452 +/// 任务扩展 +public static class TaskEx +{ + private static readonly Task s_preCompletedTask = Task.FromResult(false); + /// 已完成任务 + public static Task CompletedTask => s_preCompletedTask; +} +#endif diff --git a/src/Admin/ThingsGateway.NewLife.X/Threading/ThreadPoolX.cs b/src/Admin/ThingsGateway.NewLife.X/Threading/ThreadPoolX.cs new file mode 100644 index 000000000..b871c4ec1 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Threading/ThreadPoolX.cs @@ -0,0 +1,75 @@ +using System.Diagnostics; + +namespace ThingsGateway.NewLife.Threading; + +/// 线程池助手 +public class ThreadPoolX : DisposeBase +{ + #region 全局线程池助手 + static ThreadPoolX() + { + // 在这个同步异步大量混合使用的时代,需要更多的初始线程来屏蔽各种对TPL的不合理使用 + ThreadPool.GetMinThreads(out var wt, out var io); + if (wt < 32 || io < 32) + { + if (wt < 32) wt = 32; + if (io < 32) io = 32; + ThreadPool.SetMinThreads(wt, io); + } + +#if NET7_0_OR_GREATER + // 线程池最大延迟,超过这个延迟后,线程池会增加线程数。@一线码农 + AppContext.SetData("System.Threading.ThreadPool.Blocking.MaxDelayMs", 50); +#endif + } + + /// 初始化线程池 + /// + public static void Init() { } + + /// 带异常处理的线程池任务调度,不允许异常抛出,以免造成应用程序退出,同时不会捕获上下文 + /// + [DebuggerHidden] + public static void QueueUserWorkItem(Action callback) + { + if (callback == null) return; + + ThreadPool.UnsafeQueueUserWorkItem(s => + { + try + { + callback(); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + }, null); + + //Instance.QueueWorkItem(callback); + } + + /// 带异常处理的线程池任务调度,不允许异常抛出,以免造成应用程序退出,同时不会捕获上下文 + /// + /// + [DebuggerHidden] + public static void QueueUserWorkItem(Action callback, T state) + { + if (callback == null) return; + + ThreadPool.UnsafeQueueUserWorkItem(s => + { + try + { + callback(state); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + }, null); + + //Instance.QueueWorkItem(() => callback(state)); + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Threading/TimerScheduler.cs b/src/Admin/ThingsGateway.NewLife.X/Threading/TimerScheduler.cs new file mode 100644 index 000000000..f8772a049 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Threading/TimerScheduler.cs @@ -0,0 +1,363 @@ +using System.Diagnostics; + +using ThingsGateway.NewLife.Log; +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Threading; + +/// 定时器调度器 +public class TimerScheduler : ILogFeature +{ + #region 静态 + private TimerScheduler(String name) => Name = name; + + private static readonly Dictionary _cache = []; + + /// 创建指定名称的调度器 + /// + /// + public static TimerScheduler Create(String name) + { + if (_cache.TryGetValue(name, out var ts)) return ts; + lock (_cache) + { + if (_cache.TryGetValue(name, out ts)) return ts; + + ts = new TimerScheduler(name); + _cache[name] = ts; + + return ts; + } + } + + /// 默认调度器 + public static TimerScheduler Default { get; } = Create("Default"); + + [ThreadStatic] + private static TimerScheduler? _Current; + /// 当前调度器 + public static TimerScheduler? Current { get => _Current; private set => _Current = value; } + + /// 全局时间提供者。影响所有调度器 + public static TimeProvider GlobalTimeProvider { get; set; } = TimeProvider.System; + #endregion + + #region 属性 + /// 名称 + public String Name { get; private set; } + + /// 定时器个数 + public Int32 Count { get; private set; } + + /// 最大耗时。超过时报警告日志,默认500ms + public Int32 MaxCost { get; set; } = 500; + + /// 时间提供者。该调度器下所有绝对定时器,均从此获取当前时间 + public TimeProvider? TimeProvider { get; set; } + + private Thread? thread; + private Int32 _tid; + + private TimerX[] Timers = []; + #endregion + + /// 把定时器加入队列 + /// + public void Add(TimerX timer) + { + if (timer == null) throw new ArgumentNullException(nameof(timer)); + + timer.Id = Interlocked.Increment(ref _tid); + WriteLog("Timer.Add {0}", timer); + + lock (this) + { + var list = new List(Timers); + if (list.Contains(timer)) return; + list.Add(timer); + + Timers = list.ToArray(); + + Count++; + + if (thread == null) + { + thread = new Thread(Process) + { + Name = Name == "Default" ? "T" : Name, + IsBackground = true + }; + thread.Start(); + + WriteLog("启动定时调度器:{0}", Name); + } + + Wake(); + } + } + + /// 从队列删除定时器 + /// + /// + public void Remove(TimerX timer, String reason) + { + if (timer == null || timer.Id == 0) return; + + WriteLog("Timer.Remove {0} reason:{1}", timer, reason); + + lock (this) + { + timer.Id = 0; + + var list = new List(Timers); + if (list.Remove(timer)) + { + Timers = list.ToArray(); + + Count--; + } + } + } + + private AutoResetEvent? _waitForTimer; + private Int32 _period = 10; + + /// 唤醒处理 + public void Wake() + { + var e = _waitForTimer; + if (e != null) + { + var swh = e.SafeWaitHandle; + if (swh != null && !swh.IsClosed) e.Set(); + } + } + + /// 调度主程序 + /// + private void Process(Object? state) + { + Current = this; + while (true) + { + // 准备好定时器列表 + var arr = Timers; + + // 如果没有任务,则销毁线程 + if (arr.Length == 0 && _period == 60_000) + { + WriteLog("没有可用任务,销毁线程"); + + var th = thread; + thread = null; + //th?.Abort(); + + break; + } + + try + { + var now = Runtime.TickCount64; + + // 设置一个较大的间隔,内部会根据处理情况调整该值为最合理值 + _period = 60_000; + foreach (var timer in arr) + { + if (!timer.Calling && CheckTime(timer, now)) + { + // 必须在主线程设置状态,否则可能异步线程还没来得及设置开始状态,主线程又开始了新的一轮调度 + timer.Calling = true; + if (timer.IsAsyncTask) + Task.Factory.StartNew(ExecuteAsync, timer); + else if (!timer.Async) + Execute(timer); + else + //Task.Factory.StartNew(() => ProcessItem(timer)); + // 不需要上下文流动,捕获所有异常 + ThreadPool.UnsafeQueueUserWorkItem(s => + { + try + { + Execute(s); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + }, timer); + } + } + } + catch (ThreadAbortException) { break; } + catch (ThreadInterruptedException) { break; } + catch { } + + _waitForTimer ??= new AutoResetEvent(false); + if (_period > 0) _waitForTimer.WaitOne(_period, true); + } + } + + /// 检查定时器是否到期 + /// + /// + /// + private Boolean CheckTime(TimerX timer, Int64 now) + { + // 删除过期的,为了避免占用过多CPU资源,TimerX禁止小于10ms的任务调度 + var p = timer.Period; + if (p is < 10 and > 0) + { + // 周期0表示只执行一次 + if (p is < 10 and > 0) XTrace.WriteLine("为了避免占用过多CPU资源,TimerX禁止小于{1}ms<10ms的任务调度,关闭任务{0}", timer, p); + timer.Dispose(); + return false; + } + + var ts = timer.NextTick - now; + if (ts > 0) + { + // 缩小间隔,便于快速调用 + if (ts < _period) _period = (Int32)ts; + + return false; + } + + return true; + } + + /// 处理每一个定时器 + /// + private void Execute(Object? state) + { + if (state is not TimerX timer) return; + + TimerX.Current = timer; + + // 控制日志显示 + WriteLogEventArgs.CurrentThreadName = Name == "Default" ? "T" : Name; + + timer.hasSetNext = false; + + var sw = Stopwatch.StartNew(); + try + { + // 弱引用判断 + var target = timer.Target.Target; + if (target == null && !timer.Method.IsStatic) + { + Remove(timer, "委托已不存在(GC回收委托所在对象)"); + timer.Dispose(); + return; + } + + var func = timer.Method.As(target); + func!(timer.State); + } + catch (ThreadAbortException) { throw; } + catch (ThreadInterruptedException) { throw; } + // 如果用户代码没有拦截错误,则这里拦截,避免出错了都不知道怎么回事 + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + finally + { + sw.Stop(); + + OnExecuted(timer, (Int32)sw.ElapsedMilliseconds); + } + } + + /// 处理每一个定时器 + /// + private async void ExecuteAsync(Object? state) + { + if (state is not TimerX timer) return; + + TimerX.Current = timer; + + // 控制日志显示 + WriteLogEventArgs.CurrentThreadName = Name == "Default" ? "T" : Name; + + timer.hasSetNext = false; + + var sw = Stopwatch.StartNew(); + try + { + // 弱引用判断 + var target = timer.Target.Target; + if (target == null && !timer.Method.IsStatic) + { + Remove(timer, "委托已不存在(GC回收委托所在对象)"); + timer.Dispose(); + return; + } + + var func = timer.Method.As>(target); + await func!(timer.State).ConfigureAwait(false); + } + catch (ThreadAbortException) { throw; } + catch (ThreadInterruptedException) { throw; } + // 如果用户代码没有拦截错误,则这里拦截,避免出错了都不知道怎么回事 + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + finally + { + sw.Stop(); + + OnExecuted(timer, (Int32)sw.ElapsedMilliseconds); + } + } + + private void OnExecuted(TimerX timer, Int32 ms) + { + timer.Cost = timer.Cost == 0 ? ms : (timer.Cost + ms) / 2; + + if (ms > MaxCost && !timer.Async && !timer.IsAsyncTask) XTrace.WriteLine("任务 {0} 耗时过长 {1:n0}ms,建议使用异步任务Async=true", timer, ms); + + timer.Timers++; + OnFinish(timer); + + timer.Calling = false; + + TimerX.Current = null; + + // 控制日志显示 + WriteLogEventArgs.CurrentThreadName = null; + + // 调度线程可能在等待,需要唤醒 + Wake(); + } + + private void OnFinish(TimerX timer) + { + // 如果内部设置了下一次时间,则不再递加周期 + var p = timer.SetAndGetNextTime(); + + // 清理一次性定时器 + if (p <= 0) + { + Remove(timer, "Period<=0"); + timer.Dispose(); + } + else if (p < _period) + _period = p; + } + + /// 获取当前时间。该调度器下所有绝对定时器,均从此获取当前时间 + /// + public DateTime GetNow() => (TimeProvider ?? GlobalTimeProvider).GetUtcNow().LocalDateTime; + + /// 已重载。 + /// + public override String ToString() => Name; + + #region 日志 + /// 日志 + public ILog Log { get; set; } = Logger.Null; + + private void WriteLog(String format, params Object?[] args) => Log?.Info(Name + format, args); + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Threading/TimerX.cs b/src/Admin/ThingsGateway.NewLife.X/Threading/TimerX.cs new file mode 100644 index 000000000..c26128c00 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Threading/TimerX.cs @@ -0,0 +1,443 @@ +using System.Reflection; + +namespace ThingsGateway.NewLife.Threading; + +/// 不可重入的定时器,支持Cron +/// +/// 文档 https://newlifex.com/core/timerx +/// +/// 为了避免系统的Timer可重入的问题,差别在于本地调用完成后才开始计算时间间隔。这实际上也是经常用到的。 +/// +/// 因为挂载在静态列表上,必须从外部主动调用才能销毁定时器。 +/// 但是要注意GC回收定时器实例。 +/// +/// 该定时器不能放入太多任务,否则适得其反! +/// +/// TimerX必须维持对象,否则Scheduler也没有维持对象时,大家很容易一起被GC回收。 +/// +public class TimerX : ITimer, IDisposable +{ + #region 属性 + /// 编号 + public Int32 Id { get; internal set; } + + /// 所属调度器 + public TimerScheduler Scheduler { get; private set; } + + /// 目标对象。弱引用,使得调用方对象可以被GC回收 + internal readonly WeakReference Target; + + /// 委托方法 + internal readonly MethodInfo Method; + + internal readonly Boolean IsAsyncTask; + + private WeakReference? _state; + /// 获取/设置 用户数据 + public Object? State + { + get => _state != null && _state.IsAlive ? _state.Target : null; + set + { + if (_state == null) + _state = new WeakReference(value); + else + _state.Target = value; + } + } + + /// 基准时间。开机时间 + private static DateTime _baseTime; + + private Int64 _nextTick; + /// 下一次执行时间。开机以来嘀嗒数,无惧时间回拨问题 + public Int64 NextTick => _nextTick; + + /// 获取/设置 下一次调用时间 + public DateTime NextTime => _baseTime.AddMilliseconds(_nextTick); + + /// 获取/设置 调用次数 + public Int32 Timers { get; internal set; } + + /// 获取/设置 间隔周期。毫秒,设为0或-1则只调用一次 + public Int32 Period { get; set; } + + /// 获取/设置 异步执行任务。默认false + public Boolean Async { get; set; } + + /// 获取/设置 绝对精确时间执行。默认false + public Boolean Absolutely { get; set; } + + /// 调用中 + public Boolean Calling { get; internal set; } + + /// 平均耗时。毫秒 + public Int32 Cost { get; internal set; } + + /// Cron表达式,实现复杂的定时逻辑 + public Cron[]? Crons => _crons; + + /// 链路追踪名称。默认使用方法名 + public String TracerName { get; set; } + + private DateTime _AbsolutelyNext; + private readonly Cron[]? _crons; + #endregion + + #region 静态 +#if NET452 + private static readonly ThreadLocal _Current = new(); +#else + private static readonly AsyncLocal _Current = new(); +#endif + /// 当前定时器 + public static TimerX? Current { get => _Current.Value; set => _Current.Value = value; } + #endregion + + #region 构造 + private TimerX(Object? target, MethodInfo method, Object? state, String? scheduler = null) + { + Target = new WeakReference(target); + Method = method; + State = state; + + // 使用开机滴答作为定时调度基准 + _nextTick = Runtime.TickCount64; + //_baseTime = DateTime.Now.AddMilliseconds(-_nextTick); + + Scheduler = (scheduler == null || scheduler.IsNullOrEmpty()) ? TimerScheduler.Default : TimerScheduler.Create(scheduler); + //Scheduler.Add(this); + _baseTime = Scheduler.GetNow().AddMilliseconds(-_nextTick); + + TracerName = $"timer:{method.Name}"; + } + + private void Init(Int64 ms) + { + SetNextTick(ms); + + Scheduler.Add(this); + } + + /// 实例化一个不可重入的定时器 + /// 委托 + /// 用户数据 + /// 多久之后开始。毫秒 + /// 间隔周期。毫秒 + /// 调度器 + public TimerX(TimerCallback callback, Object? state, Int32 dueTime, Int32 period, String? scheduler = null) : this(callback.Target, callback.Method, state, scheduler) + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + if (dueTime < 0) throw new ArgumentOutOfRangeException(nameof(dueTime)); + + Period = period; + + Init(dueTime); + } + + /// 实例化一个不可重入的定时器 + /// 委托 + /// 用户数据 + /// 多久之后开始。毫秒 + /// 间隔周期。毫秒 + /// 调度器 + public TimerX(Func callback, Object? state, Int32 dueTime, Int32 period, String? scheduler = null) : this(callback.Target, callback.Method, state, scheduler) + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + if (dueTime < 0) throw new ArgumentOutOfRangeException(nameof(dueTime)); + + IsAsyncTask = true; + Async = true; + Period = period; + + Init(dueTime); + } + + /// 实例化一个绝对定时器,指定时刻执行,跟当前时间和SetNext无关 + /// 委托 + /// 用户数据 + /// 绝对开始时间 + /// 间隔周期。毫秒 + /// 调度器 + public TimerX(TimerCallback callback, Object? state, DateTime startTime, Int32 period, String? scheduler = null) : this(callback.Target, callback.Method, state, scheduler) + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + if (startTime <= DateTime.MinValue) throw new ArgumentOutOfRangeException(nameof(startTime)); + if (period <= 0) throw new ArgumentOutOfRangeException(nameof(period)); + + Period = period; + Absolutely = true; + + //var now = DateTime.Now; + var now = Scheduler.GetNow(); + var next = startTime; + while (next < now) next = next.AddMilliseconds(period); + + var ms = (Int64)(next - now).TotalMilliseconds; + _AbsolutelyNext = next; + Init(ms); + } + + /// 实例化一个绝对定时器,指定时刻执行,跟当前时间和SetNext无关 + /// 委托 + /// 用户数据 + /// 绝对开始时间 + /// 间隔周期。毫秒 + /// 调度器 + public TimerX(Func callback, Object? state, DateTime startTime, Int32 period, String? scheduler = null) : this(callback.Target, callback.Method, state, scheduler) + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + if (startTime <= DateTime.MinValue) throw new ArgumentOutOfRangeException(nameof(startTime)); + if (period <= 0) throw new ArgumentOutOfRangeException(nameof(period)); + + IsAsyncTask = true; + Async = true; + Period = period; + Absolutely = true; + + //var now = DateTime.Now; + var now = Scheduler.GetNow(); + var next = startTime; + while (next < now) next = next.AddMilliseconds(period); + + var ms = (Int64)(next - now).TotalMilliseconds; + _AbsolutelyNext = next; + Init(ms); + } + + /// 实例化一个Cron定时器 + /// 委托 + /// 用户数据 + /// Cron表达式。支持多个表达式,分号分隔 + /// 调度器 + public TimerX(TimerCallback callback, Object? state, String cronExpression, String? scheduler = null) : this(callback.Target, callback.Method, state, scheduler) + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + if (cronExpression.IsNullOrEmpty()) throw new ArgumentNullException(nameof(cronExpression)); + + var list = new List(); + foreach (var item in cronExpression.Split(";")) + { + var cron = new Cron(); + if (!cron.Parse(item)) throw new ArgumentException($"Invalid Cron expression[{item}]", nameof(cronExpression)); + + list.Add(cron); + } + _crons = list.ToArray(); + + Absolutely = true; + + //var now = DateTime.Now; + var now = Scheduler.GetNow(); + var next = _crons.Min(e => e.GetNext(now)); + var ms = (Int64)(next - now).TotalMilliseconds; + _AbsolutelyNext = next; + Init(ms); + //Init(_AbsolutelyNext = _cron.GetNext(DateTime.Now)); + } + + /// 实例化一个Cron定时器 + /// 委托 + /// 用户数据 + /// Cron表达式。支持多个表达式,分号分隔 + /// 调度器 + public TimerX(Func callback, Object? state, String cronExpression, String? scheduler = null) : this(callback.Target, callback.Method, state, scheduler) + { + if (callback == null) throw new ArgumentNullException(nameof(callback)); + if (cronExpression.IsNullOrEmpty()) throw new ArgumentNullException(nameof(cronExpression)); + + var list = new List(); + foreach (var item in cronExpression.Split(";")) + { + var cron = new Cron(); + if (!cron.Parse(item)) throw new ArgumentException($"Invalid Cron expression[{item}]", nameof(cronExpression)); + + list.Add(cron); + } + _crons = list.ToArray(); + + IsAsyncTask = true; + Async = true; + Absolutely = true; + + //var now = DateTime.Now; + var now = Scheduler.GetNow(); + var next = _crons.Min(e => e.GetNext(now)); + var ms = (Int64)(next - now).TotalMilliseconds; + _AbsolutelyNext = next; + Init(ms); + //Init(_AbsolutelyNext = _cron.GetNext(DateTime.Now)); + } + + /// 销毁定时器 + public void Dispose() + { + Dispose(true); + + // 告诉GC,不要调用析构函数 + GC.SuppressFinalize(this); + } + + /// 销毁 + /// + protected virtual void Dispose(Boolean disposing) + { + if (disposing) + { + // 释放托管资源 + } + + // 释放非托管资源 + Scheduler?.Remove(this, disposing ? "Dispose" : "GC"); + } + +#if NET6_0_OR_GREATER + /// 异步销毁 + /// + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } +#endif + #endregion + + #region 方法 + /// 是否已设置下一次时间 + internal Boolean hasSetNext; + + private void SetNextTick(Int64 ms) + { + // 使用开机滴答来做定时调度,无惧时间回拨,每次修正时间基准 + var tick = Runtime.TickCount64; + _baseTime = Scheduler.GetNow().AddMilliseconds(-tick); + _nextTick = tick + ms; + } + + /// 设置下一次运行时间 + /// 小于等于0表示马上调度 + public void SetNext(Int32 ms) + { + //NextTime = DateTime.Now.AddMilliseconds(ms); + + SetNextTick(ms); + + hasSetNext = true; + + Scheduler.Wake(); + } + + /// 设置下一次执行时间,并获取间隔 + /// 返回下一次执行的间隔时间,不能小于等于0,否则定时器被销毁 + internal Int32 SetAndGetNextTime() + { + // 如果已设置 + var period = Period; + var nowTick = Runtime.TickCount64; + if (hasSetNext) + { + var ts = (Int32)(_nextTick - nowTick); + return ts > 0 ? ts : period; + } + + if (Absolutely) + { + // Cron以当前时间开始计算下一次 + // 绝对时间还没有到时,不计算下一次 + //var now = DateTime.Now; + var now = Scheduler.GetNow(); + DateTime next; + if (_crons != null) + { + next = _crons.Min(e => e.GetNext(now)); + + // 如果cron计算得到的下一次时间过近,则需要重新计算 + if ((next - now).TotalMilliseconds < 1000) next = _crons.Min(e => e.GetNext(next)); + } + else + { + // 能够处理基准时间变大,但不能处理基准时间变小 + next = _AbsolutelyNext; + while (next < now) next = next.AddMilliseconds(period); + } + + // 即使基准时间改变,也不影响绝对时间定时器的执行时刻 + _AbsolutelyNext = next; + var ts = (Int32)Math.Round((next - now).TotalMilliseconds); + SetNextTick(ts); + + return ts > 0 ? ts : period; + } + else + { + //NextTime = DateTime.Now.AddMilliseconds(period); + SetNextTick(period); + + return period; + } + } + + /// 更改计时器的启动时间和方法调用之间的时间间隔,使用 TimeSpan 值度量时间间隔。 + /// 一个 TimeSpan,表示在调用构造 ITimer 时指定的回调方法之前的延迟时间量。 指定 InfiniteTimeSpan 可防止重新启动计时器。 指定 Zero 可立即重新启动计时器。 + /// 构造 Timer 时指定的回调方法调用之间的时间间隔。 指定 InfiniteTimeSpan 可以禁用定期终止。 + /// + public Boolean Change(TimeSpan dueTime, TimeSpan period) + { + if (Absolutely) return false; + if (Crons != null && Crons.Length > 0) return false; + + if (period.TotalMilliseconds <= 0) + { + Dispose(); + return true; + } + + Period = (Int32)period.TotalMilliseconds; + + if (dueTime.TotalMilliseconds >= 0) SetNext((Int32)dueTime.TotalMilliseconds); + + return true; + } + #endregion + + #region 静态方法 + /// 延迟执行一个委托。特别要小心,很可能委托还没被执行,对象就被gc回收了 + /// + /// + /// + public static TimerX Delay(TimerCallback callback, Int32 ms) => new(callback, null, ms, 0) { Async = true }; + + private static TimerX? _NowTimer; + private static DateTime _Now; + /// 当前时间。定时读取系统时间,避免频繁读取系统时间造成性能瓶颈 + public static DateTime Now + { + get + { + if (_NowTimer == null) + { + lock (TimerScheduler.Default) + { + if (_NowTimer == null) + { + // 多线程下首次访问Now可能取得空时间 + _Now = TimerScheduler.Default.GetNow(); + + _NowTimer = new TimerX(CopyNow, null, 0, 500); + } + } + } + + return _Now; + } + } + + private static void CopyNow(Object? state) => _Now = TimerScheduler.Default.GetNow(); + #endregion + + #region 辅助 + /// 已重载 + /// + public override String ToString() => $"[{Id}]{Method.DeclaringType?.Name}.{Method.Name} ({(_crons != null ? _crons.Join(";") : (Period + "ms"))})"; + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Web/Link.cs b/src/Admin/ThingsGateway.NewLife.X/Web/Link.cs new file mode 100644 index 000000000..9e03eb8c2 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Web/Link.cs @@ -0,0 +1,340 @@ +using System.Text.RegularExpressions; + +using ThingsGateway.NewLife.Collections; + +namespace ThingsGateway.NewLife.Web; + +/// 超链接 +public class Link +{ + #region 属性 + /// 名称 + public String Name { get; set; } = null!; + + /// 全名 + public String? FullName { get; set; } + + /// 超链接 + public String? Url { get; set; } + + /// 原始超链接 + public String? RawUrl { get; set; } + + /// 标题 + public String? Title { get; set; } + + /// 版本 + public Version? Version { get; set; } + + /// 时间 + public DateTime Time { get; set; } + + /// 哈希 + public String? Hash { get; set; } + + /// 原始Html + public String? Html { get; set; } + #endregion + + #region 方法 + private static readonly Regex _regA = new("""(?<时间>[^<]*)\s*(?<大小>[^<]*)\s*\s*]* href="?(?<链接>[^>"]*)"?[^>]*>(?<名称>[^<]*)\s*[^>]*]*>(?<哈希>[^<]*)""", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + private static readonly Regex _regTitle = new("""title=("?)(?<标题>[^ ']*?)\1""", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled); + + /// 分析HTML中的链接 + /// Html文本 + /// 基础Url,用于生成超链接的完整Url + /// 用于基础过滤的过滤器 + /// + public static Link[] Parse(String html, String? baseUrl = null, Func? filter = null) + { + // baseurl必须是/结尾 + if (baseUrl != null && !baseUrl.EndsWith('/')) baseUrl += "/"; + if (baseUrl.StartsWithIgnoreCase("ftp://")) return ParseFTP(html, baseUrl, filter); + + // 分析所有链接 + var list = new List(); + var buri = baseUrl.IsNullOrEmpty() ? null : new Uri(baseUrl); + foreach (var item in _regA.Matches(html)) + { + if (item is not Match match) continue; + + var link = new Link + { + Html = match.Value, + FullName = match.Groups["名称"].Value.Trim(), + Url = match.Groups["链接"].Value.Trim(), + Hash = match.Groups["哈希"].Value.Trim(), + Time = match.Groups["时间"].Value.Trim().ToDateTime(), + }; + if (link.Hash.Contains("<")) link.Hash = null; + link.RawUrl = link.Url; + link.Name = link.FullName; + + // 过滤器 + if (filter != null && !filter(link)) continue; + + link.Url = link.Url.TrimStart("#"); + if (String.IsNullOrEmpty(link.Url)) continue; + + if (link.Url.StartsWithIgnoreCase("javascript:")) continue; + + //// 分析title + //var txt = match.Groups["其它1"].Value.Trim(); + //if (txt.IsNullOrWhiteSpace() || !_regTitle.IsMatch(txt)) txt = match.Groups["其它2"].Value.Trim(); + //var mc = _regTitle.Match(txt); + //if (mc.Success) + //{ + // link.Title = mc.Groups["标题"].Value.Trim(); + //} + + // 完善下载地址 + if (buri != null) + { + var uri = new Uri(buri, link.RawUrl); + link.Url = uri.ToString(); + } + else + { + link.Url = link.RawUrl; + } + + // 从github.com下载需要处理Url + if (link.Url.Contains("github.com") && link.Url.Contains("/blob/")) link.Url = link.Url.Replace("/blob/", "/raw/"); + + // 分割名称,计算结尾的时间 yyyyMMddHHmmss + //if (link.Time.Year < 1000) + link.ParseTime(); + + // 分割版本,_v1.0.0.0 + link.ParseVersion(); + + // 去掉后缀,特殊处理.tar.gz双后缀 + var name = link.Name; + if (name.EndsWithIgnoreCase(".tar.gz")) + link.Name = name[..^7]; + else + { + var p = name.LastIndexOf('.'); + if (p > 0) link.Name = name[..p]; + } + + list.Add(link); + } + + return list.ToArray(); + } + + private static Link[] ParseFTP(String html, String? baseUrl, Func? filter = null) + { + var list = new List(); + + var ns = html.Split("\r\n", "\r", "\n"); + if (ns.Length == 0) return list.ToArray(); + + // 如果由很多段组成,可能是unix格式 + _ = ns[0].Split(' ').Length >= 6; + var buri = baseUrl.IsNullOrEmpty() ? null : new Uri(baseUrl); + foreach (var item in ns) + { + var link = new Link + { + FullName = item + }; + link.Name = link.FullName; + //link.Name = Path.GetFileNameWithoutExtension(item); + //link.Url = new Uri(buri, item).ToString(); + //link.RawUrl = link.Url; + + // 过滤器 + if (filter != null && !filter(link)) continue; + + // 分析title + link.Title = Path.GetFileNameWithoutExtension(item); + + // 完善下载地址 + if (buri != null) + { + var uri = new Uri(buri, item); + link.Url = uri.ToString(); + } + else + { + link.Url = item; + } + + //if (link.Time.Year < 1000) + { + // 分割名称,计算结尾的时间 yyyyMMddHHmmss + var idx = link.ParseTime(); + if (idx > 0) link.Title = link.Title[..idx]; + } + + { + // 分割版本,_v1.0.0.0 + var idx = link.ParseVersion(); + if (idx > 0) link.Title = link.Title[..idx]; + } + + // 去掉后缀,特殊处理.tar.gz双后缀 + var name = link.Name; + if (name.EndsWithIgnoreCase(".tar.gz")) + link.Name = name[..^7]; + else + { + var p = name.LastIndexOf('.'); + if (p > 0) link.Name = name[..p]; + } + + list.Add(link); + } + + return list.ToArray(); + } + + /// 分解文件 + /// + /// + public Link Parse(String file) + { + RawUrl = file; + Url = file.GetFullPath(); + FullName = Path.GetFileName(file); + Name = FullName; + + ParseTime(); + ParseVersion(); + + // 去掉后缀,特殊处理.tar.gz双后缀 + var name = Name; + if (name.EndsWithIgnoreCase(".tar.gz")) + Name = name[..^7]; + else + { + var p = name.LastIndexOf('.'); + if (p > 0) Name = name[..p]; + } + + // 时间 + if (Time.Year < 2000) + { + var fi = file.AsFile(); + if (fi != null && fi.Exists) Time = fi.LastWriteTime; + } + + return this; + } + + /// 从名称分解时间 + /// + public Int32 ParseTime() + { + var name = Name; + if (name.IsNullOrEmpty()) return -1; + + // 分割名称,计算结尾的时间 yyyyMMddHHmmss + var p = name.LastIndexOf('_'); + if (p <= 0) return -1; + + var ts = name[(p + 1)..]; + if (ts.StartsWith("20") && ts.Length >= 4 + 2 + 2 + 2 + 2 + 2) + { + Time = new DateTime( + ts[..4].ToInt(), + ts.Substring(4, 2).ToInt(), + ts.Substring(6, 2).ToInt(), + ts.Substring(8, 2).ToInt(), + ts.Substring(10, 2).ToInt(), + ts.Substring(12, 2).ToInt()); + + Name = name[..p] + name[(p + 1 + 14)..]; + } + else if (ts.StartsWith("20") && ts.Length >= 4 + 2 + 2) + { + Time = new DateTime( + ts[..4].ToInt(), + ts.Substring(4, 2).ToInt(), + ts.Substring(6, 2).ToInt()); + + Name = name[..p] + name[(p + 1 + 8)..]; + } + + return p; + } + + /// 从名称分解版本 + /// + public Int32 ParseVersion() + { + var name = Name; + if (name.IsNullOrEmpty()) return -1; + + // 分割版本,_v1.0.0.0 + var p = IndexOfAny(name, ["_v", "_V", ".v", ".V", " v", " V"], 0); + if (p <= 0) return -1; + + // 后续位置 + var p2 = name.IndexOfAny([' ', '_', '-'], p + 2); + if (p2 < 0) + { + p2 = name.LastIndexOf('.'); + if (p2 <= p) p2 = -1; + } + if (p2 < 0) p2 = name.Length; + + // 尾部截断 + var vs = name.Substring(p + 2, p2 - p - 2); + // 有可能只有_v1,而没有子版本 + var ss = vs.SplitAsInt("."); + if (ss.Length > 0) + { + switch (ss.Length) + { + case 1: + Version = new Version(ss[0], 0); + break; + case 2: + Version = new Version(ss[0], ss[1]); + break; + case 3: + Version = new Version(ss[0], ss[1], ss[2]); + break; + case 4: + Version = new Version(ss[0], ss[1], ss[2], ss[3]); + break; + default: + break; + } + + var str = name[..p]; + if (p2 < name.Length) str += name[p2..]; + Name = str; + } + + // 返回位置 + return p; + } + + private static Int32 IndexOfAny(String str, String[] anyOf, Int32 startIndex) + { + foreach (var item in anyOf) + { + var p = str.IndexOf(item, startIndex); + if (p >= 0) return p; + } + + return -1; + } + + /// 已重载。 + /// + public override String ToString() + { + var sb = Pool.StringBuilder.Get(); + sb.AppendFormat("{0} {1}", Name, RawUrl); + if (Version != null) sb.AppendFormat(" v{0}", Version); + if (Time > DateTime.MinValue) sb.AppendFormat(" {0}", Time.ToFullString()); + + return sb.Return(true); + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Web/TokenModel.cs b/src/Admin/ThingsGateway.NewLife.X/Web/TokenModel.cs new file mode 100644 index 000000000..249657dc0 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Web/TokenModel.cs @@ -0,0 +1,23 @@ +using System.Runtime.Serialization; + +namespace ThingsGateway.NewLife.Web; + +/// 访问令牌模型 +public class TokenModel +{ + /// 访问令牌 + [DataMember(Name = "access_token")] + public String? AccessToken { get; set; } + + /// 令牌类型 + [DataMember(Name = "token_type")] + public String? TokenType { get; set; } + + /// 过期时间。秒 + [DataMember(Name = "expire_in")] + public Int32 ExpireIn { get; set; } + + /// 刷新令牌 + [DataMember(Name = "refresh_token")] + public String? RefreshToken { get; set; } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Web/TokenProvider.cs b/src/Admin/ThingsGateway.NewLife.X/Web/TokenProvider.cs new file mode 100644 index 000000000..4ac991990 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Web/TokenProvider.cs @@ -0,0 +1,97 @@ +using ThingsGateway.NewLife.Security; + +namespace ThingsGateway.NewLife.Web; + +/// 令牌提供者 +/// +/// 文档 https://newlifex.com/core/token_provider +/// +public class TokenProvider +{ + #region 属性 + /// 密钥。签发方用私钥,验证方用公钥 + public String? Key { get; set; } + #endregion + + #region 方法 + /// 读取密钥 + /// 文件 + /// 是否生成 + /// + public Boolean ReadKey(String file, Boolean generate = false) + { + if (file.IsNullOrEmpty()) return false; + + file = file.GetBasePath(); + if (File.Exists(file)) + { + Key = File.ReadAllText(file); + + if (!Key.IsNullOrEmpty()) return true; + } + + if (!generate || !file.EndsWithIgnoreCase(".prvkey")) return false; + + var ss = DSAHelper.GenerateKey(); + File.WriteAllText(file.EnsureDirectory(true), ss[0]); + file = Path.ChangeExtension(file, ".pubkey"); + File.WriteAllText(file, ss[1]); + + Key = ss[0]; + + return true; + } + + /// 编码用户和有效期得到令牌 + /// 用户 + /// 有效期 + /// + public String Encode(String user, DateTime expire) + { + if (user.IsNullOrEmpty()) throw new ArgumentNullException(nameof(user)); + if (expire.Year < 2000) throw new ArgumentOutOfRangeException(nameof(expire)); + if (Key.IsNullOrEmpty()) throw new ArgumentNullException(nameof(Key)); + + var secs = expire.ToUniversalTime().ToInt(); + + // 拼接数据并签名 + var data = (user + "," + secs).GetBytes(); + var sig = DSAHelper.Sign(data, Key); + + // Base64拼接数据和签名 + return data.ToUrlBase64() + "." + sig.ToUrlBase64(); + } + + /// 尝试解码令牌,即使失败,也会返回用户信息和有效时间 + /// 令牌 + /// 用户信息 + /// 有效时间 + /// 解码结果,成功或失败 + public Boolean TryDecode(String token, out String user, out DateTime expire) + { + if (token.IsNullOrEmpty()) throw new ArgumentNullException(nameof(token)); + //if (Key.IsNullOrEmpty()) throw new ArgumentNullException(nameof(Key)); + + // Base64拆分数据和签名 + var p = token.IndexOf('.'); + var data = token[..p].ToBase64(); + var sig = token[(p + 1)..].ToBase64(); + + // 拆分数据和有效期 + var str = data.ToStr(); + p = str.LastIndexOf(','); + + user = str[..p]; + var secs = str[(p + 1)..].ToInt(); + expire = secs.ToDateTime().ToLocalTime(); + + if (Key.IsNullOrEmpty()) return false; + + // 验证签名 + //if (!DSAHelper.Verify(data, Key, sig)) throw new InvalidOperationException("签名验证失败!"); + if (!DSAHelper.Verify(data, Key, sig)) return false; + + return true; + } + #endregion +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Web/UriInfo.cs b/src/Admin/ThingsGateway.NewLife.X/Web/UriInfo.cs new file mode 100644 index 000000000..0f411f7a3 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Web/UriInfo.cs @@ -0,0 +1,139 @@ +namespace ThingsGateway.NewLife.Web; + +/// 资源定位。无限制解析Url地址 +public class UriInfo +{ + /// 协议 + public String? Scheme { get; set; } + + /// 主机 + public String? Host { get; set; } + + /// 端口 + public Int32 Port { get; set; } + + /// 路径 + public String? AbsolutePath { get; set; } + + /// 查询 + public String? Query { get; set; } + + /// 路径与查询 + public String? PathAndQuery + { + get + { + if (Query.IsNullOrEmpty()) return AbsolutePath; + + if (Query[0] == '?') return AbsolutePath + Query; + + return $"{AbsolutePath}?{Query}"; + } + } + + /// 实例化 + /// + public UriInfo(String value) => Parse(value); + + /// 主机与端口。省略默认端口 + public String? Authority + { + get + { + if (Port == 0) return Host; + if (Host.IsNullOrEmpty()) return Host; + + if (Scheme.EqualIgnoreCase("http", "ws")) + return Port == 80 ? Host : $"{Host}:{Port}"; + else if (Scheme.EqualIgnoreCase("https", "wss")) + return Port == 443 ? Host : $"{Host}:{Port}"; + + return $"{Host}:{Port}"; + } + } + + /// 解析Url字符串 + /// + public void Parse(String value) + { + if (value.IsNullOrWhiteSpace()) return; + + // 先处理头尾,再处理中间的主机和端口 + var p = value.IndexOf("://"); + if (p >= 0) + { + Scheme = value[..p]; + p += 3; + } + else + p = 0; + + // 第二步找到/,它左边是主机和端口,右边是路径和查询。如果没有/,则整个字符串都是主机和端口 + var p2 = value.IndexOf('/', p); + if (p2 >= 0) + { + ParseHost(value[p..p2]); + ParsePath(value, p2); + } + else + { + p2 = value.IndexOf('?', p); + if (p2 >= 0) + { + ParseHost(value[p..p2]); + Query = value[p2..]; + } + else + { + Host = value[p..]; + } + } + + if (AbsolutePath.IsNullOrEmpty()) AbsolutePath = "/"; + } + + private void ParsePath(String value, Int32 p) + { + // 第二步找到/,它左边是主机和端口,右边是路径和查询。如果没有/,则整个字符串都是主机和端口 + var p2 = value.IndexOf('?', p); + if (p2 >= 0) + { + AbsolutePath = value[p..p2]; + Query = value[p2..]; + } + else + { + AbsolutePath = value[p..]; + } + } + + private void ParseHost(String value) + { + // 拆分主机和端口,注意IPv6地址 + var p2 = value.LastIndexOf(':'); + if (p2 > 0) + { + Host = value[..p2]; + Port = value[(p2 + 1)..].ToInt(); + } + else if (!value.IsNullOrEmpty()) + { + Host = value; + } + } + + /// 已重载。 + /// + public override String? ToString() + { + var authority = Authority; + if (Scheme.IsNullOrEmpty()) + { + if (authority.IsNullOrEmpty()) return PathAndQuery; + + return $"{authority}{PathAndQuery}"; + } + + return $"{Scheme}://{authority}{PathAndQuery}"; + } +} diff --git a/src/Admin/ThingsGateway.NewLife.X/Windows/ConsoleHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Windows/ConsoleHelper.cs new file mode 100644 index 000000000..eeb910bbb --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Windows/ConsoleHelper.cs @@ -0,0 +1,84 @@ +using System.Runtime.InteropServices; + +namespace ThingsGateway.NewLife.Windows; + +/// +/// 控制台帮助类,用于控制控制台的快速编辑、关闭按钮。 +/// +public class ConsoleHelper +{ + #region 关闭控制台 快速编辑模式、插入模式 + + private const Int32 STD_INPUT_HANDLE = -10; + private const UInt32 ENABLE_QUICK_EDIT_MODE = 0x0040; + private const UInt32 ENABLE_INSERT_MODE = 0x0020; + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern IntPtr GetStdHandle(Int32 hConsoleHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern Boolean GetConsoleMode(IntPtr hConsoleHandle, out UInt32 mode); + + [DllImport("kernel32.dll", SetLastError = true)] + internal static extern Boolean SetConsoleMode(IntPtr hConsoleHandle, UInt32 mode); + + /// + /// 退出编辑模式 + /// + public static void DisableQuickEditMode() + { + var hStdin = GetStdHandle(STD_INPUT_HANDLE); + GetConsoleMode(hStdin, out var mode); + mode &= ~ENABLE_QUICK_EDIT_MODE;//移除快速编辑模式 + mode &= ~ENABLE_INSERT_MODE; //移除插入模式 + SetConsoleMode(hStdin, mode); + } + + #endregion 关闭控制台 快速编辑模式、插入模式 + + #region 设置控制台标题 禁用关闭按钮 + + [DllImport("user32.dll", EntryPoint = "FindWindow")] + private static extern IntPtr FindWindow(String? lpClassName, String lpWindowName); + + [DllImport("user32.dll", EntryPoint = "GetSystemMenu")] + private static extern IntPtr GetSystemMenu(IntPtr hWnd, IntPtr bRevert); + + [DllImport("user32.dll", EntryPoint = "RemoveMenu")] + private static extern IntPtr RemoveMenu(IntPtr hMenu, UInt32 uPosition, UInt32 uFlags); + + /// + /// 禁用关闭按钮 + /// + /// 控制台标题,程序名称 + public static void DisableCloseButton(String cmdTitle) + { + var windowHandle = FindWindow(null, cmdTitle); + var closeMenu = GetSystemMenu(windowHandle, IntPtr.Zero); + UInt32 SC_CLOSE = 0xF060; + RemoveMenu(closeMenu, SC_CLOSE, 0x0); + } + + /// + /// 禁用关闭按钮 + /// + /// 窗口句柄 + public static void DisableCloseButton(IntPtr windowHandle) + { + var closeMenu = GetSystemMenu(windowHandle, IntPtr.Zero); + UInt32 SC_CLOSE = 0xF060; + RemoveMenu(closeMenu, SC_CLOSE, 0x0); + } + + /// + /// 关闭控制台 + /// + /// + /// + protected static void CloseConsole(Object sender, ConsoleCancelEventArgs e) + { + Environment.Exit(0); + } + + #endregion 设置控制台标题 禁用关闭按钮 +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Windows/ControlHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Windows/ControlHelper.cs new file mode 100644 index 000000000..3ab32cd98 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Windows/ControlHelper.cs @@ -0,0 +1,557 @@ +#if WIN +using System.Drawing; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using System.Windows.Forms; + +using ThingsGateway.NewLife; +using ThingsGateway.NewLife.Log; +using ThingsGateway.NewLife.Reflection; +using ThingsGateway.NewLife.Threading; + +namespace ThingsGateway.NewLife.Windows; + +/// 控件助手 +public static class ControlHelper +{ + #region 在UI线程上执行委托 + /// 执行无参委托 + /// + /// + /// + public static void Invoke(this Control control, Action method) + { + if (control.IsDisposed) return; + + control.BeginInvoke(new Action(() => + { + //using var tc = new TimeCost("Control.Invoke", 500); + method(); + })); + } + + ///// 执行仅返回值委托 + ///// + ///// + ///// + ///// + //public static TResult Invoke(this Control control, Func method) + //{ + // if (control.IsDisposed) return default(TResult); + + // return (TResult)control.Invoke(method); + //} + + /// 执行单一参数无返回值的委托 + /// + /// + /// + /// + /// + public static void Invoke(this Control control, Action method, T arg) + { + if (control.IsDisposed) return; + + control.BeginInvoke(new Action(() => + { + //using var tc = new TimeCost("Control.Invoke", 500); + method(arg); + })); + } + + ///// 执行单一参数和返回值的委托 + ///// + ///// + ///// + ///// + ///// + ///// + //public static TResult Invoke(this Control control, Func method, T arg) + //{ + // if (control.IsDisposed) return default(TResult); + + // return (TResult)control.Invoke(method, arg); + //} + + /// 执行二参数无返回值的委托 + /// + /// + /// + /// + /// + /// + public static void Invoke(this Control control, Action method, T arg, T2 arg2) + { + if (control.IsDisposed) return; + + control.BeginInvoke(new Action(() => + { + //using var tc = new TimeCost("Control.Invoke", 500); + method(arg, arg2); + })); + } + + ///// 执行二参数和返回值的委托 + ///// + ///// + ///// + ///// + ///// + ///// + ///// + ///// + //public static TResult Invoke(this Control control, Func method, T arg, T2 arg2) + //{ + // return (TResult)control.Invoke(method, arg, arg2); + //} + #endregion + + #region 文本控件扩展 + private static readonly Regex _line = new("(?:[^\n])\r", RegexOptions.Compiled); + /// 附加文本到文本控件末尾。主要解决非UI线程以及滚动控件等问题 + /// 控件 + /// 消息 + /// 最大行数。超过该行数讲清空控件 + /// + public static TextBoxBase Append(this TextBoxBase txt, String msg, Int32 maxLines = 1000) + { + if (txt.IsDisposed) return txt; + + var func = new Action(m => + { + try + { + if (txt.Lines.Length >= maxLines) txt.Clear(); + + // 记录原选择 + var selstart = txt.SelectionStart; + var sellen = txt.SelectionLength; + + // 输出日志 + if (m != null) + { + //txt.AppendText(m); + // 需要考虑处理特殊符号 + //ProcessBell(ref m); + //ProcessBackspace(txt, ref m); + //ProcessReturn(txt, ref m); + + m = m.Trim('\0'); + // 针对非Windows系统到来的数据,处理一下换行 + if (txt is RichTextBox && Environment.NewLine == "\r\n") + { + // 合并多个回车 + while (m.Contains("\r\r")) m = m.Replace("\r\r", "\r"); + //while (m.Contains("\n\r")) m = m.Replace("\n\r", "\r\n"); + //m = m.Replace("\r\n", ""); + m = m.Replace("\r\n", "\n"); + //m = m.Replace("\r", "\r\n"); + m = m.Replace("\n\r", "\n"); + // 单独的\r换成\n + //if (_line.IsMatch(m)) + // m = _line.Replace(m, "\n"); + m = m.Replace("\r", "\n"); + //m = m.Replace("\r", null); + //m = m.Replace("", "\r\n"); + } + if (String.IsNullOrEmpty(m)) return; + txt.AppendText(m); + } + + // 如果有选择,则不要滚动 + if (sellen > 0) + { + // 恢复选择 + if (selstart < txt.TextLength) + { + sellen = Math.Min(sellen, txt.TextLength - selstart - 1); + txt.Select(selstart, sellen); + txt.ScrollToCaret(); + } + + return; + } + + txt.Scroll(); + } + catch { } + }); + + //txt.Invoke(func, msg); + var ar = txt.BeginInvoke(func, msg); + //ar.AsyncWaitHandle.WaitOne(100); + //if (!ar.AsyncWaitHandle.WaitOne(10)) + // txt.EndInvoke(ar); + + return txt; + } + + /// 滚动控件的滚动条 + /// 指定控件 + /// 是否底端,或者顶端 + /// + public static TextBoxBase Scroll(this TextBoxBase txt, Boolean bottom = true) + { + if (txt.IsDisposed) return txt; + + _ = SendMessage(txt.Handle, WM_VSCROLL, bottom ? SB_BOTTOM : SB_TOP, 0); + + return txt; + } + + private static void ProcessBackspace(TextBoxBase txt, ref String? m) + { + while (!m.IsNullOrEmpty()) + { + var p = m.IndexOf('\b'); + if (p < 0) break; + + // 计算一共有多少个字符 + var count = 1; + while (p + count < m.Length && m[p + count] == '\b') count++; + + // 前面的字符不足,消去前面历史字符 + if (p < count) + { + count -= p; + // 选中最后字符,然后干掉它 + if (txt.TextLength > count) + { + txt.Select(txt.TextLength - count, count); + txt.SelectedText = null; + } + else + txt.Clear(); + } + else if (p > count) + { + // 少输出一个 + txt.AppendText(m[..(p - count)]); + } + + if (p == m.Length - count) + { + m = null; + break; + } + m = m[(p + count)..]; + } + } + + /// 处理回车,移到行首 + /// + /// + private static void ProcessReturn(TextBoxBase txt, ref String? m) + { + while (!m.IsNullOrEmpty()) + { + var p = m.IndexOf('\r'); + if (p < 0) break; + + // 后一个是 + if (p + 1 < m.Length && m[p + 1] == '\n') + { + txt.AppendText(m[..(p + 2)]); + m = m[(p + 2)..]; + continue; + } + + // 后一个不是\n,移到行首 + if (p >= 0) + { + // 取得最后一行首字符索引 + var lines = txt.Lines.Length; + var last = lines <= 1 ? 0 : txt.GetFirstCharIndexFromLine(lines - 1); + if (last >= 0) + { + // 最后一行第一个字符,删掉整行 + txt.Select(last, txt.TextLength - last); + txt.SelectedText = null; + } + } + + if (p + 1 == m.Length) + { + m = null; + break; + } + m = m[(p + 1)..]; + } + } + + private static void ProcessBell(ref String m) + { + var ch = (Char)7; + var p = 0; + while (true) + { + p = m.IndexOf(ch, p); + if (p < 0) break; + + if (p > 0) + { + var str = m[..p]; + if (p + 1 < m.Length) str += m[(p + 1)..]; + m = str; + } + + //Console.Beep(); + // 用定时器来控制Beep,避免被堵塞 + _timer ??= new TimerX(Bell, null, 100, 100); + _Beep = true; + //SystemSounds.Beep.Play(); + p++; + } + } + + private static TimerX? _timer; + private static Boolean _Beep; + + private static void Bell(Object? state) + { + if (_Beep) + { + _Beep = false; + Console.Beep(); + } + } + + [DllImport("user32.dll")] + private static extern Int32 SendMessage(IntPtr hwnd, Int32 wMsg, Int32 wParam, Int32 lParam); + private const Int32 SB_TOP = 6; + private const Int32 SB_BOTTOM = 7; + private const Int32 WM_VSCROLL = 0x115; + #endregion + + #region 设置控件样式 + /// 设置默认样式,包括字体、前景色、背景色 + /// 控件 + /// 字体大小 + /// + public static Control SetDefaultStyle(this Control control, Int32 size = 10) + { + control.SetFontSize(size); + control.ForeColor = Color.White; + control.BackColor = Color.FromArgb(42, 33, 28); + return control; + } + + /// 设置字体大小 + /// + /// + /// + public static Control SetFontSize(this Control control, Int32 size = 10) + { + control.Font = new Font(control.Font.FontFamily, size, GraphicsUnit.Point); + return control; + } + #endregion + + #region 文本控件着色 + //Int32 _pColor = 0; + private static readonly Color _Key = Color.FromArgb(255, 170, 0); + private static readonly Color _Num = Color.FromArgb(255, 58, 131); + private static readonly Color _KeyName = Color.FromArgb(0, 255, 255); + private static readonly String[] _Keys = [ + "(", ")", "{", "}", "[", "]", "*", "->", "+", "-", "*", "/", "\\", "%", "&", "|", "!", "=", ";", ",", ">", "<", + "void", "new", "delete", "true", "false" + ]; + + /// 采用默认着色方案进行着色 + /// 文本控件 + /// 开始位置 + public static Int32 ColourDefault(this RichTextBox rtb, Int32 start) + { + if (start > rtb.TextLength) start = 0; + if (start == rtb.TextLength) return start; + + // 有选择时不着色 + if (rtb.SelectionLength > 0) return start; + + //var color = Color.Yellow; + //var color = Color.FromArgb(255, 170, 0); + //ChangeColor("Send", color); + foreach (var item in _Keys) + { + ChangeColor(rtb, start, item, _Key); + } + + ChangeCppColor(rtb, start); + ChangeKeyNameColor(rtb, start); + ChangeNumColor(rtb, start); + + // 移到最后,避免瞬间有字符串写入,所以减去100 + start = rtb.TextLength; + if (start < 0) start = 0; + + return start; + } + + private static void ChangeColor(RichTextBox rtb, Int32 start, String text, Color color) + { + var s = start; + //while ((-1 + text.Length - 1) != (s = text.Length - 1 + rtx.Find(text, s, -1, RichTextBoxFinds.WholeWord))) + while (true) + { + if (s >= rtb.TextLength) break; + s = rtb.Find(text, s, -1, RichTextBoxFinds.WholeWord); + if (s < 0) break; + if (s > rtb.TextLength - 1) break; + s++; + + rtb.SelectionColor = color; + //rtx.SelectionFont = new Font(rtx.SelectionFont.FontFamily, rtx.SelectionFont.Size, FontStyle.Bold); + } + //rtx.Select(0, 0); + rtb.SelectionLength = 0; + } + + // 正则匹配,数字开头的词。支持0x开头的十六进制 + private static readonly Regex _reg = new(@"(?i)\b(0x|[0-9])([0-9a-fA-F\-]*)(.*?)\b", RegexOptions.Compiled); + + private static void ChangeNumColor(RichTextBox rtb, Int32 start) + { + //var ms = _reg.Matches(rtb.Text, start); + //foreach (Match item in ms) + //{ + // rtb.Select(item.Groups[1].Index, item.Groups[1].Length); + // rtb.SelectionColor = _Num; + + // rtb.Select(item.Groups[2].Index, item.Groups[2].Length); + // rtb.SelectionColor = _Num; + + // rtb.Select(item.Groups[3].Index, item.Groups[3].Length); + // rtb.SelectionColor = _Key; + //} + //rtb.SelectionLength = 0; + + rtb.Colour(_reg, start, _Num, _Num, _Key); + } + + private static readonly Regex _reg2 = new(@"(?i)(\b\w+\b)(\s*::\s*)(\b\w+\b)", RegexOptions.Compiled); + + /// 改变C++类名方法名颜色 + private static void ChangeCppColor(RichTextBox rtb, Int32 start) + { + var color = Color.FromArgb(30, 154, 224); + var color3 = Color.FromArgb(85, 228, 57); + + //var ms = _reg2.Matches(rtx.Text, start); + //foreach (Match item in ms) + //{ + // rtx.Select(item.Groups[1].Index, item.Groups[1].Length); + // rtx.SelectionColor = color; + + // rtx.Select(item.Groups[2].Index, item.Groups[2].Length); + // rtx.SelectionColor = _Key; + + // rtx.Select(item.Groups[3].Index, item.Groups[3].Length); + // rtx.SelectionColor = color3; + //} + //rtx.SelectionLength = 0; + + rtb.Colour(_reg2, start, color, _Key, color3); + } + + private static readonly Regex _reg3 = new(@"(?i)(\b\w+\b)(\s*[=:])[^:]\s*", RegexOptions.Compiled); + + private static void ChangeKeyNameColor(RichTextBox rtb, Int32 start) + { + //var ms = _reg3.Matches(rtx.Text, _pColor); + //foreach (Match item in ms) + //{ + // rtx.Select(item.Groups[1].Index, item.Groups[1].Length); + // rtx.SelectionColor = _KeyName; + + // rtx.Select(item.Groups[2].Index, item.Groups[2].Length); + // rtx.SelectionColor = _Key; + //} + //rtx.SelectionLength = 0; + + rtb.Colour(_reg3, start, _KeyName, _Key); + } + + /// 着色文本控件的内容 + /// 文本控件 + /// 正则表达式 + /// 开始位置 + /// 颜色数组 + /// + public static RichTextBox Colour(this RichTextBox rtb, Regex reg, Int32 start, params Color[] colors) + { + var ms = reg.Matches(rtb.Text, start); + foreach (Match item in ms) + { + //rtx.Select(item.Groups[1].Index, item.Groups[1].Length); + //rtx.SelectionColor = _KeyName; + + //rtx.Select(item.Groups[2].Index, item.Groups[2].Length); + //rtx.SelectionColor = _Key; + + // 如果没有匹配组,说明作为整体着色 + if (item.Groups.Count <= 1) + { + rtb.Select(item.Groups[0].Index, item.Groups[0].Length); + rtb.SelectionColor = colors[0]; + } + else + { + // 遍历匹配组,注意0号代表整体 + for (var i = 1; i < item.Groups.Count; i++) + { + rtb.Select(item.Groups[i].Index, item.Groups[i].Length); + rtb.SelectionColor = colors[i - 1]; + } + } + } + rtb.SelectionLength = 0; + + return rtb; + } + + /// 着色文本控件的内容 + /// 文本控件 + /// 正则表达式 + /// 开始位置 + /// 颜色数组 + /// + public static RichTextBox Colour(this RichTextBox rtb, String reg, Int32 start, params Color[] colors) + { + var r = new Regex(reg, RegexOptions.Compiled); + return Colour(rtb, r, start, colors); + } + #endregion + + #region DPI修复 + /// 当前Dpi + public static Int32 Dpi { get; set; } + + /// 修正ListView的Dpi + /// + public static void FixDpi(this ListView lv) + { + if (Dpi == 0) Dpi = (Int32)lv.CreateGraphics().DpiX; + + foreach (ColumnHeader item in lv.Columns) + { + item.Width *= Dpi / 96; + } + } + + /// 修正窗体的Dpi + /// + public static void FixDpi(this Form frm) + { + // 只要重新设置一次字体,就可以适配高Dpi,不晓得为啥 + frm.Font = new Font("宋体", 9F, FontStyle.Regular, GraphicsUnit.Point, 134); + + foreach (var fi in frm.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic)) + { + if (fi.FieldType == typeof(ListView) && frm.GetValue(fi) is ListView lv) + lv.FixDpi(); + } + } + #endregion +} +#endif diff --git a/src/Admin/ThingsGateway.NewLife.X/Windows/PowerStatus.cs b/src/Admin/ThingsGateway.NewLife.X/Windows/PowerStatus.cs new file mode 100644 index 000000000..4a9428579 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Windows/PowerStatus.cs @@ -0,0 +1,109 @@ +using System.Runtime.InteropServices; + +namespace ThingsGateway.NewLife.Windows; + +/// 系统电源状态 +public enum PowerLineStatus +{ + /// 脱机状态 + Offline = 0, + /// 联机状态 + Online = 1, + /// 电源状态未知 + Unknown = 255 +} + +/// 充电状态信息 +[Flags] +public enum BatteryChargeStatus +{ + /// 指示电池能量级别较高 + High = 1, + /// 指示电池能量级别较低 + Low = 2, + /// 指示电池能量严重不足 + Critical = 4, + /// 指示电池正在充电 + Charging = 8, + /// 指示没有电池存在 + NoSystemBattery = 0x80, + /// 指示未知电池状态 + Unknown = 0xFF +} + +/// 电源状态 +public class PowerStatus +{ + private SYSTEM_POWER_STATUS systemPowerStatus; + + /// 当前的系统电源状态 + public PowerLineStatus PowerLineStatus + { + get + { + UpdateSystemPowerStatus(); + return (PowerLineStatus)systemPowerStatus.ACLineStatus; + } + } + + /// 当前的电池电量状态 + public BatteryChargeStatus BatteryChargeStatus + { + get + { + UpdateSystemPowerStatus(); + return (BatteryChargeStatus)systemPowerStatus.BatteryFlag; + } + } + + /// 报告的主电池电源的完全充电寿命(以秒为单位) + public Int32 BatteryFullLifetime + { + get + { + UpdateSystemPowerStatus(); + return systemPowerStatus.BatteryFullLifeTime; + } + } + + /// 电池剩余电量的近似量 + public Single BatteryLifePercent + { + get + { + UpdateSystemPowerStatus(); + var num = systemPowerStatus.BatteryLifePercent / 100f; + return num > 1f ? 1f : num; + } + } + + /// 电池的剩余使用时间的近似秒数 + public Int32 BatteryLifeRemaining + { + get + { + UpdateSystemPowerStatus(); + return systemPowerStatus.BatteryLifeTime; + } + } + + private void UpdateSystemPowerStatus() => GetSystemPowerStatus(ref systemPowerStatus); + + [DllImport("kernel32.dll", CharSet = CharSet.Auto, ExactSpelling = true)] + private static extern Boolean GetSystemPowerStatus([In][Out] ref SYSTEM_POWER_STATUS systemPowerStatus); + + private struct SYSTEM_POWER_STATUS + { + public Byte ACLineStatus; + + public Byte BatteryFlag; + + public Byte BatteryLifePercent; + + public Byte Reserved1; + + public Int32 BatteryLifeTime; + + public Int32 BatteryFullLifeTime; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Xml/SerializableDictionary.cs b/src/Admin/ThingsGateway.NewLife.X/Xml/SerializableDictionary.cs new file mode 100644 index 000000000..5eff3de35 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Xml/SerializableDictionary.cs @@ -0,0 +1,97 @@ +using System.Xml; +using System.Xml.Schema; +using System.Xml.Serialization; + +namespace ThingsGateway.NewLife.Xml +{ + /// 支持Xml序列化的泛型字典类 + /// + /// + [XmlRoot("Dictionary")] + [Serializable] + public class SerializableDictionary : Dictionary, IXmlSerializable + { + /// + public SerializableDictionary() : base() { } + + /// + /// + public SerializableDictionary(IDictionary dictionary) : base(dictionary) { } + + #region IXmlSerializable 成员 + XmlSchema IXmlSerializable.GetSchema() => null; + + /// 读取Xml + /// Xml读取器 + public void ReadXml(XmlReader reader) + { + if (reader.IsEmptyElement || !reader.Read()) return; + + var kfunc = CreateReader(); + var vfunc = CreateReader(); + while (reader.NodeType != XmlNodeType.EndElement) + { + reader.ReadStartElement("Item"); + + reader.ReadStartElement("Key"); + var key = kfunc(reader); + reader.ReadEndElement(); + + reader.ReadStartElement("Value"); + var value = vfunc(reader); + reader.ReadEndElement(); + + reader.ReadEndElement(); + + Add(key, value); + reader.MoveToContent(); + } + reader.ReadEndElement(); + } + + /// 写入Xml + /// Xml写入器 + public void WriteXml(XmlWriter writer) + { + var kfunc = CreateWriter(); + var vfunc = CreateWriter(); + foreach (var kv in this) + { + writer.WriteStartElement("Item"); + + writer.WriteStartElement("Key"); + kfunc(writer, kv.Key); + writer.WriteEndElement(); + + writer.WriteStartElement("Value"); + vfunc(writer, kv.Value); + writer.WriteEndElement(); + + writer.WriteEndElement(); + } + } + + static Func CreateReader() + { + var type = typeof(T); + if (type.CanXmlConvert()) return r => XmlHelper.XmlConvertFromString(r.ReadString()); + + // 因为一个委托将会被调用多次,因此把序列化对象声明在委托外面,让其生成匿名类,便于重用 + var xs = new XmlSerializer(type); + return r => (T)xs.Deserialize(r); + } + + static Action CreateWriter() + { + var type = typeof(T); + if (type.CanXmlConvert()) return (w, v) => w.WriteString(XmlHelper.XmlConvertToString(v)); + + // 因为一个委托将会被调用多次,因此把序列化对象声明在委托外面,让其生成匿名类,便于重用 + var xs = new XmlSerializer(type); + var xsns = new XmlSerializerNamespaces(); + xsns.Add("", ""); + return (w, v) => xs.Serialize(w, v, xsns); + } + #endregion + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Xml/XmlConfig.cs b/src/Admin/ThingsGateway.NewLife.X/Xml/XmlConfig.cs new file mode 100644 index 000000000..15533693a --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Xml/XmlConfig.cs @@ -0,0 +1,373 @@ +using System.Reflection; +using System.Runtime.Serialization; +using System.Text; +using System.Xml.Serialization; + +using ThingsGateway.NewLife.Log; +using ThingsGateway.NewLife.Threading; + +namespace ThingsGateway.NewLife.Xml; + +/// Xml配置文件基类 +/// +/// 标准用法:TConfig.Current +/// +/// 配置实体类通过特性指定配置文件路径以及自动更新时间。 +/// Current将加载配置文件,如果文件不存在或者加载失败,将实例化一个对象返回。 +/// +/// 考虑到自动刷新,不提供LoadFile和SaveFile等方法,可通过扩展方法ToXmlFileEntity和ToXmlFile实现。 +/// +/// 用户也可以通过配置实体类的静态构造函数修改基类的来动态配置加载信息。 +/// +/// +//[Obsolete("=>Config")] +public class XmlConfig : DisposeBase where TConfig : XmlConfig, new() +{ + #region 静态 + private static Boolean _loading; + private static TConfig? _Current; + /// 当前实例。通过置空可以使其重新加载。 + public static TConfig Current + { + get + { + if (_loading) return _Current ?? new TConfig(); + + var dcf = _.ConfigFile?.GetBasePath(); + if (dcf == null) return new TConfig(); + + // 这里要小心,避免_Current的null判断完成后,_Current被别人置空,而导致这里返回null + var config = _Current; + if (config != null) + { + // 现存有对象,尝试再次加载,可能因为未修改而返回null,这样只需要返回现存对象即可 + if (!config.IsUpdated) return config; + + XTrace.WriteLine("{0}的配置文件{1}有更新,重新加载配置!", typeof(TConfig), config.ConfigFile); + + // 异步更新 + ThreadPool.UnsafeQueueUserWorkItem(s => + { + try + { + config.Load(dcf); + } + catch { } + }, null); + + return config; + } + + // 现在没有对象,尝试加载,若返回null则实例化一个新的 + lock (dcf) + { + if (_Current != null) return _Current; + + config = new TConfig(); + _Current = config; + if (!config.Load(dcf)) + { + config.ConfigFile = dcf; + config.SetExpire(); // 设定过期时间 + config.IsNew = true; + config.OnNew(); + + config.OnLoaded(); + + // 创建或覆盖 + var act = File.Exists(dcf) ? "加载出错" : "不存在"; + XTrace.WriteLine("{0}的配置文件{1} {2},准备用默认配置覆盖!", typeof(TConfig).Name, dcf, act); + try + { + // 根据配置,有可能不保存,直接返回默认 + if (_.SaveNew) config.Save(); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + } + } + + return config; + } + set { _Current = value; } + } + + /// 一些设置。派生类可以在自己的静态构造函数中指定 + public static class _ + { + /// 是否调试 + public static Boolean Debug { get; set; } + + /// 配置文件路径 + public static String ConfigFile { get; set; } + + /// 重新加载时间。单位:毫秒 + public static Int32 ReloadTime { get; set; } + + /// 没有配置文件时是否保存新配置。默认true + public static Boolean SaveNew { get; set; } = true; + + static _() + { + // 获取XmlConfigFileAttribute特性,那里会指定配置文件名称 + var att = typeof(TConfig).GetCustomAttribute(true); + if (att == null || att.FileName.IsNullOrWhiteSpace()) + { + // 这里不能着急,派生类可能通过静态构造函数指定配置文件路径 + //throw new XException("编码错误!请为配置类{0}设置{1}特性,指定配置文件!", typeof(TConfig), typeof(XmlConfigFileAttribute).Name); + _.ConfigFile = $"Config\\{typeof(TConfig).Name}.config"; + _.ReloadTime = 10000; + } + else + { + _.ConfigFile = att.FileName; + _.ReloadTime = att.ReloadTime; + } + + // 实例化一次,用于触发派生类中可能的静态构造函数 + new TConfig(); + } + } + #endregion + + #region 属性 + /// 配置文件 + [XmlIgnore, IgnoreDataMember] + public String? ConfigFile { get; set; } + + /// 最后写入时间 + [XmlIgnore, IgnoreDataMember] + private DateTime lastWrite; + /// 过期时间。如果在这个时间之后再次访问,将检查文件修改时间 + [XmlIgnore, IgnoreDataMember] + private DateTime expire; + + /// 是否已更新。通过文件写入时间判断 + [XmlIgnore, IgnoreDataMember] + protected Boolean IsUpdated + { + get + { + var cf = ConfigFile; + //if (cf.IsNullOrEmpty() || !File.Exists(cf)) return false; + // 频繁调用File.Exists的性能损耗巨大 + if (cf.IsNullOrEmpty()) return false; + + var now = DateTime.Now; + if (_.ReloadTime > 0 && expire < now) + { + var fi = new FileInfo(cf); + fi.Refresh(); + expire = now.AddMilliseconds(_.ReloadTime); + + if (lastWrite < fi.LastWriteTime) + { + lastWrite = fi.LastWriteTime; + return true; + } + } + return false; + } + } + + /// 设置过期重新加载配置的时间 + void SetExpire() + { + if (_.ReloadTime > 0 && !ConfigFile.IsNullOrEmpty()) + { + // 这里必须在加载后即可设置过期时间和最后写入时间,否则下一次访问的时候,IsUpdated会报告文件已更新 + var fi = new FileInfo(ConfigFile); + if (fi.Exists) + { + fi.Refresh(); + lastWrite = fi.LastWriteTime; + } + else + lastWrite = DateTime.Now; + expire = DateTime.Now.AddMilliseconds(_.ReloadTime); + } + } + + /// 是否新的配置文件 + [XmlIgnore, IgnoreDataMember] + public Boolean IsNew { get; set; } + #endregion + + #region 构造 + /// 销毁 + /// + protected override void Dispose(Boolean disposing) + { + base.Dispose(disposing); + + _Timer.TryDispose(); + } + #endregion + + #region 加载 + /// 加载指定配置文件 + /// + /// + public virtual Boolean Load(String filename) + { + if (filename.IsNullOrWhiteSpace()) return false; + + filename = filename.GetBasePath(); + if (!File.Exists(filename)) return false; + + _loading = true; + try + { + var data = File.ReadAllBytes(filename); + if (this is not TConfig config) return false; + + Object? obj = config; + var xml = new Serialization.Xml + { + Stream = new MemoryStream(data), + UseAttribute = false, + UseComment = true + }; + + if (_.Debug) xml.Log = XTrace.Log; + + if (!xml.TryRead(GetType(), ref obj)) return false; + + config.ConfigFile = filename; + config.SetExpire(); // 设定过期时间 + config.OnLoaded(); + + return true; + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + return false; + } + finally + { + _loading = false; + } + } + #endregion + + #region 成员方法 + /// 从配置文件中读取完成后触发 + protected virtual void OnLoaded() + { + // 如果默认加载后的配置与保存的配置不一致,说明可能配置实体类已变更,需要强制覆盖 + var config = this; + try + { + var cfi = ConfigFile; + if (cfi.IsNullOrEmpty()) return; + + // 新建配置不要检查格式 + var flag = File.Exists(cfi); + if (!flag) return; + + var xml1 = File.ReadAllText(cfi).Trim(); + var xml2 = config.GetXml().Trim(); + flag = xml1 == xml2; + + if (!flag) + { + // 异步处理,避免加载日志路径配置时死循环 + XTrace.WriteLine("配置文件{0}格式不一致,保存为最新格式!", cfi); + config.Save(); + } + } + catch (Exception ex) + { + if (_.Debug) NewLife.Log.XTrace.WriteException(ex); + } + } + + /// 保存到配置文件中去 + /// + public virtual void Save(String? filename) + { + if (filename.IsNullOrWhiteSpace()) filename = ConfigFile; + if (filename.IsNullOrWhiteSpace()) throw new XException("No configuration file path specified for {0}!", typeof(TConfig).Name); + + filename = filename.GetBasePath(); + + // 加锁避免多线程保存同一个文件冲突 + lock (filename) + { + var xml1 = File.Exists(filename) ? File.ReadAllText(filename).Trim() : null; + var xml2 = GetXml(); + + //if (File.Exists(filename)) File.Delete(filename); + filename.EnsureDirectory(true); + OnSaving(filename, xml1, xml2); + } + } + /// + /// 在持久化配置文件时执行 + /// 如果重写该方法 请注意调用父类 以免造成配置文件不能正常持久化。 + /// + /// 配置文件全路径 + /// 老配置文件的内容 + /// 新配置文件的内容 + protected virtual void OnSaving(String filename, String? oldXml, String newXml) + { + if (oldXml != newXml) File.WriteAllText(filename, newXml); + } + + /// 保存到配置文件中去 + public virtual void Save() => Save(null); + + private TimerX? _Timer; + /// 异步保存 + public virtual void SaveAsync() + { + if (_Timer == null) + { + lock (this) + { + _Timer ??= new TimerX(DoSave, null, 1000, 5000) + { + Async = true, + //CanExecute = () => _commits > 0, + }; + } + } + + Interlocked.Increment(ref _commits); + } + + private Int32 _commits; + private void DoSave(Object? state) + { + var old = _commits; + //if (Interlocked.CompareExchange(ref _commits, 0, old) != old) return; + if (old == 0) return; + + Save(null); + + Interlocked.Add(ref _commits, -old); + } + + /// 新创建配置文件时执行 + protected virtual void OnNew() { } + + private String GetXml() + { + var xml = new Serialization.Xml + { + Encoding = Encoding.UTF8, + UseAttribute = false, + UseComment = true + }; + + if (_.Debug) xml.Log = XTrace.Log; + + xml.Write(this); + + return xml.GetString(); + } + #endregion +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Xml/XmlConfigFileAttribute.cs b/src/Admin/ThingsGateway.NewLife.X/Xml/XmlConfigFileAttribute.cs new file mode 100644 index 000000000..f4b8a6dd5 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Xml/XmlConfigFileAttribute.cs @@ -0,0 +1,25 @@ +namespace ThingsGateway.NewLife.Xml; + +/// Xml配置文件特性 +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public class XmlConfigFileAttribute : Attribute +{ + /// 配置文件名 + public String FileName { get; set; } + + /// 重新加载时间。单位:毫秒 + public Int32 ReloadTime { get; set; } + + /// 指定配置文件名 + /// + public XmlConfigFileAttribute(String fileName) => FileName = fileName; + + /// 指定配置文件名和重新加载时间(毫秒) + /// + /// + public XmlConfigFileAttribute(String fileName, Int32 reloadTime) + { + FileName = fileName; + ReloadTime = reloadTime; + } +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.NewLife.X/Xml/XmlHelper.cs b/src/Admin/ThingsGateway.NewLife.X/Xml/XmlHelper.cs new file mode 100644 index 000000000..8e6fa6ac4 --- /dev/null +++ b/src/Admin/ThingsGateway.NewLife.X/Xml/XmlHelper.cs @@ -0,0 +1,327 @@ +using System.Text; +using System.Xml; + +using ThingsGateway.NewLife.Reflection; + +namespace ThingsGateway.NewLife.Xml; + +/// Xml辅助类 +public static class XmlHelper +{ + #region 实体转Xml + /// 序列化为Xml字符串 + /// 要序列化为Xml的对象 + /// 编码 + /// 是否附加注释,附加成员的Description和DisplayName注释 + /// 是否使用特性输出 + /// Xml字符串 + public static String ToXml(this Object obj, Encoding? encoding = null, Boolean attachComment = false, Boolean useAttribute = false) + { + if (obj == null) return String.Empty; + + encoding ??= Encoding.UTF8; + + using var stream = new MemoryStream(); + ToXml(obj, stream, encoding, attachComment, useAttribute); + + return stream.ToArray().ToStr(); + } + + /// 序列化为Xml字符串 + /// 要序列化为Xml的对象 + /// 编码 + /// 是否附加注释,附加成员的Description和DisplayName注释 + /// 是否使用特性输出 + /// 忽略XML声明 + /// Xml字符串 + public static String ToXml(this Object obj, Encoding encoding, Boolean attachComment, Boolean useAttribute, Boolean omitXmlDeclaration) + { + if (obj == null) return String.Empty; + + using var stream = new MemoryStream(); + var xml = new Serialization.Xml + { + Stream = stream, + Encoding = encoding ?? Encoding.UTF8, + UseAttribute = useAttribute, + UseComment = attachComment, + Setting = new XmlWriterSettings + { + OmitXmlDeclaration = omitXmlDeclaration, + Indent = true + } + }; + xml.Write(obj); + + return stream.ToArray().ToStr(); + } + + /// 序列化为Xml数据流 + /// 要序列化为Xml的对象 + /// 目标数据流 + /// 编码 + /// 是否附加注释,附加成员的Description和DisplayName注释 + /// 是否使用特性输出 + public static void ToXml(this Object obj, Stream stream, Encoding? encoding = null, Boolean attachComment = false, Boolean useAttribute = false) + { + if (obj == null) return; + + var xml = new Serialization.Xml + { + Stream = stream, + Encoding = encoding ?? Encoding.UTF8, + UseAttribute = useAttribute, + UseComment = attachComment + }; + xml.Write(obj); + } + + /// 序列化为Xml文件 + /// 要序列化为Xml的对象 + /// 目标Xml文件 + /// 编码 + /// 是否附加注释,附加成员的Description和DisplayName注释 + /// Xml字符串 + public static void ToXmlFile(this Object obj, String file, Encoding? encoding = null, Boolean attachComment = true) + { + if (File.Exists(file)) File.Delete(file); + file.EnsureDirectory(true); + + // 如果是字符串字典,直接写入文件,其它设置无效 + if (obj is IDictionary dic) + { + var xml = dic.ToXml(); + File.WriteAllText(file, xml, encoding ?? Encoding.UTF8); + return; + } + + using var stream = new FileStream(file, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); + obj.ToXml(stream, encoding, attachComment); + // 必须通过设置文件流长度来实现截断,否则后面可能会多一截旧数据 + stream.SetLength(stream.Position); + stream.Flush(); + } + #endregion + + #region Xml转实体 + /// 字符串转为Xml实体对象 + /// 实体类型 + /// Xml字符串 + /// Xml实体对象 + public static TEntity? ToXmlEntity(this String xml) where TEntity : class + { + return xml.ToXmlEntity(typeof(TEntity)) as TEntity; + } + + /// 字符串转为Xml实体对象 + /// Xml字符串 + /// 实体类型 + /// Xml实体对象 + public static Object? ToXmlEntity(this String xml, Type type) + { + if (xml.IsNullOrWhiteSpace()) throw new ArgumentNullException(nameof(xml)); + if (type == null) throw new ArgumentNullException(nameof(type)); + + var x = new Serialization.Xml + { + Stream = new MemoryStream(xml.GetBytes()) + }; + + return x.Read(type); + + //if (!type.IsPublic) throw new XException("类型{0}不是public,不能进行Xml序列化!", type.FullName); + + //var serial = new XmlSerializer(type); + //using (var reader = new StringReader(xml)) + //using (var xr = new XmlTextReader(reader)) + //{ + // // 必须关闭Normalization,否则字符串的\r\n会变为\n + // //xr.Normalization = true; + // return serial.Deserialize(xr); + //} + } + + /// 数据流转为Xml实体对象 + /// 实体类型 + /// 数据流 + /// 编码 + /// Xml实体对象 + public static TEntity? ToXmlEntity(this Stream stream, Encoding? encoding = null) where TEntity : class + { + return stream.ToXmlEntity(typeof(TEntity), encoding) as TEntity; + } + + /// 数据流转为Xml实体对象 + /// 数据流 + /// 实体类型 + /// 编码 + /// Xml实体对象 + public static Object? ToXmlEntity(this Stream stream, Type type, Encoding? encoding = null) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + if (type == null) throw new ArgumentNullException(nameof(type)); + encoding ??= Encoding.UTF8; + + var x = new Serialization.Xml + { + Stream = stream, + Encoding = encoding + }; + + return x.Read(type); + + //if (!type.IsPublic) throw new XException("类型{0}不是public,不能进行Xml序列化!", type.FullName); + + //var serial = new XmlSerializer(type); + //using (var reader = new StreamReader(stream, encoding)) + //using (var xr = new XmlTextReader(reader)) + //{ + // // 必须关闭Normalization,否则字符串的\r\n会变为\n + // //xr.Normalization = true; + // return serial.Deserialize(xr); + //} + } + + /// Xml文件转为Xml实体对象 + /// 实体类型 + /// Xml文件 + /// 编码 + /// Xml实体对象 + public static TEntity? ToXmlFileEntity(this String file, Encoding? encoding = null) where TEntity : class + { + if (file.IsNullOrWhiteSpace()) throw new ArgumentNullException(nameof(file)); + if (!File.Exists(file)) return null; + + using var stream = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return stream.ToXmlEntity(encoding); + } + #endregion + + #region Xml类型转换 + ///// 删除字节序,硬编码支持utf-8、utf-32、Unicode三种 + ///// 原始编码 + ///// 删除字节序后的编码 + //internal static Encoding TrimPreamble(this Encoding encoding) + //{ + // if (encoding == null) return encoding; + + // var bts = encoding.GetPreamble(); + // if (bts == null || bts.Length <= 0) return encoding; + + // if (encoding is UTF8Encoding) return _utf8Encoding ?? (_utf8Encoding = new UTF8Encoding(false)); + // if (encoding is UTF32Encoding) return _utf32Encoding ?? (_utf32Encoding = new UTF32Encoding(false, false)); + // if (encoding is UnicodeEncoding) return _unicodeEncoding ?? (_unicodeEncoding = new UnicodeEncoding(false, false)); + + // return encoding; + //} + //private static Encoding _utf8Encoding; + //private static Encoding _utf32Encoding; + //private static Encoding _unicodeEncoding; + + internal static Boolean CanXmlConvert(this Type type) + { + if (type.IsBaseType()) return true; + + if (!type.IsValueType) return false; + + if (type == typeof(Guid) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan)) return true; + + return false; + } + + internal static String? XmlConvertToString(Object value) + { + if (value == null) return null; + + var type = value.GetType(); + var code = type.GetTypeCode(); + if (code == TypeCode.String) return value.ToString(); + if (code == TypeCode.DateTime) return XmlConvert.ToString((DateTime)value, XmlDateTimeSerializationMode.RoundtripKind); + + //var method = Reflect.GetMethodEx(typeof(XmlConvert), "ToString", type); + var method = typeof(XmlConvert).GetMethodEx("ToString", type); + if (method == null) throw new XException("Type {0} does not support converting to Xml strings. Please use the CanXmlConvert method first to determine!", type); + + return (String?)"".Invoke(method, value); + } + + internal static T? XmlConvertFromString(String xml) => (T?)XmlConvertFromString(typeof(T), xml); + + internal static Object? XmlConvertFromString(Type type, String xml) + { + if (xml == null) return null; + + var code = type.GetTypeCode(); + if (code == TypeCode.String) return xml; + if (code == TypeCode.DateTime) return XmlConvert.ToDateTime(xml, XmlDateTimeSerializationMode.RoundtripKind); + + //var method = Reflect.GetMethodEx(typeof(XmlConvert), "To" + type.Name, typeof(String)); + var method = typeof(XmlConvert).GetMethodEx("To" + type.Name, typeof(String)); + if (method == null) throw new XException("Type {0} does not support converting from Xml strings. Please use the CanXmlConvert method first!", type); + + return "".Invoke(method, xml); + } + #endregion + + #region Xml转字典 + /// 简单Xml转为字符串字典 + /// + /// + public static Dictionary? ToXmlDictionary(this String xml) + { + if (String.IsNullOrEmpty(xml)) return null; + + var doc = new XmlDocument(); + doc.LoadXml(xml); + var root = doc.DocumentElement; + if (root == null) return null; + + var dic = new Dictionary(); + + if (root.ChildNodes != null && root.ChildNodes.Count > 0) + { + foreach (var item in root.ChildNodes) + { + if (item is not XmlNode node) continue; + + if (node.ChildNodes != null && (node.ChildNodes.Count > 1 || + node.ChildNodes.Count == 1 && !(node.FirstChild is XmlText) && !(node.FirstChild is XmlCDataSection))) + { + dic[node.Name] = node.InnerXml; + } + else + { + dic[node.Name] = node.InnerText; + } + } + } + + return dic; + } + + /// 字符串字典转为Xml + /// + /// + /// + public static String ToXml(this IDictionary dic, String? rootName = null) + { + if (String.IsNullOrEmpty(rootName)) rootName = "xml"; + + var doc = new XmlDocument(); + var root = doc.CreateElement(rootName); + doc.AppendChild(root); + + if (dic != null && dic.Count > 0) + { + foreach (var item in dic) + { + var elm = doc.CreateElement(item.Key); + elm.InnerText = item.Value; + root.AppendChild(elm); + } + } + + return doc.OuterXml; + } + #endregion +} \ No newline at end of file diff --git a/src/ThingsGateway.Photino/Photino/PhotinoBlazorApp.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoBlazorApp.cs similarity index 100% rename from src/ThingsGateway.Photino/Photino/PhotinoBlazorApp.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoBlazorApp.cs diff --git a/src/ThingsGateway.Photino/Photino/PhotinoBlazorAppBuilder.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoBlazorAppBuilder.cs similarity index 99% rename from src/ThingsGateway.Photino/Photino/PhotinoBlazorAppBuilder.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoBlazorAppBuilder.cs index 040b1ad00..f12228531 100644 --- a/src/ThingsGateway.Photino/Photino/PhotinoBlazorAppBuilder.cs +++ b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoBlazorAppBuilder.cs @@ -37,6 +37,7 @@ namespace Photino.Blazor public RootComponentList RootComponents { get; set; } public IServiceCollection Services { get; set; } + public PhotinoBlazorApp Build(Action serviceProviderOptions = null) { // register root components with DI container diff --git a/src/ThingsGateway.Photino/Photino/PhotinoBlazorAppConfiguration.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoBlazorAppConfiguration.cs similarity index 100% rename from src/ThingsGateway.Photino/Photino/PhotinoBlazorAppConfiguration.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoBlazorAppConfiguration.cs diff --git a/src/ThingsGateway.Photino/Photino/PhotinoDispatcher.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoDispatcher.cs similarity index 100% rename from src/ThingsGateway.Photino/Photino/PhotinoDispatcher.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoDispatcher.cs diff --git a/src/ThingsGateway.Photino/Photino/PhotinoHttpHandler.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoHttpHandler.cs similarity index 100% rename from src/ThingsGateway.Photino/Photino/PhotinoHttpHandler.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoHttpHandler.cs diff --git a/src/ThingsGateway.Photino/Photino/PhotinoSyncrhronizationContext.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoSyncrhronizationContext.cs similarity index 100% rename from src/ThingsGateway.Photino/Photino/PhotinoSyncrhronizationContext.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoSyncrhronizationContext.cs diff --git a/src/ThingsGateway.Photino/Photino/PhotinoWebViewManager.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoWebViewManager.cs similarity index 100% rename from src/ThingsGateway.Photino/Photino/PhotinoWebViewManager.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoWebViewManager.cs diff --git a/src/ThingsGateway.Photino/Photino/PhotinoWindowRootComponents.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoWindowRootComponents.cs similarity index 92% rename from src/ThingsGateway.Photino/Photino/PhotinoWindowRootComponents.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoWindowRootComponents.cs index 590d8b62a..5f0808aa1 100644 --- a/src/ThingsGateway.Photino/Photino/PhotinoWindowRootComponents.cs +++ b/src/Admin/ThingsGateway.Photino.Blazor/Photino/PhotinoWindowRootComponents.cs @@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Components.Web; namespace Photino.Blazor { /// - /// Configures root components for a . + /// Configures root components /> /// public sealed class BlazorWindowRootComponents : IJSComponentConfiguration { @@ -25,7 +25,7 @@ namespace Photino.Blazor /// /// Adds a root component to the window. /// - /// The component type. + /// The component type. /// A CSS selector describing where the component should be added in the host page. /// An optional dictionary of parameters to pass to the component. public void Add(Type typeComponent, string selector, IDictionary parameters = null) diff --git a/src/ThingsGateway.Photino/Photino/ServiceCollectionExtensions.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/ServiceCollectionExtensions.cs similarity index 100% rename from src/ThingsGateway.Photino/Photino/ServiceCollectionExtensions.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/ServiceCollectionExtensions.cs diff --git a/src/ThingsGateway.Photino/Photino/Utils/SynchronousTaskScheduler.cs b/src/Admin/ThingsGateway.Photino.Blazor/Photino/Utils/SynchronousTaskScheduler.cs similarity index 100% rename from src/ThingsGateway.Photino/Photino/Utils/SynchronousTaskScheduler.cs rename to src/Admin/ThingsGateway.Photino.Blazor/Photino/Utils/SynchronousTaskScheduler.cs diff --git a/src/Admin/ThingsGateway.Photino.Blazor/ThingsGateway.Photino.Blazor.csproj b/src/Admin/ThingsGateway.Photino.Blazor/ThingsGateway.Photino.Blazor.csproj new file mode 100644 index 000000000..f9119ac49 --- /dev/null +++ b/src/Admin/ThingsGateway.Photino.Blazor/ThingsGateway.Photino.Blazor.csproj @@ -0,0 +1,22 @@ + + + + + + net9.0;net8.0; + + + + + + + + + + + + + + + + diff --git a/src/Admin/ThingsGateway.Razor/BaseLayout.razor b/src/Admin/ThingsGateway.Razor/BaseLayout.razor new file mode 100644 index 000000000..b6cb24a5a --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/BaseLayout.razor @@ -0,0 +1,11 @@ +@inherits LayoutComponentBase +@namespace ThingsGateway.Razor + +@* BB根组件 *@ + + @Body + + + + + diff --git a/src/Admin/ThingsGateway.Razor/BaseLayout.razor.cs b/src/Admin/ThingsGateway.Razor/BaseLayout.razor.cs new file mode 100644 index 000000000..d53c10864 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/BaseLayout.razor.cs @@ -0,0 +1,18 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +/// 母版页基类 +/// +public partial class BaseLayout +{ +} diff --git a/src/Admin/ThingsGateway.Razor/BaseLayout.razor.css b/src/Admin/ThingsGateway.Razor/BaseLayout.razor.css new file mode 100644 index 000000000..5f282702b --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/BaseLayout.razor.css @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/Common/ConcurrentList.cs b/src/Admin/ThingsGateway.Razor/Common/ConcurrentList.cs new file mode 100644 index 000000000..f35f92a35 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Common/ConcurrentList.cs @@ -0,0 +1,675 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Collections; + +namespace ThingsGateway.List; + +/// +/// 线程安全的List,其基本操作和List一致。 +/// +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class ConcurrentList : IList, IReadOnlyList +{ + private readonly List m_list; + + /// + /// 构造函数 + /// + /// + public ConcurrentList(IEnumerable collection) + { + m_list = new List(collection); + } + + /// + /// 构造函数 + /// + public ConcurrentList() + { + m_list = new List(); + } + + /// + /// 构造函数 + /// + /// + public ConcurrentList(int capacity) + { + m_list = new List(capacity); + } + + /// + /// 获取或设置容量 + /// + public int Capacity + { + get + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.Capacity; + } + } + set + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Capacity = value; + } + } + } + + /// + /// 元素数量 + /// + public int Count + { + get + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.Count; + } + } + } + + /// + /// 是否为只读 + /// + public bool IsReadOnly => false; + + /// + /// 获取索引元素 + /// + /// + /// + public T this[int index] + { + get + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list[index]; + } + } + set + { + lock (((ICollection)m_list).SyncRoot) + { + m_list[index] = value; + } + } + } + + /// + /// 添加元素 + /// + /// + public void Add(T item) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Add(item); + } + } + + /// + /// + /// + /// + public void AddRange(IEnumerable collection) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.AddRange(collection); + } + } + + /// + /// + /// + /// + /// + public int BinarySearch(T item) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.BinarySearch(item); + } + } + + /// + /// + /// + /// + /// + /// + public int BinarySearch(T item, IComparer comparer) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.BinarySearch(item, comparer); + } + } + + /// + /// + /// + /// + /// + /// + /// + /// + public int BinarySearch(int index, int count, T item, IComparer comparer) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.BinarySearch(index, count, item, comparer); + } + } + + /// + /// 清空所有元素 + /// + public void Clear() + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Clear(); + } + } + + /// + /// 是否包含某个元素 + /// + /// + /// + public bool Contains(T item) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.Contains(item); + } + } + + /// + /// + /// + /// + /// + /// + public List ConvertAll(Converter converter) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.ConvertAll(converter); + } + } + + /// + /// 复制到 + /// + /// + /// + public void CopyTo(T[] array, int arrayIndex) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.CopyTo(array, arrayIndex); + } + } + + /// + /// + /// + /// + /// + public T Find(Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.Find(match); + } + } + + /// + /// + /// + /// + /// + public List FindAll(Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.FindAll(match); + } + } + + /// + /// + /// + /// + /// + /// + /// + public int FindIndex(int startIndex, int count, Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.FindIndex(startIndex, count, match); + } + } + + /// + /// + /// + /// + /// + /// + public int FindIndex(int startIndex, Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.FindIndex(startIndex, match); + } + } + + /// + /// + /// + /// + /// + public int FindIndex(Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.FindIndex(match); + } + } + + /// + /// + /// + /// + /// + public T FindLast(Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.FindLast(match); + } + } + + /// + /// + /// + /// + /// + /// + /// + public int FindLastIndex(int startIndex, int count, Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.FindLastIndex(startIndex, count, match); + } + } + + /// + /// + /// + /// + /// + /// + public int FindLastIndex(int startIndex, Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.FindLastIndex(startIndex, match); + } + } + + /// + /// + /// + /// + /// + public int FindLastIndex(Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.FindLastIndex(match); + } + } + + /// + /// + /// + /// + public void ForEach(Action action) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.ForEach(action); + } + } + + /// + /// 返回迭代器 + /// + /// + public IEnumerator GetEnumerator() + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.ToList().GetEnumerator(); + } + } + + /// + /// 返回迭代器组合 + /// + /// + IEnumerator IEnumerable.GetEnumerator() + { + lock (((ICollection)m_list).SyncRoot) + { + return GetEnumerator(); + } + } + + /// + /// + /// + /// + /// + /// + public List GetRange(int index, int count) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.GetRange(index, count); + } + } + + /// + /// 索引 + /// + /// + /// + public int IndexOf(T item) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.IndexOf(item); + } + } + + /// + /// + /// + /// + /// + /// + public int IndexOf(T item, int index) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.IndexOf(item, index); + } + } + + /// + /// + /// + /// + /// + /// + /// + public int IndexOf(T item, int index, int count) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.IndexOf(item, index, count); + } + } + + /// + /// 插入 + /// + /// + /// + public void Insert(int index, T item) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Insert(index, item); + } + } + + /// + /// + /// + /// + /// + public void InsertRange(int index, IEnumerable collection) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.InsertRange(index, collection); + } + } + + /// + /// + /// + /// + /// + public int LastIndexOf(T item) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.IndexOf(item); + } + } + + /// + /// + /// + /// + /// + /// + public int LastIndexOf(T item, int index) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.LastIndexOf(item, index); + } + } + + /// + /// + /// + /// + /// + /// + /// + public int LastIndexOf(T item, int index, int count) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.LastIndexOf(item, index, count); + } + } + + /// + /// 移除元素 + /// + /// + /// + public bool Remove(T item) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.Remove(item); + } + } + + /// + /// + /// + /// + public void RemoveAll(Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.RemoveAll(match); + } + } + + /// + /// 按索引移除 + /// + /// + public void RemoveAt(int index) + { + lock (((ICollection)m_list).SyncRoot) + { + if (index < m_list.Count) + { + m_list.RemoveAt(index); + } + } + } + + /// + /// + /// + /// + /// + public void RemoveRange(int index, int count) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.RemoveRange(index, count); + } + } + + /// + /// + /// + public void Reverse() + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Reverse(); + } + } + + /// + /// + /// + /// + /// + public void Reverse(int index, int count) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Reverse(index, count); + } + } + + /// + /// + /// + public void Sort() + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Sort(); + } + } + + /// + /// + /// + /// + public void Sort(Comparison comparison) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Sort(comparison); + } + } + + /// + /// + /// + /// + public void Sort(IComparer comparer) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Sort(comparer); + } + } + + /// + /// + /// + /// + /// + /// + public void Sort(int index, int count, IComparer comparer) + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.Sort(index, count, comparer); + } + } + + /// + /// + /// + /// + public T[] ToArray() + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.ToArray(); + } + } + + /// + /// + /// + public void TrimExcess() + { + lock (((ICollection)m_list).SyncRoot) + { + m_list.TrimExcess(); + } + } + + /// + /// + /// + /// + /// + public bool TrueForAll(Predicate match) + { + lock (((ICollection)m_list).SyncRoot) + { + return m_list.TrueForAll(match); + } + } +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Common/EncodingMapper.cs b/src/Admin/ThingsGateway.Razor/Common/EncodingMapper.cs similarity index 96% rename from src/Gateway/ThingsGateway.Gateway.Application/Common/EncodingMapper.cs rename to src/Admin/ThingsGateway.Razor/Common/EncodingMapper.cs index a942cf92e..6ea004d71 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Common/EncodingMapper.cs +++ b/src/Admin/ThingsGateway.Razor/Common/EncodingMapper.cs @@ -12,7 +12,7 @@ using Mapster; using System.Text; -namespace ThingsGateway.Gateway.Application; +namespace ThingsGateway.Razor; /// /// Master规则 diff --git a/src/Admin/ThingsGateway.Razor/Common/IDriverUIBase.cs b/src/Admin/ThingsGateway.Razor/Common/IDriverUIBase.cs new file mode 100644 index 000000000..fa8ea8bd6 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Common/IDriverUIBase.cs @@ -0,0 +1,30 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +public interface IDriverUIBase +{ + public object Driver { get; set; } +} +public interface IPropertyUIBase +{ + public string Id { get; set; } + public bool CanWrite { get; set; } + public ModelValueValidateForm Model { get; set; } + public IEnumerable PluginPropertyEditorItems { get; set; } +} + +public interface IAddressUIBase +{ + public string Model { get; set; } + + public Action ModelChanged { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Common/ImportPreviewOutput.cs b/src/Admin/ThingsGateway.Razor/Common/ImportPreviewOutput.cs new file mode 100644 index 000000000..abe98a191 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Common/ImportPreviewOutput.cs @@ -0,0 +1,46 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.List; + +namespace ThingsGateway; + +/// +/// 文件导入通用输出 +/// +public class ImportPreviewOutputBase +{ + /// + /// 导入数据数量 + /// + public int DataCount { get => Results.Count; } + + /// + /// 是否有错误 + /// + public bool HasError { get; set; } + + /// + /// 返回状态 + /// + public ConcurrentList<(int Row, bool Success, string? ErrorMessage)> Results { get; set; } = new(); +} + +/// +/// 导入预览 +/// +/// +public class ImportPreviewOutput : ImportPreviewOutputBase where T : class +{ + /// + /// 数据 + /// + public Dictionary Data { get; set; } = new(); +} diff --git a/src/Admin/ThingsGateway.Razor/Common/ModelValueValidateForm.cs b/src/Admin/ThingsGateway.Razor/Common/ModelValueValidateForm.cs new file mode 100644 index 000000000..0ca7850c6 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Common/ModelValueValidateForm.cs @@ -0,0 +1,17 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +public class ModelValueValidateForm +{ + public object Value { get; set; } + public ValidateForm ValidateForm { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Common/RandomHelper.cs b/src/Admin/ThingsGateway.Razor/Common/RandomHelper.cs new file mode 100644 index 000000000..fb2dd3fba --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Common/RandomHelper.cs @@ -0,0 +1,169 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway; + +using System; +using System.Text; + +/// +/// 随机数 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class RandomHelper +{ + /// + /// 生成随机纯字母随机数 + /// + /// 生成长度 + /// + public static string CreateLetter(int Length) + { + + char[] Pattern = new char[] { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z' }; + string result = ""; + int n = Pattern.Length; + System.Random random = new Random(~unchecked((int)DateTime.Now.Ticks)); + for (int i = 0; i < Length; i++) + { + int rnd = random.Next(0, n); + result += Pattern[rnd]; + } + return result; + } + + + /// + /// 生成随机字母和数字随机数 + /// + /// 生成长度 + /// + public static string CreateLetterAndNumber(int Length) + { + + char[] Pattern = new char[] { 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; + string result = ""; + int n = Pattern.Length; + System.Random random = new Random(~unchecked((int)DateTime.Now.Ticks)); + for (int i = 0; i < Length; i++) + { + int rnd = random.Next(0, n); + result += Pattern[rnd]; + } + return result; + } + + /// + /// 生成随机小写字母和数字随机数 + /// + /// 生成长度 + /// + public static string CreateLetterAndNumberLower(int Length) + { + + char[] Pattern = new char[] { 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' }; + string result = ""; + int n = Pattern.Length; + System.Random random = new Random(~unchecked((int)DateTime.Now.Ticks)); + for (int i = 0; i < Length; i++) + { + int rnd = random.Next(0, n); + result += Pattern[rnd]; + } + return result; + } + + + + /// + /// 生成随机字符串 + /// + /// 字符串的长度 + /// + public static string CreateRandomString(int length) + { + // 创建一个StringBuilder对象存储密码 + StringBuilder sb = new StringBuilder(); + //使用for循环把单个字符填充进StringBuilder对象里面变成14位密码字符串 + for (int i = 0; i < length; i++) + { + Random random = new Random(Guid.NewGuid().GetHashCode()); + //随机选择里面其中的一种字符生成 + switch (random.Next(3)) + { + case 0: + //调用生成生成随机数字的方法 + sb.Append(CreateNum()); + break; + case 1: + //调用生成生成随机小写字母的方法 + sb.Append(CreateSmallAbc()); + break; + case 2: + //调用生成生成随机大写字母的方法 + sb.Append(CreateBigAbc()); + break; + } + } + return sb.ToString(); + } + + /// + /// 生成单个随机数字 + /// + public static int CreateNum() + { + Random random = new Random(Guid.NewGuid().GetHashCode()); + int num = random.Next(10); + return num; + } + + /// + /// 生成指定长度的随机数字字符串 + /// + /// + /// + public static string CreateNum(int length = 1) + { + Random random = new Random(Guid.NewGuid().GetHashCode()); + var result = ""; + for (int i = 0; i < length; i++) + { + result += random.Next(10); + } + return result; + } + + /// + /// 生成单个大写随机字母 + /// + public static string CreateBigAbc() + { + //A-Z的 ASCII值为65-90 + Random random = new Random(Guid.NewGuid().GetHashCode()); + int num = random.Next(65, 91); + string abc = Convert.ToChar(num).ToString(); + return abc; + } + + /// + /// 生成单个小写随机字母 + /// + public static string CreateSmallAbc() + { + //a-z的 ASCII值为97-122 + Random random = new Random(Guid.NewGuid().GetHashCode()); + int num = random.Next(97, 123); + string abc = Convert.ToChar(num).ToString(); + return abc; + } + +} + diff --git a/src/Admin/ThingsGateway.Razor/Components/About.razor b/src/Admin/ThingsGateway.Razor/Components/About.razor new file mode 100644 index 000000000..ff0455fa4 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/About.razor @@ -0,0 +1,23 @@ +@namespace ThingsGateway.Razor +@using ThingsGateway + +
+
@Localizer["Docs"]
+
+ + +
+
@Localizer["Community"]
+
+ @Localizer["QQGroup"] @WebsiteOption.Value.QQGroup1Number +
+
+ +
+ @WebsiteOption.Value.Copyright +
+ + + diff --git a/src/Admin/ThingsGateway.Razor/Components/About.razor.cs b/src/Admin/ThingsGateway.Razor/Components/About.razor.cs new file mode 100644 index 000000000..dd04513aa --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/About.razor.cs @@ -0,0 +1,23 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +public partial class About +{ + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + [Inject] + [NotNull] + private IOptions? WebsiteOption { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/Base/WebSiteModuleComponentBase.cs b/src/Admin/ThingsGateway.Razor/Components/Base/WebSiteModuleComponentBase.cs new file mode 100644 index 000000000..9c1d023b2 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/Base/WebSiteModuleComponentBase.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +public abstract class WebSiteModuleComponentBase : BootstrapModuleComponentBase +{ + /// + protected override void OnLoadJSModule() + { + base.OnLoadJSModule(); + ModulePath = $".{WebsiteConst.DefaultResourceUrl}{ModulePath}"; + } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/BlazorReconnector.razor b/src/Admin/ThingsGateway.Razor/Components/BlazorReconnector.razor new file mode 100644 index 000000000..562e00d68 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/BlazorReconnector.razor @@ -0,0 +1,79 @@ +@namespace ThingsGateway.Razor + + + +
+
+
+ @RenderThingsGateway +
+
@Reconnecting1
+

@Reconnecting2

+

@((MarkupString)Reconnecting3)

+
+ +
+
+
+ +
+
+
+ @RenderThingsGateway +
+
@ReconnectFailed1
+

@ReconnectFailed2

+

@((MarkupString)ReconnectFailed3)

+
+ +
+
+
+ +
+
+
+ @RenderThingsGateway +
+
@ReconnectRejected1
+

@ReconnectRejected2

+

@((MarkupString)ReconnectRejected3)

+
+ +
+
+
+
+ +@code { + + RenderFragment RenderThingsGateway => + @
+
@RenderThingsGateway1
+
+
+

+

@RenderThingsGateway2

+

+

@RenderThingsGateway3

+
+
+
+
; +} + + + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/Components/BlazorReconnector.razor.cs b/src/Admin/ThingsGateway.Razor/Components/BlazorReconnector.razor.cs new file mode 100644 index 000000000..d8c198974 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/BlazorReconnector.razor.cs @@ -0,0 +1,84 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +public partial class BlazorReconnector +{ + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + [NotNull] + private string? ReconnectFailed1 { get; set; } + + [NotNull] + private string? ReconnectFailed2 { get; set; } + + [NotNull] + private string? ReconnectFailed3 { get; set; } + + [NotNull] + private string? ReconnectFailed4 { get; set; } + + [NotNull] + private string? ReconnectFailed5 { get; set; } + + [NotNull] + private string? Reconnecting1 { get; set; } + + [NotNull] + private string? Reconnecting2 { get; set; } + + [NotNull] + private string? Reconnecting3 { get; set; } + + [NotNull] + private string? ReconnectRejected1 { get; set; } + + [NotNull] + private string? ReconnectRejected2 { get; set; } + + [NotNull] + private string? ReconnectRejected3 { get; set; } + + [NotNull] + private string? ReconnectRejected4 { get; set; } + + [NotNull] + private string? RenderThingsGateway1 { get; set; } + + [NotNull] + private string? RenderThingsGateway2 { get; set; } + + [NotNull] + private string? RenderThingsGateway3 { get; set; } + + /// + protected override void OnInitialized() + { + Reconnecting1 = Localizer[nameof(Reconnecting1)]; + Reconnecting2 = Localizer[nameof(Reconnecting2)]; + Reconnecting3 = Localizer[nameof(Reconnecting3)]; + ReconnectFailed1 = Localizer[nameof(ReconnectFailed1)]; + ReconnectFailed2 = Localizer[nameof(ReconnectFailed2)]; + ReconnectFailed3 = Localizer[nameof(ReconnectFailed3)]; + ReconnectFailed4 = Localizer[nameof(ReconnectFailed4)]; + ReconnectFailed5 = Localizer[nameof(ReconnectFailed5)]; + ReconnectRejected1 = Localizer[nameof(ReconnectRejected1)]; + ReconnectRejected2 = Localizer[nameof(ReconnectRejected2)]; + ReconnectRejected3 = Localizer[nameof(ReconnectRejected3)]; + ReconnectRejected4 = Localizer[nameof(ReconnectRejected4)]; + RenderThingsGateway1 = Localizer[nameof(RenderThingsGateway1)]; + RenderThingsGateway2 = Localizer[nameof(RenderThingsGateway2)]; + RenderThingsGateway3 = Localizer[nameof(RenderThingsGateway3)]; + } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/CultureChooser.razor b/src/Admin/ThingsGateway.Razor/Components/CultureChooser.razor new file mode 100644 index 000000000..cd4b9a098 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/CultureChooser.razor @@ -0,0 +1,12 @@ +@inherits BootstrapComponentBase +@namespace ThingsGateway.Razor +
+ +
diff --git a/src/Admin/ThingsGateway.Razor/Components/CultureChooser.razor.cs b/src/Admin/ThingsGateway.Razor/Components/CultureChooser.razor.cs new file mode 100644 index 000000000..db852f6d4 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/CultureChooser.razor.cs @@ -0,0 +1,106 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.NewLife; + +namespace ThingsGateway.Razor; + +/// +public partial class CultureChooser +{ + private bool _firstRender; + + [Inject] + [NotNull] + private IOptionsMonitor? BootstrapOptions { get; set; } + + [Inject] + [NotNull] + private NavigationManager? NavigationManager { get; set; } + + private string SelectedCulture { get; set; } = CultureInfo.CurrentUICulture.Name; + + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _firstRender = true; + if (OperatingSystem.IsBrowser() || !Runtime.IsWeb) + { + var cultureName = await JSRuntime.GetCulture(); + if (SelectedCulture != cultureName) + { + SelectedCulture = cultureName ?? "zh-CN"; + await InvokeAsync(StateHasChanged); + } + else + { + SelectedCulture = cultureName ?? "zh-CN"; + } + } + } + await base.OnAfterRenderAsync(firstRender); + } + + private static string GetDisplayName(CultureInfo culture) + { + string? ret; + if (OperatingSystem.IsBrowser()) + { + ret = culture.Name switch + { + "zh-CN" => "中文(中国)", + "en-US" => "English (United States)", + _ => "" + }; + } + else + { + ret = culture.DisplayName; + } + return ret; + } + + private async Task SetCulture(SelectedItem item) + { + if (_firstRender) + { + if (OperatingSystem.IsBrowser() || !Runtime.IsWeb) + { + var cultureName = item.Value; + if (cultureName != CultureInfo.CurrentCulture.Name) + { + await JSRuntime.SetCulture(cultureName); + var culture = new CultureInfo(cultureName); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + + NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); + } + } + else + { + // 使用 api 方式 适用于 Server-Side 模式 + if (SelectedCulture != item.Value) + { + var culture = item.Value; + var uri = new Uri(NavigationManager.Uri).GetComponents(UriComponents.PathAndQuery, UriFormat.Unescaped); + var query = $"?culture={Uri.EscapeDataString(culture)}&redirectUri={Uri.EscapeDataString(uri)}"; + + // use a path that matches your culture redirect controller from the previous steps + NavigationManager.NavigateTo("/Culture/SetCulture" + query, forceLoad: true); + } + } + } + } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/DefaultTable.razor b/src/Admin/ThingsGateway.Razor/Components/DefaultTable.razor new file mode 100644 index 000000000..4ccb8b76f --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/DefaultTable.razor @@ -0,0 +1,36 @@ +@namespace ThingsGateway.Razor +@typeparam TItem + + +
diff --git a/src/Admin/ThingsGateway.Razor/Components/DefaultTable.razor.cs b/src/Admin/ThingsGateway.Razor/Components/DefaultTable.razor.cs new file mode 100644 index 000000000..1a2c64eab --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/DefaultTable.razor.cs @@ -0,0 +1,365 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +[CascadingTypeParameter(nameof(TItem))] +public partial class DefaultTable where TItem : class, new() +{ + /// + [Parameter] + public bool AllowDragColumn { get; set; } = false; + + /// + [Parameter] + public bool AllowResizing { get; set; } = false; + + /// + [Parameter] + public bool AutoGenerateColumns { get; set; } + + /// + [Parameter] + public int AutoRefreshInterval { get; set; } + + /// + [Parameter] + public RenderFragment? BeforeRowButtonTemplate { get; set; } + + /// + [Parameter] + public bool ClickToSelect { get; set; } + + /// + /// + /// + [Parameter] + public string? ClientTableName { get; set; } + + /// + [Parameter] + public ITableSearchModel? CustomerSearchModel { get; set; } + + /// + [Parameter] + public RenderFragment? CustomerSearchTemplate { get; set; } + + /// + [Parameter] + public bool DisableExtendDeleteButton { get; set; } + + /// + [Parameter] + public Func? DisableExtendDeleteButtonCallback { get; set; } + + /// + [Parameter] + public bool DisableExtendEditButton { get; set; } + + /// + [Parameter] + public Func? DisableExtendEditButtonCallback { get; set; } + + /// + [Parameter] + public RenderFragment EditFooterTemplate { get; set; } + + /// + [Parameter] + public bool ScrollingDialogContent { get; set; } + + /// + [Parameter] + public RenderFragment? EditTemplate { get; set; } + + /// + [Parameter] + public RenderFragment> ExportButtonDropdownTemplate { get; set; } + + /// + + /// + [Parameter] + public int ExtendButtonColumnWidth { get; set; } = 130; + + /// + [Parameter] + public int? Height { get; set; } = null; + + /// + [Parameter] + public bool IsAutoQueryFirstRender { get; set; } = true; + + /// + [Parameter] + public bool IsAutoRefresh { get; set; } = false; + + /// + [Parameter] + public bool IsFixedHeader { get; set; } = true; + + /// + [Parameter] + public bool IsMultipleSelect { get; set; } = true; + + /// + [Parameter] + public bool IsPagination { get; set; } + + /// + [Parameter] + public bool IsTree { get; set; } + + /// + [Parameter] + public IEnumerable? Items { get; set; } + + /// + [Parameter] + public Func? ModelEqualityComparer { get; set; } + + /// + [Parameter] + public Func, Task>? OnAfterDeleteAsync { get; set; } + + /// + [Parameter] + public Func? OnAfterModifyAsync { get; set; } + + /// + [Parameter] + public Func? OnAfterSaveAsync { get; set; } + + /// + [Parameter] + public Func, Task>? OnDeleteAsync { get; set; } + + /// + [Parameter] + public Func>>? OnQueryAsync { get; set; } + + /// + [Parameter] + public Func>? OnSaveAsync { get; set; } + + /// + [Parameter] + public Func>>>? OnTreeExpand { get; set; } + + /// + [Parameter] + public IEnumerable? PageItemsSource { get; set; } = new int[] + { + 20, + 50, + 100, + 200 + }; + + /// + [Parameter] + public RenderFragment? RowButtonTemplate { get; set; } + + /// + [Parameter] + public float RowHeight { get; set; } = 38; + + /// + [Parameter] + public ScrollMode ScrollMode { get; set; } + + /// + [Parameter] + public SearchMode SearchMode { get; set; } + + /// + [Parameter] + public TItem SearchModel { get; set; } + + /// + [Parameter] + public RenderFragment? SearchTemplate { get; set; } + + /// + [Parameter] + public List? SelectedRows { get; set; } = new List(); + + /// + [Parameter] + public EventCallback> SelectedRowsChanged { get; set; } + + /// + [Parameter] + public Func? SetRowClassFormatter { get; set; } + + /// + [Parameter] + public bool ShowAddButton { get; set; } = true; + + /// + [Parameter] + public bool ShowAdvancedSearch { get; set; } = true; + + /// + [Parameter] + public bool ShowCardView { get; set; } = true; + + /// + [Parameter] + public bool ShowColumnList { get; set; } = true; + + /// + [Parameter] + public bool ShowDefaultButtons { get; set; } = true; + + /// + [Parameter] + public bool ShowDeleteButton { get; set; } = true; + + /// + [Parameter] + public Func? ShowDeleteButtonCallback { get; set; } + + /// + [Parameter] + public bool ShowEditButton { get; set; } = true; + + + /// + [Parameter] + public bool ShowEmpty { get; set; } = true; + + /// + [Parameter] + public bool ShowExportButton { get; set; } = false; + + /// + [Parameter] + public bool ShowExportCsvButton { get; set; } = false; + + /// + [Parameter] + public bool ShowExportPdfButton { get; set; } + + /// + [Parameter] + public bool ShowExtendButtons { get; set; } = false; + + /// + [Parameter] + public bool ShowExtendDeleteButton { get; set; } = true; + + /// + [Parameter] + public Func? ShowExtendDeleteButtonCallback { get; set; } + + /// + [Parameter] + public bool ShowExtendEditButton { get; set; } = true; + + /// + [Parameter] + public Func? ShowExtendEditButtonCallback { get; set; } + + /// + [Parameter] + public bool ShowFilterHeader { get; set; } = false; + + /// + [Parameter] + public bool ShowLoading { get; set; } = false; + + /// + [Parameter] + public bool ShowMultiFilterHeader { get; set; } = false; + + /// + [Parameter] + public bool ShowRefresh { get; set; } = true; + + /// + [Parameter] + public bool ShowResetButton { get; set; } = true; + + /// + [Parameter] + public bool ShowSearch { get; set; } = true; + + /// + [Parameter] + public bool ShowSearchText { get; set; } = false; + + /// + [Parameter] + public bool ShowToolbar { get; set; } = true; + + /// + [Parameter] + public string? SortString { get; set; } + + /// + [NotNull] + [Parameter] + public RenderFragment? TableColumns { get; set; } + + /// + [Parameter] + public TableSize TableSize { get; set; } = TableSize.Normal; + + /// + [NotNull] + [Parameter] + public RenderFragment? TableToolbarBeforeTemplate { get; set; } + + /// + [NotNull] + [Parameter] + public RenderFragment? TableToolbarTemplate { get; set; } + + /// + [Parameter] + public RenderFragment? TableExtensionToolbarBeforeTemplate { get; set; } + + /// + [Parameter] + public RenderFragment? TableExtensionToolbarTemplate { get; set; } + + /// + [Parameter] + public Func, Task>>>? TreeNodeConverter { get; set; } + + /// + [Parameter] + public Size EditDialogSize { get; set; } = Size.ExtraExtraLarge; + + + [NotNull] + private Table? Instance { get; set; } + + [Inject] + [NotNull] + private NavigationManager? NavigationManager { get; set; } + + public Task OnAddAsync() + { + return Task.FromResult(new TItem()); + } + + /// + public Task QueryAsync(int? pageIndex) => Instance.QueryAsync(pageIndex); + /// + public Task QueryAsync() => Instance.QueryAsync(); + + /// + public ValueTask ToggleLoading(bool v) => Instance.ToggleLoading(v); + + + +} diff --git a/src/Admin/ThingsGateway.Razor/Components/EditComponent.razor b/src/Admin/ThingsGateway.Razor/Components/EditComponent.razor new file mode 100644 index 000000000..2b01e5ec7 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/EditComponent.razor @@ -0,0 +1,15 @@ +@namespace ThingsGateway.Razor +@using ThingsGateway.Razor + +@typeparam T where T : class, new() +
+ + + + + +
diff --git a/src/Admin/ThingsGateway.Razor/Components/EditComponent.razor.cs b/src/Admin/ThingsGateway.Razor/Components/EditComponent.razor.cs new file mode 100644 index 000000000..6bf58700c --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/EditComponent.razor.cs @@ -0,0 +1,35 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; + +namespace ThingsGateway.Razor; + +/// +public partial class EditComponent +{ + /// + [Parameter] + public int? ItemsPerRow { get; set; } + + /// + [Parameter] + [EditorRequired] + public T Model { get; set; } + + /// + [Parameter] + [EditorRequired] + public Func OnSave { [return: NotNull] get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? DefaultLocalizer { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/EditComponent.razor.css b/src/Admin/ThingsGateway.Razor/Components/EditComponent.razor.css new file mode 100644 index 000000000..64423b5ba --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/EditComponent.razor.css @@ -0,0 +1,3 @@ +.editpage ::deep .form-footer { + text-align: left; +} diff --git a/src/Admin/ThingsGateway.Razor/Components/EditorForm/EditorFormObject.razor b/src/Admin/ThingsGateway.Razor/Components/EditorForm/EditorFormObject.razor new file mode 100644 index 000000000..7f3807e51 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/EditorForm/EditorFormObject.razor @@ -0,0 +1,84 @@ +@namespace BootstrapBlazor.Components +@typeparam TModel +@inherits BootstrapComponentBase + +
+ + @FieldItems?.Invoke(Model) + + + + @if (ShowUnsetGroupItemsOnTop) + { + if (UnsetGroupItems.Any()) + { + @RenderUnsetGroupItems + } + @foreach (var g in GroupItems) + { + @RenderGroupItems(g) + } + } + else + { + @foreach (var g in GroupItems) + { + @RenderGroupItems(g) + } + if (UnsetGroupItems.Any()) + { + @RenderUnsetGroupItems + } + } + + + + @if (Buttons != null) + { + + } + +
+ +@code +{ + RenderFragment RenderUnsetGroupItems => + @
+ @foreach (var item in UnsetGroupItems) + { + var render = GetRenderTemplate(item); + @if (render != null) + { + @render(Model) + } + else + { +
+ @AutoGenerateTemplate(item) +
+ } + } +
; + + RenderFragment>> RenderGroupItems => g => + @ +
+ @foreach (var item in g.Value) + { + var render = GetRenderTemplate(item); + @if (render != null) + { + @render(Model) + } + else + { +
+ @AutoGenerateTemplate(item) +
+ } + } +
+
; +} diff --git a/src/Admin/ThingsGateway.Razor/Components/EditorForm/EditorFormObject.razor.cs b/src/Admin/ThingsGateway.Razor/Components/EditorForm/EditorFormObject.razor.cs new file mode 100644 index 000000000..533cbb8ba --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/EditorForm/EditorFormObject.razor.cs @@ -0,0 +1,300 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License +// See the LICENSE file in the project root for more information. +// Maintainer: Argo Zhang(argo@live.ca) Website: https://www.blazor.zone + +using Microsoft.AspNetCore.Components.Forms; + +using ThingsGateway.Razor; + +namespace BootstrapBlazor.Components; + +/// +/// 编辑表单基类 +/// +[CascadingTypeParameter(nameof(TModel))] +public partial class EditorFormObject : IShowLabel +{ + private string? ClassString => CssBuilder.Default("bb-editor form-body") + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + /// + /// 支持每行多少个控件功能 + /// + /// + /// + private string? GetCssString(IEditorItem item) + { + int cols = 0; + double mdCols = 6; + if (item is AutoGenerateColumnAttribute a && a.Cols > 0 && a.Cols < 13) + { + cols = a.Cols; + } + if (ItemsPerRow.HasValue) + { + mdCols = Math.Min(12, Math.Ceiling(12d / ItemsPerRow.Value)); + } + return CssBuilder.Default("col-12") + .AddClass($"col-sm-{cols}", cols > 0) // 指定 Cols + .AddClass($"col-sm-6 col-md-{mdCols}", mdCols < 12 && cols == 0 && item.Items == null && item.Rows == 0) // 指定 ItemsPerRow + .Build(); + } + + private string? FormClassString => CssBuilder.Default("row g-3") + .AddClass("form-inline", RowType == RowType.Inline) + .AddClass("form-inline-end", RowType == RowType.Inline && LabelAlign == Alignment.Right) + .AddClass("form-inline-center", RowType == RowType.Inline && LabelAlign == Alignment.Center) + .Build(); + + private string? FormStyleString => CssBuilder.Default() + .AddClass($"--bb-row-label-width: {LabelWidth}px;", LabelWidth.HasValue) + .Build(); + + /// + /// 获得/设置 每行显示组件数量 默认为 null + /// + [Parameter] + public int? ItemsPerRow { get; set; } + + /// + /// 获得/设置 实体类编辑模式 Add 还是 Update + /// + [Parameter] + public ItemChangedType ItemChangedType { get; set; } + + /// + /// 获得/设置 设置行格式 默认 Row 布局 + /// + [Parameter] + public RowType RowType { get; set; } + + /// + /// 获得/设置 设置 Inline 模式下标签对齐方式 默认 None 等效于 Left 左对齐 + /// + [Parameter] + public Alignment LabelAlign { get; set; } + + /// + /// 获得/设置 标签宽度 默认 null 未设置使用全局设置 --bb-row-label-width 值 + /// + [Parameter] + public int? LabelWidth { get; set; } + + /// + /// 获得/设置 列模板 设置 时本参数不生效 + /// + [Parameter] + public RenderFragment? FieldItems { get; set; } + + /// + /// 获得/设置 按钮模板 + /// + [Parameter] + public RenderFragment? Buttons { get; set; } + + /// + /// 获得/设置 绑定模型 + /// + [Parameter] + [NotNull] + public TModel? Model { get; set; } + + /// + /// 获得/设置 是否显示前置标签 默认为 null 未设置时默认显示标签 + /// + [Parameter] + public bool? ShowLabel { get; set; } + + /// + /// 获得/设置 是否显示标签 Tooltip 多用于标签文字过长导致裁减时使用 默认 null + /// + [Parameter] + public bool? ShowLabelTooltip { get; set; } + + /// + /// 获得/设置 是否显示为 Display 组件 默认为 false + /// + [Parameter] + public bool IsDisplay { get; set; } + + /// + /// 获得/设置 是否使用 SearchTemplate 默认 false 使用 EditTemplate 模板 + /// + /// 多用于表格组件传递 集合给参数 + [CascadingParameter(Name = "IsSearch")] + [NotNull] + private bool? IsSearch { get; set; } + + /// + /// 获得/设置 是否自动生成模型的所有属性 默认为 true 生成所有属性 + /// + [Parameter] + public bool AutoGenerateAllItem { get; set; } = true; + + /// + /// 获得/设置 级联上下文绑定字段信息集合 设置此参数后 模板不生效 + /// + [Parameter] + public IEnumerable? Items { get; set; } + + /// + /// 获得/设置 自定义列排序规则 默认 null 未设置 使用内部排序机制 1 2 3 0 -3 -2 -1 顺序 + /// + [Parameter] + public Func, IEnumerable>? ColumnOrderCallback { get; set; } + + /// + /// 获得/设置 未设置 GroupName 编辑项是否放置在顶部 默认 false + /// + [Parameter] + public bool ShowUnsetGroupItemsOnTop { get; set; } + + /// + /// 获得/设置 默认占位符文本 默认 null + /// + [Parameter] + [NotNull] + public string? PlaceHolderText { get; set; } + + /// + /// 获得/设置 级联上下文 EditContext 实例 内置于 EditForm 或者 ValidateForm 时有值 + /// + [CascadingParameter] + private EditContext? CascadedEditContext { get; set; } + + /// + /// 获得 ValidateForm 实例 + /// + [CascadingParameter] + private ValidateForm? ValidateForm { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer>? Localizer { get; set; } + + [Inject] + [NotNull] + private ILookupService? LookupService { get; set; } + + /// + /// 获得/设置 配置编辑项目集合 + /// + private readonly List _editorItems = []; + + private IEnumerable UnsetGroupItems => RenderItems.Where(i => string.IsNullOrEmpty(i.GroupName) && i.IsVisible(ItemChangedType, IsSearch.Value)); + + private IEnumerable>> GroupItems => RenderItems + .Where(i => !string.IsNullOrEmpty(i.GroupName) && i.IsVisible(ItemChangedType, IsSearch.Value)) + .GroupBy(i => i.GroupName).OrderBy(i => i.First().Order) + .Select(i => new KeyValuePair>(i.Key!, i.OrderBy(x => x.Order))); + + private List? _itemsCache; + + private List RenderItems + { + get + { + _itemsCache ??= GetRenderItems(); + return _itemsCache; + } + } + + /// + /// OnInitialized 方法 + /// + protected override void OnInitialized() + { + base.OnInitialized(); + + if (CascadedEditContext != null && IsSearch is not true) + { + var message = Localizer["ModelInvalidOperationExceptionMessage", nameof(EditorForm)]; + if (!CascadedEditContext.Model.GetType().IsAssignableTo(typeof(TModel))) + { + throw new InvalidCastException(message); + } + + Model = (TModel)CascadedEditContext.Model; + } + + // 统一设置所有 IEditorItem 的 PlaceHolder + PlaceHolderText ??= Localizer[nameof(PlaceHolderText)]; + IsSearch ??= false; + } + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + // 为空时使用级联参数 ValidateForm 的 ShowLabel + ShowLabel ??= ValidateForm?.ShowLabel; + _itemsCache = null; + } + + private List GetRenderItems() + { + var items = new List(); + if (Items != null) + { + items.AddRange(Items.Where(i => !i.GetIgnore() && !string.IsNullOrEmpty(i.GetFieldName()))); + } + //else //需删除,否则会导致自定义模板无法显示 + { + // 如果 EditorItems 有值表示 用户自定义列 + if (AutoGenerateAllItem) + { + // 获取绑定模型所有属性 + var columns = Utility.GetTableColumns(Model.GetType(), defaultOrderCallback: ColumnOrderCallback).ToList(); + + // 通过设定的 FieldItems 模板获取项进行渲染 + foreach (var el in _editorItems) + { + var item = columns.FirstOrDefault(i => i.GetFieldName() == el.GetFieldName()); + if (item != null) + { + // 过滤掉不编辑与不可见的列 + if (el.GetIgnore() || !el.IsVisible(ItemChangedType, IsSearch.Value) || string.IsNullOrEmpty(el.GetFieldName())) + { + columns.Remove(item); + } + else + { + // 设置只读属性与列模板 + item.CopyValue(el); + } + } + } + items.AddRange(columns); + } + else + { + items.AddRange(_editorItems.Where(i => !i.GetIgnore() + && !string.IsNullOrEmpty(i.GetFieldName()) + && i.IsVisible(ItemChangedType, IsSearch.Value))); + } + } + return items; + } + + private RenderFragment AutoGenerateTemplate(IEditorItem item) => builder => + { + if (IsDisplay || !item.CanWrite(Model.GetType(), ItemChangedType, IsSearch.Value)) + { + builder.CreateDisplayByFieldType(item, Model); + } + else + { + item.PlaceHolder ??= PlaceHolderText; + builder.CreateComponentByFieldType(this, item, Model, ItemChangedType, IsSearch.Value, item.GetLookupService(LookupService)); + } + }; + + private RenderFragment? GetRenderTemplate(IEditorItem item) => IsSearch.Value && item is ITableColumn col + ? col.SearchTemplate + : item.EditTemplate; +} diff --git a/src/Admin/ThingsGateway.Razor/Components/EditorForm/EditorFormObject.razor.css b/src/Admin/ThingsGateway.Razor/Components/EditorForm/EditorFormObject.razor.css new file mode 100644 index 000000000..bd43a235e --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/EditorForm/EditorFormObject.razor.css @@ -0,0 +1,16 @@ +.bb-editor { + position: relative; +} + + .bb-editor .ef-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--bs-body-bg); + } + +.bb-editor-footer { + margin-top: 1rem; +} diff --git a/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor b/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor new file mode 100644 index 000000000..9e1300979 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor @@ -0,0 +1,3636 @@ +@inherits WebSiteModuleComponentBase +@namespace ThingsGateway.Razor +
+
+
+
+

Sponsored (392)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Staff Favorites (38)

+ +
+
+

Accessibility (21)

+ +
+
+

Alert (9)

+ +
+
+

Alphabet (28)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Animals (25)

+ +
+
+

Arrows (119)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Astronomy (7)

+ +
+
+

Automotive (28)

+ +
+
+

Buildings (74)

+ +
+
+

Business (96)

+ +
+
+

Camping (37)

+ +
+
+

Charity (20)

+ +
+
+

Charts + Diagrams (14)

+ +
+
+

Childhood (25)

+ +
+
+

Clothing + Fashion (12)

+ +
+
+

Coding (42)

+ +
+
+

Communication (51)

+ +
+
+

Connectivity (12)

+ +
+
+

Construction (30)

+ +
+
+

Design (53)

+ +
+
+

Devices + Hardware (43)

+ +
+
+

Disaster + Crisis (37)

+ +
+
+

Editing (46)

+ +
+
+

Education (24)

+ +
+
+

Emoji (36)

+ +
+
+

Energy (37)

+ +
+
+

Files (35)

+ +
+
+

Film + Video (22)

+ +
+
+

Food + Beverage (48)

+ +
+
+

Fruits + Vegetables (6)

+ +
+
+

Gaming (35)

+ +
+
+

Genders (14)

+ +
+
+

Halloween (13)

+ +
+
+

Hands (35)

+ +
+
+

Holidays (14)

+ +
+
+

Household (48)

+ +
+
+

Humanitarian (333)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Logistics (56)

+ +
+
+

Maps (136)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Maritime (14)

+ +
+
+

Marketing (24)

+ +
+
+

Mathematics (23)

+ +
+
+

Media Playback (37)

+ +
+
+

Medical + Health (109)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Money (67)

+ +
+
+

Moving (16)

+ +
+
+

Music + Audio (19)

+ +
+
+

Nature (28)

+ +
+
+

Numbers (10)

+
+ + + + + + + + + + +
+
+
+

Photos + Images (23)

+ +
+
+

Political (20)

+ +
+
+

Punctuation + Symbols (18)

+ +
+
+

Religion (32)

+ +
+
+

Science (33)

+ +
+
+

Science Fiction (8)

+ +
+
+

Security (61)

+ +
+
+

Shapes (23)

+ +
+
+

Shopping (38)

+ +
+
+

Social (31)

+ +
+
+

Spinners (23)

+ +
+
+

Sports + Fitness (33)

+ +
+
+

Text Formatting (40)

+ +
+
+

Time (17)

+ +
+
+

Toggle (15)

+ +
+
+

Transportation (49)

+ +
+
+

Travel + Hotel (73)

+ +
+
+

Users + People (120)

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+

Weather (37)

+ +
+
+

Writing (27)

+ +
+
+
+ @if (ShowCatalog) + { + + } +
diff --git a/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor.cs b/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor.cs new file mode 100644 index 000000000..0767adbf0 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor.cs @@ -0,0 +1,115 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using Microsoft.JSInterop; + +namespace ThingsGateway.Razor; + +/// +/// FAIconList 组件 +/// +[JSModuleAutoLoader("Components/FAIconList.razor.js", JSObjectReference = true)] +public partial class FAIconList : IAsyncDisposable +{ + private string? ClassString => CssBuilder.Default("icon-list") + .AddClass("is-catalog", ShowCatalog) + .AddClass("is-dialog", ShowCopyDialog) + .AddClassFromAttributes(AdditionalAttributes) + .Build(); + + /// + /// 获得/设置 点击时是否显示高级拷贝弹窗 默认 false 直接拷贝到粘贴板 + /// + [Parameter] + public bool ShowCopyDialog { get; set; } + + /// + /// 获得/设置 是否显示目录 默认 false + /// + [Parameter] + public bool ShowCatalog { get; set; } + + /// + /// 获得/设置 高级弹窗 Header 显示文字 + /// + [Parameter] + [NotNull] + public string? DialogHeaderText { get; set; } + + /// + /// 获得/设置 当前选择图标 + /// + [Parameter] + public string? Icon { get; set; } + + /// + /// 获得/设置 当前选择图标回调方法 + /// + [Parameter] + public EventCallback IconChanged { get; set; } + + /// + /// 获得/设置 拷贝成功提示文字 + /// + [Parameter] + public string? CopiedTooltipText { get; set; } + + [Inject] + [NotNull] + private DialogService? DialogService { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + /// + /// + /// + protected override void OnParametersSet() + { + base.OnParametersSet(); + + DialogHeaderText ??= Localizer[nameof(DialogHeaderText)]; + } + + /// + /// + /// + /// + protected override Task InvokeInitAsync() => InvokeVoidAsync("init", Id, Interop, nameof(UpdateIcon), nameof(ShowDialog)); + + /// + /// UpdateIcon 方法由 JS Invoke 调用 + /// + /// + [JSInvokable] + public async Task UpdateIcon(string icon) + { + Icon = icon; + if (IconChanged.HasDelegate) + { + await IconChanged.InvokeAsync(Icon); + } + else + { + StateHasChanged(); + } + } + + /// + /// ShowDialog 方法由 JS Invoke 调用 + /// + /// + [JSInvokable] + public Task ShowDialog(string text) => DialogService.ShowCloseDialog(DialogHeaderText, parameters => + { + parameters.Add(nameof(IconDialog.IconName), text); + }); +} diff --git a/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor.css b/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor.css new file mode 100644 index 000000000..d9147ce3e --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor.css @@ -0,0 +1,47 @@ +.icon-list { + --bb-icon-list-width: 190px; + display: flex; +} + + .icon-list .icons-body { + flex: 1; + } + + .icon-list.is-catalog .icons-body { + overflow-y: auto; + overflow-x: hidden; + height: 100%; + } + + .icon-list .icons-nav { + overflow-y: auto; + overflow-x: hidden; + width: var(--bb-icon-list-width); + height: 100%; + display: none; + } + + .icon-list .icons-nav h5 { + padding-left: .5rem; + } + + .icon-list .icons-nav .nav { + --bs-nav-link-padding-x: .5rem; + --bs-nav-link-padding-y: .25rem; + flex-direction: column; + flex-wrap: nowrap; + } + + .icon-list .icons-nav .nav-link:not(:last-child) { + margin-top: .125rem; + } + + .icon-list .icons-nav.nav-pills { + --bs-nav-pills-link-active-bg: var(--bs-warning); + } + +@media (min-width: 768px) { + .icon-list .icons-nav { + display: block; + } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor.js b/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor.js new file mode 100644 index 000000000..a0df3de21 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/FAIconList.razor.js @@ -0,0 +1,115 @@ +import { copy } from "../../../_content/BootstrapBlazor/modules/utility.js" +import Data from "../../../_content/BootstrapBlazor/modules/data.js" +import EventHandler from "../../../_content/BootstrapBlazor/modules/event-handler.js" + +export function init(id, invoke, updateMethod, showDialogMethod) { + const el = document.getElementById(id); + const faList = { + element: el, + invoke, + updateMethod, + showDialogMethod + } + Data.set(id, faList) + + if (el.classList.contains('is-catalog')) { + faList.body = el.querySelector('.icons-body') + faList.target = faList.body.getAttribute('data-bs-target') + faList.scrollspy = new bootstrap.ScrollSpy(faList.body, { + target: faList.target + }) + faList.base = el.querySelector('#bb-fa-top') + } + + EventHandler.on(el, 'click', '.nav-link', e => { + e.preventDefault() + e.stopPropagation() + + const targetId = e.delegateTarget.getAttribute('href') + const target = el.querySelector(targetId) + + const rect = target.getBoundingClientRect() + let margin = rect.top + let marginTop = getComputedStyle(target).getPropertyValue('margin-top').replace('px', '') + if (marginTop) { + margin = margin - parseInt(marginTop) + } + const offset = el.getAttribute('bb-data-offset') + if (offset) { + margin = margin - parseInt(offset) + } + margin = margin - faList.base.getBoundingClientRect().top + faList.body.scrollTo(0, margin) + }) + + EventHandler.on(el, 'click', '.icons-body a', e => { + e.preventDefault() + e.stopPropagation() + + const i = e.delegateTarget.querySelector('i') + const css = i.getAttribute('class') + faList.invoke.invokeMethodAsync(faList.updateMethod, css) + const dialog = el.classList.contains('is-dialog') + if (dialog) { + faList.invoke.invokeMethodAsync(faList.showDialogMethod, css) + } + else { + faList.copy(e.delegateTarget, css) + } + }) + + faList.copy = (element, text) => { + copy(text) + + faList.tooltip = bootstrap.Tooltip.getInstance(element) + if (faList.tooltip) { + faList.reset(element) + } + else { + faList.show(element) + } + } + + faList.show = element => { + faList.tooltip = new bootstrap.Tooltip(element, { + title: faList.element.getAttribute('data-bb-title') + }) + faList.tooltip.show() + faList.tooltipHandler = setTimeout(() => { + clearTimeout(faList.tooltipHandler) + if (faList.tooltip) { + faList.tooltip.dispose() + } + }, 1000) + } + + faList.reset = element => { + if (faList.tooltipHandler) { + clearTimeout(faList.tooltipHandler) + } + if (faList.tooltip) { + faList.tooltipHandler = setTimeout(() => { + clearTimeout(faList.tooltipHandler) + faList.tooltip.dispose() + faList.show() + }, 10) + } + else { + faList.show(element) + } + } +} + +export function dispose(id) { + const faList = Data.get(id) + Data.remove(id) + + if (faList) { + EventHandler.off(faList.element, 'click', '.nav-link') + EventHandler.off(faList.element, 'click', '.icons-body a') + + if (faList.scrollspy) { + faList.scrollspy.dispose() + } + } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/GlobalSearch.razor b/src/Admin/ThingsGateway.Razor/Components/GlobalSearch.razor new file mode 100644 index 000000000..5087aa1ee --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/GlobalSearch.razor @@ -0,0 +1,5 @@ +@namespace ThingsGateway.Razor +@* 响应布局,xl断点下显示,其他情况 不显示 *@ +
+ +
diff --git a/src/Admin/ThingsGateway.Razor/Components/GlobalSearch.razor.cs b/src/Admin/ThingsGateway.Razor/Components/GlobalSearch.razor.cs new file mode 100644 index 000000000..9099b2567 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/GlobalSearch.razor.cs @@ -0,0 +1,67 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +public partial class GlobalSearch +{ + /// + [Parameter] + [EditorRequired] + [NotNull] + public IEnumerable? Menus { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + [Inject] + [NotNull] + private NavigationManager? NavigationManager { get; set; } + + [NotNull] + private string? SearchText { get; set; } + + /// + protected override void OnInitialized() + { + SearchText = Localizer[nameof(SearchText)]; + } + + [Inject] + IServiceProvider ServiceProvider { get; set; } + + private async Task> OnSearch(string searchText) + { + await Task.CompletedTask; + if (!string.IsNullOrWhiteSpace(searchText)) + { + return Menus?.Where(i => i.Text?.Contains(searchText, StringComparison.OrdinalIgnoreCase) ?? false).Where(a => a != null && !string.IsNullOrEmpty(a.Url)).Select(a => a.Text); + } + else + { + return Menus?.Where(a => a != null && !string.IsNullOrEmpty(a.Url)).Select(a => a.Text); + } + } + + private async Task OnSelectedItemChanged(string searchText) + { + await Task.CompletedTask; + if (!string.IsNullOrWhiteSpace(searchText)) + { + var item = Menus?.FirstOrDefault(i => i.Text?.Equals(searchText, StringComparison.OrdinalIgnoreCase) ?? false); + if (item != null && !string.IsNullOrEmpty(item.Url)) + { + NavigationManager.NavigateTo(ServiceProvider, item.Url, item.Text, item.Icon); + } + } + } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/GlobalSearch.razor.css b/src/Admin/ThingsGateway.Razor/Components/GlobalSearch.razor.css new file mode 100644 index 000000000..c5eaa7d4c --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/GlobalSearch.razor.css @@ -0,0 +1,5 @@ +::deep .auto-complete { + --bs-border-width: 0; + border: 1px solid var(--bs-border-color); + border-radius: var(--bs-border-radius); +} diff --git a/src/Admin/ThingsGateway.Razor/Components/ImportExcel.razor b/src/Admin/ThingsGateway.Razor/Components/ImportExcel.razor new file mode 100644 index 000000000..e157d45ea --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/ImportExcel.razor @@ -0,0 +1,56 @@ +@using ThingsGateway.Extension +@namespace ThingsGateway.Razor + +
@Localizer["Tip"]
+ + + + + + + +
+ + @foreach (var item in _importPreviews) + { +
+ @( + Localizer["UploadCount", item.Key, item.Value.DataCount] + ) +
+
+ @( + (item.Value.HasError ? "Error" : "Success") + ) +
+ if (item.Value.HasError) + { +
+ + +
+ @item1.Row + + @item1.ErrorMessage + +
+
+
+
+ } + + } + + + + +
+
+ + @Localizer["Import"] + +
+@code { + [NotNull] + Step? step{ get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/ImportExcel.razor.cs b/src/Admin/ThingsGateway.Razor/Components/ImportExcel.razor.cs new file mode 100644 index 000000000..658290ec8 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/ImportExcel.razor.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; + +using System.ComponentModel.DataAnnotations; + +namespace ThingsGateway.Razor; + +/// +public partial class ImportExcel +{ + private Dictionary _importPreviews = new(); + + /// + /// 导入 + /// + [Parameter] + [EditorRequired] + public Func, Task> Import { get; set; } + + [Inject] + [NotNull] + private IStringLocalizer? Localizer { get; set; } + + /// + /// 预览 + /// + [Parameter] + [EditorRequired] + public Func>> Preview { get; set; } + + [Inject] + [NotNull] + private ToastService? ToastService { get; set; } + + [Required] + private IBrowserFile _importFile { get; set; } + + [CascadingParameter] + private Func? OnCloseAsync { get; set; } + + private async Task DeviceImport(IBrowserFile file) + { + try + { + _importPreviews.Clear(); + _importPreviews = await Preview.Invoke(file); + await step.Next(); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + } + } + + private async Task SaveDeviceImport() + { + try + { + await Task.Run(async () => + { + await Import.Invoke(_importPreviews); + _importFile = null; + + + await InvokeAsync(async () => + { + if (OnCloseAsync != null) + await OnCloseAsync(); + await ToastService.Default(); + }); + }); + } + catch (Exception ex) + { + await InvokeAsync(async () => + { + await ToastService.Warn(ex); + }); + } + } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/ImportExcel.razor.css b/src/Admin/ThingsGateway.Razor/Components/ImportExcel.razor.css new file mode 100644 index 000000000..28f1aff11 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/ImportExcel.razor.css @@ -0,0 +1,9 @@ +::deep .avatar { + border-radius: 1.5rem; + width: 24px; + height: 24px; + background-color: var(--bs-red); + color: #fff; + flex: 0 0 auto; + font-size: 1rem; +} diff --git a/src/Admin/ThingsGateway.Razor/Components/Logo.razor b/src/Admin/ThingsGateway.Razor/Components/Logo.razor new file mode 100644 index 000000000..641bf286d --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/Logo.razor @@ -0,0 +1,2 @@ +@namespace ThingsGateway.Razor +
@WebsiteOption.Value.Title
diff --git a/src/Admin/ThingsGateway.Razor/Components/Logo.razor.cs b/src/Admin/ThingsGateway.Razor/Components/Logo.razor.cs new file mode 100644 index 000000000..7148dcb26 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/Logo.razor.cs @@ -0,0 +1,19 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +public partial class Logo +{ + [Inject] + [NotNull] + private IOptions? WebsiteOption { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/Logo.razor.css b/src/Admin/ThingsGateway.Razor/Components/Logo.razor.css new file mode 100644 index 000000000..5f282702b --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/Logo.razor.css @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/Components/MenuIconList.razor b/src/Admin/ThingsGateway.Razor/Components/MenuIconList.razor new file mode 100644 index 000000000..630c43ff5 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/MenuIconList.razor @@ -0,0 +1,10 @@ +@namespace ThingsGateway.Razor +
+ + +
\ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/Components/MenuIconList.razor.cs b/src/Admin/ThingsGateway.Razor/Components/MenuIconList.razor.cs new file mode 100644 index 000000000..75b3306fa --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/MenuIconList.razor.cs @@ -0,0 +1,36 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +public partial class MenuIconList +{ + /// + [Parameter] + public string? Value { get; set; } + + /// + [Parameter] + public EventCallback ValueChanged { get; set; } + + [Inject] + private IStringLocalizer Localizer { get; set; } + [Inject] + private IStringLocalizer RazorLocalizer { get; set; } + + private async Task OnSelctedIcon() + { + if (ValueChanged.HasDelegate) + { + await ValueChanged.InvokeAsync(Value); + } + } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/MenuIconList.razor.css b/src/Admin/ThingsGateway.Razor/Components/MenuIconList.razor.css new file mode 100644 index 000000000..849937380 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/MenuIconList.razor.css @@ -0,0 +1,29 @@ +.form-footer { + display: flex; + align-items: center; + justify-content: flex-end; +} + +.icon { + font-size: 22px; + border: solid 1px; + border-radius: 4px; + min-width: 36px; + min-height: 34px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 .25rem; +} + +::deep .fil a:hover { + background-color: var(--bs-primary); +} + +::deep .nav-pills .nav-link.active { + --bs-nav-pills-link-active-bg: var(--bs-primary); +} + +::deep .icon-list { + height: calc(100vh - 8rem - 47px - 52px); +} diff --git a/src/Admin/ThingsGateway.Razor/Components/SelectItemChooser.razor b/src/Admin/ThingsGateway.Razor/Components/SelectItemChooser.razor new file mode 100644 index 000000000..22c47e339 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/SelectItemChooser.razor @@ -0,0 +1,9 @@ +@namespace ThingsGateway.Razor +
+ @foreach (var item in Items) + { +
+ @ItemTemplate(item) +
+ } +
diff --git a/src/Admin/ThingsGateway.Razor/Components/SelectItemChooser.razor.cs b/src/Admin/ThingsGateway.Razor/Components/SelectItemChooser.razor.cs new file mode 100644 index 000000000..682818655 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/SelectItemChooser.razor.cs @@ -0,0 +1,61 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +public partial class SelectItemChooser +{ + private long _selectedItem; + + /// + [Parameter] + [NotNull] + [EditorRequired] + public IEnumerable Items { get; set; } + + /// + [Parameter] + [EditorRequired] + [NotNull] + public RenderFragment? ItemTemplate { get; set; } + + /// + [Parameter] + [NotNull] + public long Value { get; set; } + + /// + [Parameter] + [NotNull] + public EventCallback ValueChanged { get; set; } + + /// + protected override async Task OnParametersSetAsync() + { + _selectedItem = Value; + await base.OnParametersSetAsync(); + } + + private string? GetItemClass(long item) => CssBuilder.Default("btn m-2") + .AddClass("btn-primary", _selectedItem == item) + .Build(); + + private async Task OnClickItem(long item) + { + if (_selectedItem != item && ValueChanged.HasDelegate) + { + Value = item; + await ValueChanged.InvokeAsync(Value); + } + _selectedItem = item; + Value = item; + } +} diff --git a/src/Admin/ThingsGateway.Razor/Components/SelectItemChooser.razor.css b/src/Admin/ThingsGateway.Razor/Components/SelectItemChooser.razor.css new file mode 100644 index 000000000..16d15656b --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Components/SelectItemChooser.razor.css @@ -0,0 +1,2 @@ +.selectItem-chooser { +} diff --git a/src/Admin/ThingsGateway.Razor/Const/WebsiteConst.cs b/src/Admin/ThingsGateway.Razor/Const/WebsiteConst.cs new file mode 100644 index 000000000..19de9687c --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Const/WebsiteConst.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +/// 网站常量 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class WebsiteConst +{ + /// + /// 默认的资源项目名称 + /// + public const string DefaultProjectName = "ThingsGateway.Razor"; + + /// + /// 默认的资源路径 + /// + public const string DefaultResourceUrl = "/_content/ThingsGateway.Razor/"; +} diff --git a/src/Admin/ThingsGateway.Razor/Data/BasePageInput.cs b/src/Admin/ThingsGateway.Razor/Data/BasePageInput.cs new file mode 100644 index 000000000..c18590fc4 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Data/BasePageInput.cs @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +/// 全局分页查询输入参数 +/// +public class BasePageInput : IBasePageInput +{ + /// + /// 当前页码 + /// + public virtual int Current { get; set; } = 1; + + /// + /// 每页条数 + /// + public virtual int Size { get; set; } = 10; + + /// + /// 排序方式,true为desc,false为asc + /// + public virtual List SortDesc { get; set; } = new List(); + + /// + /// 排序字段 + /// + public virtual List SortField { get; set; } = new List(); +} diff --git a/src/Admin/ThingsGateway.Razor/Data/IBasePageInput.cs b/src/Admin/ThingsGateway.Razor/Data/IBasePageInput.cs new file mode 100644 index 000000000..e024097d0 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Data/IBasePageInput.cs @@ -0,0 +1,37 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +/// 全局分页查询输入参数 +/// +public interface IBasePageInput +{ + /// + /// 当前页码 + /// + int Current { get; set; } + + /// + /// 每页条数 + /// + int Size { get; set; } + + /// + /// 排序方式,true为desc,false为asc + /// + List SortDesc { get; set; } + + /// + /// 排序字段 + /// + List SortField { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Data/IPrimaryIdEntity.cs b/src/Admin/ThingsGateway.Razor/Data/IPrimaryIdEntity.cs new file mode 100644 index 000000000..60b57755f --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Data/IPrimaryIdEntity.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +/// 主键id基类 +/// +public interface IPrimaryIdEntity +{ + /// + /// 主键Id + /// + long Id { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Data/MessageItem.cs b/src/Admin/ThingsGateway.Razor/Data/MessageItem.cs new file mode 100644 index 000000000..f708f1902 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Data/MessageItem.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +public class MessageItem +{ + /// + public ToastCategory Category { get; set; } + /// + public string? Message { get; set; } + /// + public string? Title { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Data/SqlSugarPagedList.cs b/src/Admin/ThingsGateway.Razor/Data/SqlSugarPagedList.cs new file mode 100644 index 000000000..d1fe86cde --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Data/SqlSugarPagedList.cs @@ -0,0 +1,54 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +/// SqlSugar 分页泛型集合 +/// +/// +public class SqlSugarPagedList +{ + /// + /// 页码 + /// + public int Current { get; set; } + + /// + /// 是否有下一页 + /// + public bool HasNextPages { get; set; } + + /// + /// 是否有上一页 + /// + public bool HasPrevPages { get; set; } + + /// + /// 总页数 + /// + public int Pages { get; set; } + + /// + /// 当前页集合 + /// + [NotNull] + public IEnumerable? Records { get; set; } + + /// + /// 数量 + /// + public int Size { get; set; } + + /// + /// 总条数 + /// + public int Total { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Extensions/DisplayExtensions.cs b/src/Admin/ThingsGateway.Razor/Extensions/DisplayExtensions.cs new file mode 100644 index 000000000..6b4289267 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Extensions/DisplayExtensions.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class DisplayExtensions +{ + /// + public static bool GetIgnore(this IEditorItem col) => col.Ignore ?? false; + + /// + public static bool GetReadonly(this IEditorItem col) => col.Readonly ?? false; +} diff --git a/src/Admin/ThingsGateway.Razor/Extensions/GenericExtensions.cs b/src/Admin/ThingsGateway.Razor/Extensions/GenericExtensions.cs new file mode 100644 index 000000000..6bda44f54 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Extensions/GenericExtensions.cs @@ -0,0 +1,161 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Reflection; + +using ThingsGateway.NewLife.Caching; + +namespace ThingsGateway.Extension.Generic; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class GenericExtensions +{ + private static MemoryCache Instance { get; set; } = new MemoryCache(); + + /// + /// 把已修改的属性赋值到列表中,并返回字典 + /// + /// + /// + /// + /// + /// + public static Dictionary GetDiffProperty(this IEnumerable models, T oldModel, T model) + { + // 获取Channel类型的所有公共属性 + var properties = typeof(T).GetRuntimeProperties(); + + // 比较oldModel和model的属性,找出差异 + var differences = properties + .Where(prop => prop.CanRead && prop.CanWrite) // 确保属性可读可写 + .Where(prop => !Equals(prop.GetValue(oldModel), prop.GetValue(model))) // 找出值不同的属性 + .ToDictionary(prop => prop.Name, prop => prop.GetValue(model)); // 将属性名和新值存储到字典中 + + // 应用差异到channels列表中的每个Channel对象 + foreach (var channel in models) + { + foreach (var difference in differences) + { + BootstrapBlazor.Components.Utility.SetPropertyValue(channel, difference.Key, difference.Value); + } + } + + return differences; + } + + + /// + public static IEnumerable GetProperties(this IEnumerable value, params string[] names) + { + // 获取动态对象集合的类型 + var type = value.GetType().GetGenericArguments().LastOrDefault() ?? throw new ArgumentNullException(nameof(value)); + + var namesStr = System.Text.Json.JsonSerializer.Serialize(names); + // 构建缓存键,包括属性名和类型信息 + var cacheKey = $"{namesStr}-{type.FullName}-{type.TypeHandle.Value}"; + + // 从缓存中获取属性信息,如果缓存不存在,则创建并缓存 + var result = Instance.GetOrAdd(cacheKey, a => + { + // 获取动态对象类型中指定名称的属性信息 + var properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty) + .Where(pi => names.Contains(pi.Name)) // 筛选出指定属性名的属性信息 + .Where(pi => pi != null) // 过滤空属性信息 + .AsEnumerable(); + + // 检查是否找到了所有指定名称的属性,如果没有找到,则抛出异常 + if (names.Length != properties.Count()) + { + throw new InvalidOperationException($"Couldn't find properties on type:{type.Name},{Environment.NewLine}names:{namesStr}"); + } + + return properties; // 返回属性信息集合 + }, 3600); // 缓存有效期为3600秒 + + return result!; // 返回属性信息集合 + } + + /// + public static IEnumerable> GroupByKeys(this IEnumerable values, IEnumerable keys) + { + // 获取动态对象集合中指定键的属性信息 + var properties = GetProperties(values, keys.ToArray()); + + // 使用对象数组作为键进行分组 + return values.GroupBy(v => properties.Select(property => property.GetValue(v)).ToArray(), new ArrayEqualityComparer()); + } + + /// + /// 是否都包含 + /// + /// + /// 第一个列表 + /// 第二个列表 + /// + public static bool ContainsAll(this IEnumerable first, IEnumerable? second) + { + return second.All(s => first.Any(f => f.Equals(s))); + } +} + +/// +public class ArrayEqualityComparer : IEqualityComparer +{ + /// + public bool Equals(object[]? x, object[]? y) + { + // 如果引用相同,则返回 true + if (ReferenceEquals(x, y)) return true; + + // 如果其中一个数组为空,则返回 false + if (x == null || y == null) return false; + + // 如果两个数组的长度不相等,则返回 false + if (x.Length != y.Length) return false; + + // 逐个比较数组中的元素是否相等 + for (var i = 0; i < x.Length; i++) + { + // 如果任何一个元素不相等,则返回 false + if (!Equals(x[i], y[i])) + { + return false; + } + } + + // 如果所有元素都相等,则返回 true + return true; + } + + /// + /// 计算对象数组的哈希值 + /// + /// + /// + public int GetHashCode(object[]? obj) + { + // 如果数组为空,则返回 0 + if (obj == null) return 0; + + // 初始化哈希值为 17 + var hash = 17; + + // 遍历数组中的每个元素,计算哈希值并与当前哈希值组合 + foreach (var item in obj) + { + // 如果元素不为空,则计算其哈希值并与当前哈希值组合 + hash = hash * 23 + (item?.GetHashCode() ?? 0); + } + + // 返回最终计算得到的哈希值 + return hash; + } +} diff --git a/src/Admin/ThingsGateway.Razor/Extensions/JSRuntimeExtensions.cs b/src/Admin/ThingsGateway.Razor/Extensions/JSRuntimeExtensions.cs new file mode 100644 index 000000000..ebd49eb2c --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Extensions/JSRuntimeExtensions.cs @@ -0,0 +1,33 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.JSInterop; + +namespace ThingsGateway.Razor; + +/// +/// JSRuntime扩展方法 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class JSRuntimeExtensions +{ + /// + /// 获取文化信息 + /// + /// + public static ValueTask GetCulture(this IJSRuntime jsRuntime) => jsRuntime.InvokeAsync("getCultureLocalStorage"); + + /// + /// 设置文化信息 + /// + /// + /// + public static ValueTask SetCulture(this IJSRuntime jsRuntime, string cultureName) => jsRuntime.InvokeVoidAsync("setCultureLocalStorage", cultureName); +} diff --git a/src/Admin/ThingsGateway.Razor/Extensions/LocalizerExtensions.cs b/src/Admin/ThingsGateway.Razor/Extensions/LocalizerExtensions.cs new file mode 100644 index 000000000..dbc19bf04 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Extensions/LocalizerExtensions.cs @@ -0,0 +1,342 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; + +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq.Expressions; +using System.Reflection; + +namespace ThingsGateway; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class LocalizerExtensions +{ + /// + /// 获取 DisplayName属性名称 + /// + /// + /// + /// + /// + public static string Description(this T item, Expression> accessor) + { + if (accessor.Body == null) + { + throw new ArgumentNullException(nameof(accessor)); + } + + var expression = accessor.Body; + if (expression is UnaryExpression unaryExpression && unaryExpression.NodeType == ExpressionType.Convert && unaryExpression.Type == typeof(object)) + { + expression = unaryExpression.Operand; + } + + if (expression is not MemberExpression memberExpression) + { + throw new ArgumentException("Can only access properties"); + } + + return typeof(T).GetPropertyDisplayName(memberExpression.Member.Name) ?? memberExpression.Member.Name; + } + + /// + public static FieldInfo? GetFieldByName(this Type type, string fieldName) => type.GetRuntimeFields().FirstOrDefault(p => p.Name == fieldName); + + /// + public static MethodInfo? GetMethodByName(this Type type, string methodName) => type.GetRuntimeMethods().FirstOrDefault(p => p.Name == methodName); + + /// + /// 获得方法的描述信息 + /// + /// + /// + /// + public static string GetMethodDisplayName(this Type modelType, string methodName) + { + var cacheKey = $"{nameof(GetMethodDisplayName)}-{CultureInfo.CurrentUICulture.Name}-{modelType.FullName}-{modelType.TypeHandle.Value}-{methodName}"; + var displayName = App.CacheService.GetOrAdd(cacheKey, entry => + { + string? dn = null; + // 显示名称为空时通过资源文件查找 methodName 项 + var localizer = modelType.Assembly.IsDynamic ? null : App.CreateLocalizerByType(modelType); + var stringLocalizer = localizer?[methodName]; + if (stringLocalizer is { ResourceNotFound: false }) + { + dn = stringLocalizer.Value; + } + else + { + var info = modelType.GetMethodByName(methodName); + if (info != null) + { + dn = FindDisplayAttribute(info); + } + } + + return dn; + }, 300); + + return displayName ?? methodName; + + string? FindDisplayAttribute(MemberInfo memberInfo) + { + // 回退查找 Display 标签 + var dn = memberInfo.GetCustomAttribute(true)?.Name + ?? memberInfo.GetCustomAttribute(true)?.DisplayName + ?? memberInfo.GetCustomAttribute(true)?.Description; + + return dn; + } + } + + /// + public static PropertyInfo? GetPropertyByName(this Type type, string propertyName) => type.GetRuntimeProperties().FirstOrDefault(p => p.Name == propertyName); + + /// + /// 获得类型属性的描述信息 + /// + public static string GetPropertyDisplayName(this Type modelType, string fieldName, Func func = null) + { + var cacheKey = $"{nameof(GetPropertyDisplayName)}-{CultureInfo.CurrentUICulture.Name}-{modelType.FullName}-{modelType.TypeHandle.Value}-{fieldName}"; + var displayName = App.CacheService.GetOrAdd(cacheKey, entry => + { + string? dn = null; + // 显示名称为空时通过资源文件查找 FieldName 项 + var localizer = modelType.Assembly.IsDynamic ? null : App.CreateLocalizerByType(modelType); + var stringLocalizer = localizer?[fieldName]; + if (stringLocalizer is { ResourceNotFound: false }) + { + dn = stringLocalizer.Value; + } + else if (modelType.IsEnum) + { + var info = modelType.GetFieldByName(fieldName); + if (info != null) + { + dn = FindDisplayAttribute(info); + } + } + else if (TryGetProperty(modelType, fieldName, out var propertyInfo)) + { + dn = FindDisplayAttribute(propertyInfo); + } + + return dn; + }, 300); + + return displayName ?? fieldName; + + string? FindDisplayAttribute(MemberInfo memberInfo) + { + // 回退查找 Display 标签 + var dn = memberInfo.GetCustomAttribute(true)?.Name + ?? memberInfo.GetCustomAttribute(true)?.DisplayName + ?? memberInfo.GetCustomAttribute(true)?.Description + ?? func?.Invoke(memberInfo); + return dn; + } + } + + /// + /// 获得类型自身的描述信息 + /// + /// + /// + public static string GetTypeDisplayName(this Type modelType) + { + string fieldName = modelType.Name; + var cacheKey = $"{nameof(GetTypeDisplayName)}-{CultureInfo.CurrentUICulture.Name}-{modelType.FullName}-{modelType.TypeHandle.Value}"; + var displayName = App.CacheService.GetOrAdd(cacheKey, entry => + { + string? dn = null; + // 显示名称为空时通过资源文件查找 FieldName 项 + var localizer = modelType.Assembly.IsDynamic ? null : App.CreateLocalizerByType(modelType); + var stringLocalizer = localizer?[fieldName]; + if (stringLocalizer is { ResourceNotFound: false }) + { + dn = stringLocalizer.Value; + } + else if (modelType.IsEnum) + { + var info = modelType.GetFieldByName(fieldName); + if (info != null) + { + dn = FindDisplayAttribute(info); + } + } + else if (TryGetProperty(modelType, fieldName, out var propertyInfo)) + { + dn = FindDisplayAttribute(propertyInfo); + } + + return dn; + }, 300); + + return displayName ?? fieldName; + + string? FindDisplayAttribute(MemberInfo memberInfo) + { + // 回退查找 Display 标签 + var dn = memberInfo.GetCustomAttribute(true)?.Name + ?? memberInfo.GetCustomAttribute(true)?.DisplayName + ?? memberInfo.GetCustomAttribute(true)?.Description; + + return dn; + } + } + + /// + /// 获取指定 Type 的资源文件 + /// + /// + /// + /// + /// + public static bool TryGetLocalizerString(this IStringLocalizer localizer, string key, [MaybeNullWhen(false)] out string? text) + { + var ret = false; + text = null; + var l = localizer[key]; + if (l != null) + { + ret = !l.ResourceNotFound; + if (ret) + { + text = l.Value; + } + } + return ret; + } + + /// + /// 验证整个模型时验证属性方法 + /// + /// + /// + public static void ValidateProperty(this ValidationContext context, List results) + { + // 获得所有可写属性 + var properties = context.ObjectType.GetRuntimeProperties().Where(p => IsPublic(p) && p.CanWrite && p.GetIndexParameters().Length == 0); + foreach (var pi in properties) + { + var fieldIdentifier = new FieldIdentifier(context.ObjectInstance, pi.Name); + context.DisplayName = fieldIdentifier.GetDisplayName(); + context.MemberName = fieldIdentifier.FieldName; + + var propertyValue = BootstrapBlazor.Components.Utility.GetPropertyValue(context.ObjectInstance, context.MemberName); + + // 验证 DataAnnotations + var messages = new List(); + // 组件进行验证 + ValidateDataAnnotations(propertyValue, context, messages, pi); + if (messages.Count > 0) + results.AddRange(messages); + } + } + + private static bool IsPublic(PropertyInfo p) => p.GetMethod != null && p.SetMethod != null && p.GetMethod.IsPublic && p.SetMethod.IsPublic; + + private static bool TryGetProperty(Type modelType, string fieldName, [NotNullWhen(true)] out PropertyInfo? propertyInfo) + { + var cacheKey = $"{nameof(TryGetProperty)}-{modelType.FullName}-{modelType.TypeHandle.Value}-{fieldName}"; + propertyInfo = App.CacheService.GetOrAdd(cacheKey, entry => + { + IEnumerable? props; + + // 支持 MetadataType + var metadataType = modelType.GetCustomAttribute(false); + if (metadataType != null) + { + props = modelType.GetRuntimeProperties().AsEnumerable().Concat(metadataType.MetadataClassType.GetRuntimeProperties()); + } + else + { + props = modelType.GetRuntimeProperties().AsEnumerable(); + } + + var pi = props.FirstOrDefault(p => p.Name == fieldName); + + return pi; + }, 300); + return propertyInfo != null; + } + + /// + /// 通过属性设置的 DataAnnotation 进行数据验证 + /// + /// + /// + /// + /// + /// + private static void ValidateDataAnnotations(object? value, ValidationContext context, List results, PropertyInfo propertyInfo, string? memberName = null) + { + var rules = propertyInfo.GetCustomAttributes(true).OfType(); + var metadataType = context.ObjectType.GetCustomAttribute(false); + if (metadataType != null) + { + var p = metadataType.MetadataClassType.GetPropertyByName(propertyInfo.Name); + if (p != null) + { + rules = rules.Concat(p.GetCustomAttributes(true).OfType()); + } + } + var displayName = context.DisplayName; + memberName ??= propertyInfo.Name; + var attributeSpan = nameof(Attribute).AsSpan(); + foreach (var rule in rules) + { + var result = rule.GetValidationResult(value, context); + if (result != null && result != ValidationResult.Success) + { + var find = false; + var ruleNameSpan = rule.GetType().Name.AsSpan(); + var index = ruleNameSpan.IndexOf(attributeSpan, StringComparison.OrdinalIgnoreCase); + var ruleName = ruleNameSpan[..index]; + //// 通过设置 ErrorMessage 检索 + //if (!context.ObjectType.Assembly.IsDynamic && !find + // && !string.IsNullOrEmpty(rule.ErrorMessage) + // && App.CreateLocalizerByType(context.ObjectType).TryGetLocalizerString(rule.ErrorMessage, out var msg)) + //{ + // rule.ErrorMessage = msg; + // find = true; + //} + + //// 通过 Attribute 检索 + //if (!rule.GetType().Assembly.IsDynamic && !find + // && App.CreateLocalizerByType(rule.GetType()).TryGetLocalizerString(nameof(rule.ErrorMessage), out msg)) + //{ + // rule.ErrorMessage = msg; + // find = true; + //} + + // 通过 字段.规则名称 检索 + if (!context.ObjectType.Assembly.IsDynamic && !find + && App.CreateLocalizerByType(context.ObjectType).TryGetLocalizerString($"{memberName}.{ruleName.ToString()}", out var msg)) + { + rule.ErrorMessage = msg; + find = true; + } + + if (!find) + { + rule.ErrorMessage = result.ErrorMessage; + } + var errorMessage = !string.IsNullOrEmpty(rule.ErrorMessage) && rule.ErrorMessage.Contains("{0}") + ? rule.FormatErrorMessage(displayName) + : rule.ErrorMessage; + results.Add(new ValidationResult(errorMessage, new string[] { memberName })); + } + } + } +} diff --git a/src/Admin/ThingsGateway.Razor/Extensions/ObjectExtensions.cs b/src/Admin/ThingsGateway.Razor/Extensions/ObjectExtensions.cs new file mode 100644 index 000000000..feb6ecee2 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Extensions/ObjectExtensions.cs @@ -0,0 +1,619 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +// 版权归百小僧及百签科技(广东)有限公司所有。 + + +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ThingsGateway; + +/// +/// 对象拓展类 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class ObjectExtensions +{ + /// + /// 判断类型是否实现某个泛型 + /// + /// 类型 + /// 泛型类型 + /// bool + public static bool HasImplementedRawGeneric(this Type type, Type generic) + { + // 检查接口类型 + var isTheRawGenericType = type.GetInterfaces().Any(IsTheRawGenericType); + if (isTheRawGenericType) return true; + + // 检查类型 + while (type != null && type != typeof(object)) + { + isTheRawGenericType = IsTheRawGenericType(type); + if (isTheRawGenericType) return true; + type = type.BaseType!; + } + + return false; + + // 判断逻辑 + bool IsTheRawGenericType(Type type) => generic == (type.IsGenericType ? type.GetGenericTypeDefinition() : type); + } + + + /// + /// 将 DateTimeOffset 转换成本地 DateTime + /// + /// + /// + public static DateTime ConvertToDateTime(this DateTimeOffset dateTime) + { + if (dateTime.Offset.Equals(TimeSpan.Zero)) + return dateTime.UtcDateTime; + if (dateTime.Offset.Equals(TimeZoneInfo.Local.GetUtcOffset(dateTime.DateTime))) + return dateTime.ToLocalTime().DateTime; + else + return dateTime.DateTime; + } + + /// + /// 将 DateTimeOffset? 转换成本地 DateTime? + /// + /// + /// + public static DateTime? ConvertToDateTime(this DateTimeOffset? dateTime) + { + return dateTime.HasValue ? dateTime.Value.ConvertToDateTime() : null; + } + + /// + /// 将 DateTime 转换成 DateTimeOffset + /// + /// + /// + public static DateTimeOffset ConvertToDateTimeOffset(this DateTime dateTime) + { + return DateTime.SpecifyKind(dateTime, DateTimeKind.Local); + } + + /// + /// 将 DateTime? 转换成 DateTimeOffset? + /// + /// + /// + public static DateTimeOffset? ConvertToDateTimeOffset(this DateTime? dateTime) + { + return dateTime.HasValue ? dateTime.Value.ConvertToDateTimeOffset() : null; + } + + /// + /// 将流保存到本地磁盘 + /// + /// + /// + /// + public static void CopyToSave(this Stream stream, string path) + { + // 空检查 + if (string.IsNullOrWhiteSpace(path)) throw new ArgumentNullException(nameof(path)); + + using var fileStream = File.Create(path); + stream.CopyTo(fileStream); + } + + /// + /// 将字节数组保存到本地磁盘 + /// + /// + /// + /// + public static void CopyToSave(this byte[] bytes, string path) + { + using var stream = new MemoryStream(bytes); + stream.CopyToSave(path); + } + + /// + /// 将流保存到本地磁盘 + /// + /// + /// 需包含文件名完整路径 + /// + public static async Task CopyToSaveAsync(this Stream stream, string path) + { + // 空检查 + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentNullException(nameof(path)); + } + + // 文件名判断 + if (string.IsNullOrWhiteSpace(Path.GetFileName(path))) + { + throw new ArgumentException("The parameter of parameter must include the complete file name."); + } + + using var fileStream = File.Create(path); + await stream.CopyToAsync(fileStream).ConfigureAwait(false); + } + + /// + /// 将字节数组保存到本地磁盘 + /// + /// + /// + /// + public static async Task CopyToSaveAsync(this byte[] bytes, string path) + { + using var stream = new MemoryStream(bytes); + await stream.CopyToSaveAsync(path).ConfigureAwait(false); + } + + /// + /// 合并两个字典 + /// + /// + /// 字典 + /// 新字典 + /// + internal static Dictionary AddOrUpdate(this Dictionary dic, IDictionary newDic) + { + foreach (var key in newDic.Keys) + { + if (dic.TryGetValue(key, out var value)) + { + dic[key] = value; + } + else + { + dic.Add(key, newDic[key]); + } + } + + return dic; + } + + /// + /// 合并两个字典 + /// + /// + /// 字典 + /// 新字典 + internal static void AddOrUpdate(this ConcurrentDictionary dic, Dictionary newDic) + { + foreach (var (key, value) in newDic) + { + dic.AddOrUpdate(key, value, (key, old) => value); + } + } + + /// + /// 将一个对象转换为指定类型 + /// + /// + /// + /// + internal static T ChangeType(this object obj) + { + return (T)ChangeType(obj, typeof(T)); + } + + /// + /// 将一个对象转换为指定类型 + /// + /// 待转换的对象 + /// 目标类型 + /// 转换后的对象 + public static object? ChangeType(this object? obj, Type type) + { + if (type == null) return obj; + if (type == typeof(string)) return obj?.ToString(); + if (type == typeof(Guid) && obj != null) return Guid.Parse(obj.ToString()); + if (type == typeof(bool) && obj != null && obj is not bool) + { + var objStr = obj.ToString().ToLower(); + if (objStr == "1" || objStr == "true" || objStr == "yes" || objStr == "on") return true; + return false; + } + if (obj == null) return type.IsValueType ? Activator.CreateInstance(type) : null; + + var underlyingType = Nullable.GetUnderlyingType(type); + if (type.IsAssignableFrom(obj.GetType())) return obj; + else if ((underlyingType ?? type).IsEnum) + { + if (underlyingType != null && string.IsNullOrWhiteSpace(obj.ToString())) return null; + else return Enum.Parse(underlyingType ?? type, obj.ToString()); + } + // 处理 DateTime -> DateTimeOffset 类型 + else if (obj.GetType().Equals(typeof(DateTime)) && (underlyingType ?? type).Equals(typeof(DateTimeOffset))) + { + return ((DateTime)obj).ConvertToDateTimeOffset(); + } + // 处理 DateTimeOffset -> DateTime 类型 + else if (obj.GetType().Equals(typeof(DateTimeOffset)) && (underlyingType ?? type).Equals(typeof(DateTime))) + { + return ((DateTimeOffset)obj).ConvertToDateTime(); + } + // 处理 DateTime -> DateOnly 类型 + else if (obj.GetType().Equals(typeof(DateTime)) && (underlyingType ?? type).Equals(typeof(DateOnly))) + { + return DateOnly.FromDateTime(((DateTime)obj)); + } + // 处理 DateTime -> TimeOnly 类型 + else if (obj.GetType().Equals(typeof(DateTime)) && (underlyingType ?? type).Equals(typeof(TimeOnly))) + { + return TimeOnly.FromDateTime(((DateTime)obj)); + } + else if (typeof(IConvertible).IsAssignableFrom(underlyingType ?? type)) + { + try + { + return Convert.ChangeType(obj, underlyingType ?? type, null); + } + catch + { + return underlyingType == null ? Activator.CreateInstance(type) : null; + } + } + else + { + var converter = TypeDescriptor.GetConverter(type); + if (converter.CanConvertFrom(obj.GetType())) return converter.ConvertFrom(obj); + + var constructor = type.GetConstructor(Type.EmptyTypes); + if (constructor != null) + { + var o = constructor.Invoke(null); + var propertys = type.GetProperties(); + var oldType = obj.GetType(); + + foreach (var property in propertys) + { + var p = oldType.GetProperty(property.Name); + if (property.CanWrite && p != null && p.CanRead) + { + property.SetValue(o, ChangeType(p.GetValue(obj, null), property.PropertyType), null); + } + } + return o; + } + } + + return obj; + } + + /// + /// 清除字符串前后缀 + /// + /// 字符串 + /// 0:前后缀,1:后缀,-1:前缀 + /// 前后缀集合 + /// + internal static string ClearStringAffixes(this string str, int pos = 0, params string[] affixes) + { + // 空字符串直接返回 + if (string.IsNullOrWhiteSpace(str)) return str; + + // 空前后缀集合直接返回 + if (affixes == null || affixes.Length == 0) return str; + + var startCleared = false; + var endCleared = false; + + string tempStr = null; + foreach (var affix in affixes) + { + if (string.IsNullOrWhiteSpace(affix)) continue; + + if (pos != 1 && !startCleared && str.StartsWith(affix, StringComparison.OrdinalIgnoreCase)) + { + tempStr = str[affix.Length..]; + startCleared = true; + } + if (pos != -1 && !endCleared && str.EndsWith(affix, StringComparison.OrdinalIgnoreCase)) + { + var _tempStr = !string.IsNullOrWhiteSpace(tempStr) ? tempStr : str; + tempStr = _tempStr[..^affix.Length]; + endCleared = true; + + if (string.IsNullOrWhiteSpace(tempStr)) + { + tempStr = null; + endCleared = false; + } + } + if (startCleared && endCleared) break; + } + + return !string.IsNullOrWhiteSpace(tempStr) ? tempStr : str; + } + + /// + /// 将时间戳转换为 DateTime + /// + /// + /// + internal static DateTime ConvertToDateTime(this long timestamp) + { + var timeStampDateTime = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var digitCount = (int)Math.Floor(Math.Log10(timestamp) + 1); + + if (digitCount != 13 && digitCount != 10) + { + throw new ArgumentException("Data is not a valid timestamp format."); + } + + return (digitCount == 13 + ? timeStampDateTime.AddMilliseconds(timestamp) // 13 位时间戳 + : timeStampDateTime.AddSeconds(timestamp)).ToLocalTime(); // 10 位时间戳 + } + + /// + /// 格式化字符串 + /// + /// + /// + /// + internal static string Format(this string str, params object[] args) + { + return args == null || args.Length == 0 ? str : string.Format(str, args); + } + + /// + /// 获取所有祖先类型 + /// + /// + /// + internal static IEnumerable GetAncestorTypes(this Type type) + { + var ancestorTypes = new List(); + while (type != null && type != typeof(object)) + { + if (IsNoObjectBaseType(type)) + { + var baseType = type.BaseType; + ancestorTypes.Add(baseType); + type = baseType; + } + else break; + } + + return ancestorTypes; + + static bool IsNoObjectBaseType(Type type) => type.BaseType != typeof(object); + } + + /// + /// 查找方法指定特性,如果没找到则继续查找声明类 + /// + /// + /// + /// + /// + public static TAttribute GetFoundAttribute(this MethodInfo method, bool inherit) + where TAttribute : Attribute + { + // 获取方法所在类型 + var declaringType = method.DeclaringType; + + var attributeType = typeof(TAttribute); + + // 判断方法是否定义指定特性,如果没有再查找声明类 + var foundAttribute = method.IsDefined(attributeType, inherit) + ? method.GetCustomAttribute(inherit) + : ( + declaringType.IsDefined(attributeType, inherit) + ? declaringType.GetCustomAttribute(inherit) + : default + ); + + return foundAttribute; + } + + + /// + /// 获取方法真实返回类型 + /// + /// + /// + internal static Type GetRealReturnType(this MethodInfo method) + { + // 判断是否是异步方法 + var isAsyncMethod = method.IsAsync(); + + // 获取类型返回值并处理 Task 和 Task 类型返回值 + var returnType = method.ReturnType; + return isAsyncMethod ? (returnType.GenericTypeArguments.FirstOrDefault() ?? typeof(void)) : returnType; + } + + /// + /// 获取类型自定义特性 + /// + /// 特性类型 + /// 类类型 + /// 是否继承查找 + /// 特性对象 + internal static TAttribute GetTypeAttribute(this Type type, bool inherit = false) + where TAttribute : Attribute + { + // 空检查 + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + // 检查特性并获取特性对象 + return type.IsDefined(typeof(TAttribute), inherit) + ? type.GetCustomAttribute(inherit) + : default; + } + + /// + /// 判断是否是匿名类型 + /// + /// 对象 + /// + internal static bool IsAnonymous(this object obj) + { + var type = obj is Type t ? t : obj.GetType(); + + return Attribute.IsDefined(type, typeof(CompilerGeneratedAttribute), false) + && type.IsGenericType && type.Name.Contains("AnonymousType") + && (type.Name.StartsWith("<>") || type.Name.StartsWith("VB$")) + && type.Attributes.HasFlag(TypeAttributes.NotPublic); + } + + /// + /// 判断方法是否是异步 + /// + /// 方法 + /// + internal static bool IsAsync(this MethodInfo method) + { + return method.GetCustomAttribute() != null + || method.ReturnType.ToString().StartsWith(typeof(Task).FullName); + } + + /// + /// 判断集合是否为空 + /// + /// 元素类型 + /// 集合对象 + /// 实例,true 表示空集合,false 表示非空集合 + internal static bool IsEmpty(this IEnumerable collection) + { + return collection == null || !collection.Any(); + } + + /// + /// 判断是否是富基元类型 + /// + /// 类型 + /// + public static bool IsRichPrimitive(this Type? type) + { + if (type == null) return false; + + // 处理元组类型 + if (type.IsValueTuple()) return false; + + // 处理数组类型,基元数组类型也可以是基元类型 + if (type.IsArray) return type.GetElementType()?.IsRichPrimitive() ?? false; + + // 基元类型或值类型或字符串类型 + if (type.IsPrimitive || type.IsValueType || type == typeof(string)) return true; + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) return type.GenericTypeArguments[0].IsRichPrimitive(); + + return false; + } + + /// + /// 判断是否是元组类型 + /// + /// 类型 + /// + internal static bool IsValueTuple(this Type type) + { + return type.Namespace == "System" && type.Name.Contains("ValueTuple`"); + } + + /// + /// 切割骆驼命名式字符串 + /// + /// + /// + internal static string[] SplitCamelCase(this string str) + { + if (str == null) return Array.Empty(); + + if (string.IsNullOrWhiteSpace(str)) return [str]; + if (str.Length == 1) return [str]; + + return Regex.Split(str, @"(?=\p{Lu}\p{Ll})|(?<=\p{Ll})(?=\p{Lu})") + .Where(u => u.Length > 0) + .ToArray(); + } + + /// + /// 首字母小写 + /// + /// + /// + internal static string ToLowerCamelCase(this string str) + { + if (string.IsNullOrWhiteSpace(str)) return str; + + return string.Concat(str.First().ToString().ToLower(), str.AsSpan(1)); + } + + /// + /// JsonElement 转 Object + /// + /// + /// + internal static object ToObject(this JsonElement jsonElement) + { + switch (jsonElement.ValueKind) + { + case JsonValueKind.String: + return jsonElement.GetString(); + + case JsonValueKind.Undefined: + case JsonValueKind.Null: + return default; + + case JsonValueKind.Number: + return jsonElement.GetDecimal(); + + case JsonValueKind.True: + case JsonValueKind.False: + return jsonElement.GetBoolean(); + + case JsonValueKind.Object: + var enumerateObject = jsonElement.EnumerateObject(); + var dic = new Dictionary(); + foreach (var item in enumerateObject) + { + dic.Add(item.Name, item.Value.ToObject()); + } + return dic; + + case JsonValueKind.Array: + var enumerateArray = jsonElement.EnumerateArray(); + var list = new List(); + foreach (var item in enumerateArray) + { + list.Add(item.ToObject()); + } + return list; + + default: + return default; + } + } + + /// + /// 首字母大写 + /// + /// + /// + internal static string ToUpperCamelCase(this string str) + { + if (string.IsNullOrWhiteSpace(str)) return str; + + return string.Concat(str.First().ToString().ToUpper(), str.AsSpan(1)); + } +} diff --git a/src/Admin/ThingsGateway.Razor/Extensions/ParallelExtensions.cs b/src/Admin/ThingsGateway.Razor/Extensions/ParallelExtensions.cs new file mode 100644 index 000000000..13146e58b --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Extensions/ParallelExtensions.cs @@ -0,0 +1,92 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway; + +/// +/// 提供对 IEnumerable 的并行操作扩展方法 +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class ParallelExtensions +{ + /// + /// 使用默认的并行设置执行指定的操作 + /// + /// 集合元素类型 + /// 要操作的集合 + /// 要执行的操作 + public static void ParallelForEach(this IEnumerable source, Action body) + { + // 创建并行操作的选项对象,设置最大并行度为当前处理器数量的一半 + ParallelOptions options = new(); + options.MaxDegreeOfParallelism = Environment.ProcessorCount / 2 == 0 ? 1 : Environment.ProcessorCount / 2; + // 使用 Parallel.ForEach 执行指定的操作 + Parallel.ForEach(source, options, (variable) => + { + body(variable); + }); + } + + /// + /// 使用默认的并行设置执行指定的操作 + /// + /// 集合元素类型 + /// 要操作的集合 + /// 要执行的操作 + public static void ParallelForEach(this IEnumerable source, Action body) + { + // 创建并行操作的选项对象,设置最大并行度为当前处理器数量的一半 + ParallelOptions options = new(); + options.MaxDegreeOfParallelism = Environment.ProcessorCount / 2 == 0 ? 1 : Environment.ProcessorCount / 2; + // 使用 Parallel.ForEach 执行指定的操作 + Parallel.ForEach(source, options, (variable, state, index) => + { + body(variable, state, index); + }); + } + + /// + /// 执行指定的操作,并指定最大并行度 + /// + /// 集合元素类型 + /// 要操作的集合 + /// 要执行的操作 + /// 最大并行度 + public static void ParallelForEach(this IEnumerable source, Action body, int parallelCount) + { + // 创建并行操作的选项对象,设置最大并行度为指定的值 + var options = new ParallelOptions(); + options.MaxDegreeOfParallelism = parallelCount / 2 == 0 ? 1 : parallelCount; + // 使用 Parallel.ForEach 执行指定的操作 + Parallel.ForEach(source, options, variable => + { + body(variable); + }); + } + + /// + /// 异步执行指定的操作,并指定最大并行度和取消标志 + /// + /// 集合元素类型 + /// 要操作的集合 + /// 异步执行的操作 + /// 最大并行度 + /// 取消操作的标志 + /// 表示异步操作的任务 + public static Task ParallelForEachAsync(this IEnumerable source, Func body, int parallelCount, CancellationToken cancellationToken = default) + { + // 创建并行操作的选项对象,设置最大并行度和取消标志 + var options = new ParallelOptions(); + options.CancellationToken = cancellationToken; + options.MaxDegreeOfParallelism = parallelCount / 2 == 0 ? 1 : parallelCount; + // 使用 Parallel.ForEachAsync 异步执行指定的操作,并返回表示异步操作的任务 + return Parallel.ForEachAsync(source, options, body); + } +} diff --git a/src/Admin/ThingsGateway.Razor/Extensions/StringExtensions.cs b/src/Admin/ThingsGateway.Razor/Extensions/StringExtensions.cs new file mode 100644 index 000000000..1dbe2af7c --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Extensions/StringExtensions.cs @@ -0,0 +1,197 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Text.RegularExpressions; + +namespace ThingsGateway.Extension; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class StringExtensions +{ + /// + /// 首字母小写 + /// + /// + /// + public static string FirstCharToLower(this string input) + { + if (string.IsNullOrEmpty(input)) + { + return input; + } + return $"{input.First().ToString().ToLower()}{input.Substring(1)}"; + } + + /// + /// 返回字符串首字符的大写字母 + /// + /// + /// + public static string FirstCharToUpper(this string input) => string.IsNullOrEmpty(input) ? input : input.First().ToString().ToUpper(); + + /// + public static string Format(this string str, params object[] args) + { + return args == null || args.Length == 0 ? str : string.Format(str, args); + } + + /// + /// 获取字符串中的两个字符作为名称简述 + /// + public static string GetNameLen2(this string name, int len = 2) + { + if (string.IsNullOrEmpty(name)) + return string.Empty; + var nameLength = name.Length;//获取姓名长度 + string nameWritten = name;//需要绘制的文字 + if (nameLength > len)//如果名字长度超过2个 + { + // 如果用户输入的姓名大于等于3个字符,截取后面两位 + string firstName = name.Substring(0, 1); + if (IsChinese(firstName)) + { + // 截取倒数两位汉字 + nameWritten = name.Substring(name.Length - len); + } + else + { + // 截取第一个英文字母和第二个大写的字母 + var data = Regex.Match(name, @"[A-Z]?[a-z]+([A-Z])").Value; + nameWritten = data.FirstCharToUpper() + data.LastCharToUpper(); + if (string.IsNullOrEmpty(nameWritten)) + { + nameWritten = name.FirstCharToUpper() + name.LastCharToUpper(); + } + } + } + + return nameWritten; + } + + /// + /// 返回字符串尾字符的大写字母 + /// + /// + /// + public static string LastCharToUpper(this string input) => string.IsNullOrEmpty(input) ? input : input.Last().ToString().ToUpper(); + + /// + /// 匹配邮箱格式 + /// + /// + /// + public static bool MatchEmail(this string s) + { + if (string.IsNullOrEmpty(s) || s.Length < 7) + { + return false; + } + Match match = Regex.Match(s, "[^@ \\t\\r\\n]+@[^@ \\t\\r\\n]+\\.[^@ \\t\\r\\n]+"); + bool flag = match.Success; + + return flag; + } + + /// + /// 匹配手机号码 + /// + /// 源字符串 + /// 是否匹配成功 + public static bool MatchPhoneNumber(this string s) => !string.IsNullOrEmpty(s) && Regex.IsMatch(s, @"^1[3456789][0-9]{9}$"); + + /// + /// 首字母小写 + /// + /// + /// + public static string ToLowerCamelCase(this string str) + { + if (string.IsNullOrWhiteSpace(str)) return str; + + return string.Concat(str.First().ToString().ToLower(), str.AsSpan(1)); + } + + /// + /// 首字母大写 + /// + /// + /// + public static string ToUpperCamelCase(this string str) + { + if (string.IsNullOrWhiteSpace(str)) return str; + + return string.Concat(str.First().ToString().ToUpper(), str.AsSpan(1)); + } + + /// + /// 清除字符串前后缀 + /// + /// 字符串 + /// 0:前后缀,1:后缀,-1:前缀 + /// 前后缀集合 + /// + internal static string ClearStringAffixes(this string str, int pos = 0, params string[] affixes) + { + // 空字符串直接返回 + if (string.IsNullOrWhiteSpace(str)) return str; + + // 空前后缀集合直接返回 + if (affixes == null || affixes.Length == 0) return str; + + var startCleared = false; + var endCleared = false; + + string? tempStr = null; + foreach (var affix in affixes) + { + if (string.IsNullOrWhiteSpace(affix)) continue; + + if (pos != 1 && !startCleared && str.StartsWith(affix, StringComparison.OrdinalIgnoreCase)) + { + tempStr = str[affix.Length..]; + startCleared = true; + } + if (pos != -1 && !endCleared && str.EndsWith(affix, StringComparison.OrdinalIgnoreCase)) + { + var _tempStr = !string.IsNullOrWhiteSpace(tempStr) ? tempStr : str; + tempStr = _tempStr[..^affix.Length]; + endCleared = true; + } + if (startCleared && endCleared) break; + } + + return !string.IsNullOrWhiteSpace(tempStr) ? tempStr : str; + } + + /// + /// 切割骆驼命名式字符串 + /// + /// + /// + internal static string[] SplitCamelCase(this string str) + { + if (str == null) return Array.Empty(); + + if (string.IsNullOrWhiteSpace(str)) return [str]; + if (str.Length == 1) return [str]; + + return Regex.Split(str, @"(?=\p{Lu}\p{Ll})|(?<=\p{Ll})(?=\p{Lu})") + .Where(u => u.Length > 0) + .ToArray(); + } + + /// + /// 用 正则表达式 判断字符是不是汉字 + /// + /// 待判断字符或字符串 + /// 真:是汉字;假:不是 + private static bool IsChinese(string text) => Regex.IsMatch(text, @"[\u4e00-\u9fbb]"); +} diff --git a/src/Admin/ThingsGateway.Razor/Extensions/ToastServiceExtensions.cs b/src/Admin/ThingsGateway.Razor/Extensions/ToastServiceExtensions.cs new file mode 100644 index 000000000..a02a93590 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Extensions/ToastServiceExtensions.cs @@ -0,0 +1,36 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.Hosting; + +namespace ThingsGateway.Razor; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public static class ToastServiceExtensions +{ + /// + public static Task Default(this ToastService toastService, bool success = true) + { + if (success) + return toastService.Success("Success"); + else + return toastService.Warning("Fail"); + } + + /// + public static Task Warn(this ToastService toastService, Exception ex) + { + if (App.HostEnvironment.IsDevelopment()) + return toastService.Warning("Warn", ex.ToString()); + else + return toastService.Warning("Warn", ex.Message); + } +} diff --git a/src/Admin/ThingsGateway.Razor/Locales/en-US.json b/src/Admin/ThingsGateway.Razor/Locales/en-US.json new file mode 100644 index 000000000..e49b503e8 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Locales/en-US.json @@ -0,0 +1,73 @@ +{ + + "ThingsGateway.Razor.ImportExcel": { + "First": "Step 1", + "Upload": "Upload File", + "Validate": "Validate", + "Second": "Step 2", + "ValidateText": "Validation Content", + "UploadCount": " Table {0}, expecting to import {1} records", + "Third": "Step 3", + "Import": "Import", + "Next": "Next", + "Reset": "Reset", + "Tip": "When the data volume is large (more than 200,000), the import may take more than 1 minute, please be patient" + }, + "ThingsGateway.Razor._Imports": { + "Add": "Add", + "Remove": "Remove", + "BatchEdit": "BacthEdit", + "Edit": "Edit", + "Close": "Close", + "True": "Yes", + "False": "No", + "Save": "Save", + "Fail": "Fail {0}", + "Success": "Success", + "PleaseSelect": "Please select the data", + "TablesExportButtonExcelText": "Export Excel", + "TablesImportButtonExcelText": "Import Excel", + "Choice": "Select", + "Pause": "Pause", + "Play": "Play", + "Enable": "Enable", + "Disabled": "Disabled", + "Export": "Export", + "Delete": "Delete", + "ExportAll": "ExportAll" + }, + "ThingsGateway.Razor.GlobalSearch": { + "SearchText": "Search Page" + }, + "ThingsGateway.Razor.MenuIconList": { + "ChoiceIcon": "Icon for Selection" + }, + "ThingsGateway.Razor.CommitItem": { + "CommitCount": "Total {0} commits", + "Author": "Author??", + "Branch": "Branch name??", + "Message": "Commit message??" + }, + "ThingsGateway.Razor.About": { + "Docs": "Docs", + "Community": "Community", + "QQGroup": "QQGroup" + }, + "ThingsGateway.Razor.BlazorReconnector": { + "Reconnecting1": "Reconnecting...", + "Reconnecting2": "Attempting to reconnect to the server", + "Reconnecting3": "The server is updating to a new version, please wait for a moment to provide service, or press F12 to open Developer tools and check the Console for error output, please contact the administrator", + "ReconnectFailed1": "Reconnecting...", + "ReconnectFailed2": "Failed to connect to the server", + "ReconnectFailed3": "Please check if the network is normal, or press F12 to open Developer tools and check the Console for error output, please contact the administrator", + "ReconnectFailed4": "Reconnect", + "ReconnectFailed5": "Reload", + "ReconnectRejected1": "Reconnecting...", + "ReconnectRejected2": "Server rejected the connection", + "ReconnectRejected3": "All connection attempts have been rejected, this is likely due to network or server issues, please contact the administrator", + "ReconnectRejected4": "Reload", + "RenderThingsGateway1": "ThingsGateway Edge Collection Gateway", + "RenderThingsGateway2": "Data aggregation, multi-path forwarding", + "RenderThingsGateway3": "Edge computing, efficient processing" + } +} diff --git a/src/Admin/ThingsGateway.Razor/Locales/zh-CN.json b/src/Admin/ThingsGateway.Razor/Locales/zh-CN.json new file mode 100644 index 000000000..36483fe16 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Locales/zh-CN.json @@ -0,0 +1,73 @@ +{ + + "ThingsGateway.Razor.ImportExcel": { + "First": "第一步", + "Upload": "上传文件", + "Validate": "验证", + "Second": "第二步", + "ValidateText": "验证内容", + "UploadCount": " 表 {0},预计导入 {1} 条数据", + "Third": "第三", + "Import": "导入", + "Next": "下一步", + "Reset": "重置", + "Tip": "数据量较大时(大于20万),所需导入时间可能超过1分钟,请耐心等待" + }, + "ThingsGateway.Razor._Imports": { + "Add": "添加", + "Remove": "移除", + "BatchEdit": "批量编辑", + "Edit": "编辑", + "Close": "关闭", + "True": "是", + "False": "否", + "Save": "保存", + "Fail": "失败 {0}", + "Success": "成功", + "PleaseSelect": "请选择需要操作的数据", + "TablesExportButtonExcelText": "导出Excel", + "TablesImportButtonExcelText": "导入Excel", + "Choice": "选择", + "Pause": "暂停", + "Play": "继续", + "Enable": "启用", + "Disabled": "停用", + "Export": "导出", + "ExportAll": "导出全部", + "Delete": "删除" + }, + "ThingsGateway.Razor.GlobalSearch": { + "SearchText": "搜索页面" + }, + "ThingsGateway.Razor.MenuIconList": { + "ChoiceIcon": "选择图标" + }, + "ThingsGateway.Razor.CommitItem": { + "CommitCount": "共 {0} 个提交", + "Author": "提交作者:", + "Branch": "分支名称:", + "Message": "提交信息:" + }, + "ThingsGateway.Razor.About": { + "Docs": "文档", + "Community": "社区", + "QQGroup": "QQ群" + }, + "ThingsGateway.Razor.BlazorReconnector": { + "Reconnecting1": "重连中...", + "Reconnecting2": "正在尝试重新连接服务器", + "Reconnecting3": "服务器正在更新新版本,稍等一会儿即可提供服务,或者 F12 打开 Developer tools 查看 控制台 是否有错误输出,请与管理员联系", + "ReconnectFailed1": "重连中...", + "ReconnectFailed2": "与服务器连接失败", + "ReconnectFailed3": "请确认网络是否正常,或者 F12 打开 Developer tools 查看 控制台 是否有错误输出,请与管理员联系", + "ReconnectFailed4": "重新连接", + "ReconnectFailed5": "重新加载", + "ReconnectRejected1": "重连中...", + "ReconnectRejected2": "服务器拒绝连接", + "ReconnectRejected3": "所有的连接尝试都被拒绝了,这很有可能是由于网络问题或者服务器问题引起的,请与管理员联系", + "ReconnectRejected4": "重新加载", + "RenderThingsGateway1": "ThingsGateway 边缘采集网关", + "RenderThingsGateway2": "数据集中,多路转发", + "RenderThingsGateway3": "边缘计算,高效处理" + } +} diff --git a/src/Admin/ThingsGateway.Razor/Options/MenuOptions.cs b/src/Admin/ThingsGateway.Razor/Options/MenuOptions.cs new file mode 100644 index 000000000..feaa7a706 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Options/MenuOptions.cs @@ -0,0 +1,21 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Razor; + +/// +/// 菜单配置 +/// +public class MenuOptions : IConfigurableOptions +{ + public List MenuItems { get; set; } = new List(); +} diff --git a/src/Admin/ThingsGateway.Razor/Options/WebsiteOptions.cs b/src/Admin/ThingsGateway.Razor/Options/WebsiteOptions.cs new file mode 100644 index 000000000..acf8c4fed --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Options/WebsiteOptions.cs @@ -0,0 +1,61 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Razor; + +/// +/// 网站配置 +/// +public class WebsiteOptions : IConfigurableOptions +{ + /// + /// Copyright + /// + public string Copyright { get; set; } = "版权所有 © 2023-present Diego"; + + /// + /// 是否Demo + /// + public bool Demo { get; set; } + + /// + /// 是否显示关于页面 + /// + public bool IsShowAbout { get; set; } = true; + + /// + /// QQ群链接地址 + /// + public string? QQGroup1Link { get; set; } = "http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=NnBjPO-8kcNFzo_RzSbdICflb97u2O1i&authKey=V1MI3iJtpDMHc08myszP262kDykbx2Yev6ebE4Me0elTe0P0IFAmtU5l7Sy5w0jx&noverify=0&group_code=605534569"; + + /// + /// QQ群链接地址 + /// + public string? QQGroup1Number { get; set; } = "605534569"; + + /// + /// 开源地址 + /// + public string SourceUrl { get; set; } = "https://gitee.com/diego2098/ThingsGateway"; + + /// + /// 标题 + /// + public string? Title { get; set; } = "ThingsGateway"; + + /// + /// 文档地址 + /// + public string WikiUrl { get; set; } = "https://thingsgateway.cn/"; + + +} diff --git a/src/Admin/ThingsGateway.Razor/Services/DefaultMenuService.cs b/src/Admin/ThingsGateway.Razor/Services/DefaultMenuService.cs new file mode 100644 index 000000000..a81262338 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Services/DefaultMenuService.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Razor; + +public class DefaultMenuService : IMenuService +{ + private MenuOptions MenuOptions; + public DefaultMenuService(IOptions options) + { + MenuOptions = options?.Value ?? new(); + } + public IEnumerable? MenuItems => MenuOptions.MenuItems; + public IEnumerable? AllOwnMenuItems => MenuOptions.MenuItems; + + public IEnumerable? SameLevelMenuItems => GetSameLevelMenuItems(MenuOptions.MenuItems).Where(a => !a.Url.IsNullOrWhiteSpace()); + + private static IEnumerable GetSameLevelMenuItems(IEnumerable items) => items.Concat(items.SelectMany(i => i.Items.Any() ? GetSameLevelMenuItems(i.Items) : i.Items)); +} diff --git a/src/Admin/ThingsGateway.Razor/Services/IAppVersionService.cs b/src/Admin/ThingsGateway.Razor/Services/IAppVersionService.cs new file mode 100644 index 000000000..aaddcde0d --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Services/IAppVersionService.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +/// 版本服务 +/// +public interface IAppVersionService +{ + /// + /// 当前版本 + /// + public string? Version { get; } +} diff --git a/src/Admin/ThingsGateway.Razor/Services/IMenuService.cs b/src/Admin/ThingsGateway.Razor/Services/IMenuService.cs new file mode 100644 index 000000000..023c5323e --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Services/IMenuService.cs @@ -0,0 +1,21 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +public interface IMenuService +{ + /// + public IEnumerable? MenuItems { get; } + public IEnumerable? AllOwnMenuItems { get; } + /// + public IEnumerable? SameLevelMenuItems { get; } +} diff --git a/src/Admin/ThingsGateway.Razor/Services/VersionService.cs b/src/Admin/ThingsGateway.Razor/Services/VersionService.cs new file mode 100644 index 000000000..ac29e7b03 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Services/VersionService.cs @@ -0,0 +1,32 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Razor; + +public class VersionService : IAppVersionService +{ + public VersionService() + { + Version = ((AssemblyInformationalVersionAttribute?)Assembly.GetEntryAssembly().GetCustomAttributes(typeof(AssemblyInformationalVersionAttribute), false).FirstOrDefault())?.InformationalVersion; + Version ??= Assembly.GetEntryAssembly().GetName().Version?.ToString(); + if (!string.IsNullOrEmpty(Version)) + { + var index = Version.IndexOf('+'); + if (index > 0) + { + Version = Version[..index]; + } + } + } + + public string? Version { get; } +} diff --git a/src/Admin/ThingsGateway.Razor/Startup.cs b/src/Admin/ThingsGateway.Razor/Startup.cs new file mode 100644 index 000000000..107178be8 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Startup.cs @@ -0,0 +1,54 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.Extensions.DependencyInjection; + +using ThingsGateway.NewLife; +using ThingsGateway.NewLife.Caching; + +using Yitter.IdGenerator; + +namespace ThingsGateway.Razor; + +/// +[AppStartup(1000000000)] +public class Startup : AppStartup +{ + /// + public void ConfigureApp(IServiceCollection services) + { + services.AddBootstrapBlazor( + option => option.JSModuleVersion = Random.Shared.Next(10000).ToString() + ); + services.AddConfigurableOptions(); + services.ConfigureIconThemeOptions(options => options.ThemeKey = "fa"); + services.AddSingleton(); + services.AddScoped(); + + services.AddConfigurableOptions(); + + // 缓存 + services.AddSingleton(); + + MachineInfo.RegisterAsync(); + + // 配置雪花Id算法机器码 + YitIdHelper.SetIdGenerator(new IdGeneratorOptions + { + WorkerId = 1// 取值范围0~63 + }); + + } + + /// + public void UseService(IServiceProvider serviceProvider) + { + } +} diff --git a/src/Admin/ThingsGateway.Razor/ThingsGateway.Razor.csproj b/src/Admin/ThingsGateway.Razor/ThingsGateway.Razor.csproj new file mode 100644 index 000000000..4f131f58b --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/ThingsGateway.Razor.csproj @@ -0,0 +1,36 @@ + + + + + + net8.0; + + + + + + + + + + + Never + + + + + + + + + + + + + + + + + + + diff --git a/src/Admin/ThingsGateway.Razor/UnifyResult/UnifyResult.cs b/src/Admin/ThingsGateway.Razor/UnifyResult/UnifyResult.cs new file mode 100644 index 000000000..0d6e674ec --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/UnifyResult/UnifyResult.cs @@ -0,0 +1,38 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Razor; + +/// +/// 全局返回结果 +/// +/// +public class UnifyResult +{ + /// + /// 状态码 + /// + public int Code { get; set; } + + /// + /// 数据 + /// + public T? Data { get; set; } + + /// + /// 错误信息 + /// + public object? Msg { get; set; } + + /// + /// 时间 + /// + public DateTime Time { get; set; } +} diff --git a/src/Admin/ThingsGateway.Razor/Util/LocalizerUtil.cs b/src/Admin/ThingsGateway.Razor/Util/LocalizerUtil.cs new file mode 100644 index 000000000..d4ff30c15 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/Util/LocalizerUtil.cs @@ -0,0 +1,74 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Reflection; + +namespace ThingsGateway.Razor; + +/// +[ThingsGateway.DependencyInjection.SuppressSniffer] +public class LocalizerUtil +{ + #region 是否启用 + + public static List GetBoolItems(Type modelType, string fieldName, bool canNull = false) + { + var cacheKey = $"{nameof(GetBoolItems)}-{CultureInfo.CurrentUICulture.Name}-{modelType.FullName}-{modelType.TypeHandle.Value}-{fieldName}"; + return App.CacheService.GetOrAdd(cacheKey, entry => + { + var items = new List(); + var localizer = modelType.Assembly.IsDynamic ? null : App.CreateLocalizerByType(modelType); + IStringLocalizer? localizerAttr = null; + if (canNull) + { + items.Add(new SelectedItem("", FindDisplayText(nameof(NullableBoolItemsAttribute.NullValueDisplayText), attr => attr.FalseValueDisplayText))); + } + + items.Add(new SelectedItem("True", FindDisplayText(nameof(NullableBoolItemsAttribute.TrueValueDisplayText), attr => attr.TrueValueDisplayText))); + items.Add(new SelectedItem("False", FindDisplayText(nameof(NullableBoolItemsAttribute.FalseValueDisplayText), attr => attr.FalseValueDisplayText))); + + return items; + + string FindDisplayText(string itemName, Func callback) + { + string? dn = null; + + // 优先读取资源文件配置 + var stringLocalizer = localizer?[$"{fieldName}-{itemName}"]; + if (stringLocalizer is { ResourceNotFound: false }) + { + dn = stringLocalizer.Value; + } + else if (Utility.TryGetProperty(modelType, fieldName, out var propertyInfo)) + { + // 类资源文件未找到 回落查找标签 + var attr = propertyInfo.GetCustomAttribute(true); + if (attr != null && !modelType.Assembly.IsDynamic) + { + dn = callback(attr); + } + } + + // 回落读取 NullableBoolItemsAttribute 资源文件配置 + return dn ?? FindDisplayTextByItemName(itemName); + } + + string FindDisplayTextByItemName(string itemName) + { + localizerAttr ??= App.CreateLocalizerByType(typeof(NullableBoolItemsAttribute)); + var stringLocalizer = localizerAttr![itemName]; + return stringLocalizer.Value; + } + }); + } + + #endregion + +} diff --git a/src/Admin/ThingsGateway.Razor/_Imports.razor b/src/Admin/ThingsGateway.Razor/_Imports.razor new file mode 100644 index 000000000..277c4e5f7 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/_Imports.razor @@ -0,0 +1,19 @@ + +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.Extensions.Localization + +@using System.ComponentModel +@using System.ComponentModel.DataAnnotations + +@using BootstrapBlazor.Components + + +@using ThingsGateway.Razor; diff --git a/src/Admin/ThingsGateway.Razor/_Imports.razor.cs b/src/Admin/ThingsGateway.Razor/_Imports.razor.cs new file mode 100644 index 000000000..0c81ab8ba --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/_Imports.razor.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + + +global using BootstrapBlazor.Components; + +global using Microsoft.AspNetCore.Components; +global using Microsoft.Extensions.Localization; +global using Microsoft.Extensions.Options; + +global using System.Diagnostics.CodeAnalysis; +global using System.Globalization; + + + +[assembly: SuppressMessage("Reliability", "CA2007", Justification = "<挂起>", Scope = "module")] diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/css/site.css b/src/Admin/ThingsGateway.Razor/wwwroot/css/site.css new file mode 100644 index 000000000..6467d1d3f --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/css/site.css @@ -0,0 +1,214 @@ +/*覆盖样式*/ +:root { + --bs-nav-link-font-size: 1rem; + --bs-primary: var(--bs-blue); + --bs-primary-bg1: rgba(var(--bs-blue-rgb),0.1); + --bs-primary-bg7: rgba(var(--bs-blue-rgb),0.7); + --bs-nav-link-font-size: 0.8rem; + --bb-layout-header-height: 44px; + --bb-layout-headerbar-background: transparent; + --bs-navbar-color: var(--bb-layout-header-color); + --bs-navbar-hover-color: var(--bs-primary); + --bb-layout-header-background: var(--tg-nav-bg); + --bb-layout-sidebar-background: var(--tg-nav-bg); + --bb-layout-footer-background: var(--tg-nav-bg); + --bb-layout-sidebar-banner-background: var(--tg-nav-bg); + --bb-layout-banner-font-size: 1.2rem; + --bb-layout-banner-logo-width: 36px; + --bb-layout-banner-logo-height: 36px; + --line-chart-height: 350px; + --bb-layout-body-height: calc(100vh - var(--bs-header-height) - var(--bb-layout-header-height) - 20px); + --line-chart-table-height: calc(100% - var(--line-chart-height) - 20px); + --table-height: calc(100vh - var(--bs-header-height) - var(--bb-layout-header-height) - 40px); + --bs-header-height: 30px; +} +.line-chart-table-height { + height: var(--line-chart-table-height); +} + +.dialog-table { + height: calc(100vh - 200px); +} + +.h-100 > .tabs { + height: 100%; +} +.min-height-500{ + min-height:500px; +} + +:root, [data-bs-theme='dark'] { + --tg-nav-bg: var(--bs-body-bg); +} + +:root, [data-bs-theme='light'] { + --tg-nav-bg: var(--bs-body-bg); +} + +.bs-tooltip-auto .tooltip-inner { + text-align: start !important; + white-space: pre-line !important; +} + +[data-bs-theme="dark"] .fil a { + background-color: rgba(0,0,0,0.3); +} + +.tabs-body-content { + overflow-y: auto; + height:100%; +} +.card-body{ + height:100%; +} +.green--text { + color: #4CAF50 +} +.g-3 { + --bs-gutter-y: 0.5rem; + --bs-gutter-x: 0.5rem; +} +.card { + --bs-card-spacer-y: 0.5rem; + --bs-card-spacer-x: 0.8rem; +} +.red--text { + color: #F44336 +} +.disabled--text { + color: var(--bs-gray); +} + +.enable--text { + color: unset; +} +.border-primary { + --bs-border-opacity: 1; + /* border-color: rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important; */ + border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; +} + + +/*.card-header, [data-bs-theme="light"] { + --bs-card-cap-bg: #343a40; + --bs-card-cap-color: #f0f3fe; +}*/ + +.dropdown-menu { + --bs-dropdown-link-active-bg: var(--bs-blue) !important; +} + + +.tgTree .dropdown-item:has(i.device) { + margin-top: 0.5rem; + border-top: 1px solid var(--bs-dropdown-link-color); +} + + +/*tab*/ +.toast-body { + white-space: pre-line; +} + +/*#*/ +.menu { + --bb-menu-item-hover-bg: var(--bs-primary-bg1) !important; + --bb-menu-item-hover-color: var(--bs-primary) !important; +} + + + +/*顶栏文字样式*/ +.navbar-header-text { + color: var(--bs-navbar-color); +} + +@media (min-width: 768px) { + :root { + --bs-header-height: 30px; + } + + header.hide { + transform: translate3d(0,calc(-100% - 2px),0); + } +} + +:root, [data-bs-theme="light"] { + --bs-blue: #2196F3; + --bs-blue-rgb: 33, 150, 243; + --bs-link-color: #0096F3; + --tabs-body-bg: #f0f3fe; +} + +[data-bs-theme="dark"] { + --bs-blue: #2196F3; + --bs-blue-rgb: 33, 150, 243; + --bs-link-color: #0096F3; + --tabs-body-bg: #272727; + --bb-shadow: 0 0 8px 0 rgba(42,43,46,.3),0 2px 4px 0 rgba(42,43,46,.1); + --bb-hover-shadow: 0 1px 7px 0 rgba(232,233,238,.0509803922),0 2px 8px 0 rgba(232,233,238,.0705882353),0 3px 9px 0 rgba(232,233,238,.0588235294),0 5px 10px 0 rgba(232,233,238,.031372549); +} + +/*重连样式*/ +.connection-mask { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #000; + opacity: 0.5; +} + +.connection-body { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background-color: var(--bs-body-bg); + padding: 1rem; + color: var(--bs-body-color); +} + +[data-bs-theme='dark'] .connection-body { + background-color: #222939; +} + +.connection-body-tail { + border-right: 1px solid var(--bs-border-color); + width: 1px; + margin-left: 1rem; +} + +.connection-link { + color: #1371C3; + text-decoration: underline; + font-weight: bolder; + margin-left: 0.5rem; +} + +.connection-box { + border-radius: var(--bs-border-radius); + border: var(--bs-border-width) solid var(--bs-border-color); + margin-bottom: 1rem; + position: relative; + overflow: hidden; + min-height: 300px; +} + + .connection-box .connection-mask, + .connection-box .connection-body { + position: absolute; + } + +.connection-body img { + height: 110px; + margin-left: 1rem; +} + +@supports not selector(::-webkit-scrollbar) { + .scroll { + scrollbar-color: rgba(0,0,0,0.3) rgba(0,0,0,0); + scrollbar-width: thin; + } +} diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/favicon.ico b/src/Admin/ThingsGateway.Razor/wwwroot/favicon.ico new file mode 100644 index 000000000..d5a4939b2 Binary files /dev/null and b/src/Admin/ThingsGateway.Razor/wwwroot/favicon.ico differ diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/favicon.png b/src/Admin/ThingsGateway.Razor/wwwroot/favicon.png new file mode 100644 index 000000000..e88c7d32f Binary files /dev/null and b/src/Admin/ThingsGateway.Razor/wwwroot/favicon.png differ diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/401.png b/src/Admin/ThingsGateway.Razor/wwwroot/images/401.png new file mode 100644 index 000000000..69baab81e Binary files /dev/null and b/src/Admin/ThingsGateway.Razor/wwwroot/images/401.png differ diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/404.png b/src/Admin/ThingsGateway.Razor/wwwroot/images/404.png new file mode 100644 index 000000000..39f2c595e Binary files /dev/null and b/src/Admin/ThingsGateway.Razor/wwwroot/images/404.png differ diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/500.png b/src/Admin/ThingsGateway.Razor/wwwroot/images/500.png new file mode 100644 index 000000000..fa65a8f63 Binary files /dev/null and b/src/Admin/ThingsGateway.Razor/wwwroot/images/500.png differ diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/defaultUser.svg b/src/Admin/ThingsGateway.Razor/wwwroot/images/defaultUser.svg new file mode 100644 index 000000000..ad3d429ed --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/images/defaultUser.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/empty.svg b/src/Admin/ThingsGateway.Razor/wwwroot/images/empty.svg new file mode 100644 index 000000000..ac3ecec2e --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/images/empty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/en.svg b/src/Admin/ThingsGateway.Razor/wwwroot/images/en.svg new file mode 100644 index 000000000..ceecb9fb1 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/images/en.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/gitee.png b/src/Admin/ThingsGateway.Razor/wwwroot/images/gitee.png new file mode 100644 index 000000000..19e59632d Binary files /dev/null and b/src/Admin/ThingsGateway.Razor/wwwroot/images/gitee.png differ diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/login-left.svg b/src/Admin/ThingsGateway.Razor/wwwroot/images/login-left.svg new file mode 100644 index 000000000..e25879788 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/images/login-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/quickactions.svg b/src/Admin/ThingsGateway.Razor/wwwroot/images/quickactions.svg new file mode 100644 index 000000000..a778fcc7d --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/images/quickactions.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/images/zh.svg b/src/Admin/ThingsGateway.Razor/wwwroot/images/zh.svg new file mode 100644 index 000000000..072bc665b --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/images/zh.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/js/culture.js b/src/Admin/ThingsGateway.Razor/wwwroot/js/culture.js new file mode 100644 index 000000000..0ea1693ef --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/js/culture.js @@ -0,0 +1,9 @@ +// 设置 culture +function setCultureLocalStorage(culture) { + localStorage.setItem("culture", culture); +} + +// 获取 culture +function getCultureLocalStorage() { + return localStorage.getItem("culture"); +} \ No newline at end of file diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/js/downloadFile.js b/src/Admin/ThingsGateway.Razor/wwwroot/js/downloadFile.js new file mode 100644 index 000000000..a525bb027 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/js/downloadFile.js @@ -0,0 +1,108 @@ +//下载文件 +export function blazor_downloadFile(url, fileName, dtoObject) { + const params = new URLSearchParams(); + + // 将 dtoObject 的属性添加到 URLSearchParams 中 + for (const key in dtoObject) { + if (dtoObject[key] !== null) { + params.append(key, dtoObject[key]); + } + } + + // 构建完整的 URL + const fullUrl = `${url}?${params.toString()}`; + + // 发起 fetch 请求 + fetch(fullUrl) + .then(response => { + // 获取响应头中的 content-disposition + const dispositionHeader = response.headers.get('content-disposition'); + let resolvedFileName = fileName; + + if (dispositionHeader) { + // 解析出文件名 + const match = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(dispositionHeader); + const serverFileName = match && match[1] ? match[1].replace(/['"]/g, '') : null; + if (serverFileName) { + resolvedFileName = serverFileName; + } + } + + // 将响应转换为 blob 对象 + return response.blob().then(blob => { + // 创建临时的文件 URL + const fileUrl = window.URL.createObjectURL(blob); + + // 创建一个 元素并设置下载链接和文件名 + const anchorElement = document.createElement('a'); + anchorElement.href = fileUrl; + anchorElement.download = resolvedFileName; + anchorElement.style.display = 'none'; + + // 将 元素添加到 body 中并触发下载 + document.body.appendChild(anchorElement); + anchorElement.click(); + document.body.removeChild(anchorElement); + + // 撤销临时的文件 URL + window.URL.revokeObjectURL(fileUrl); + }); + }) + .catch(error => { + console.error('DownFile error ', error); + }); +} + + + +//下载文件 +export function postJson_downloadFile(url, fileName, jsonBody) { + const params = new URLSearchParams(); + + + // 发起 fetch 请求 + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: jsonBody + }) + .then(response => { + // 获取响应头中的 content-disposition + const dispositionHeader = response.headers.get('content-disposition'); + let resolvedFileName = fileName; + + if (dispositionHeader) { + // 解析出文件名 + const match = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(dispositionHeader); + const serverFileName = match && match[1] ? match[1].replace(/['"]/g, '') : null; + if (serverFileName) { + resolvedFileName = serverFileName; + } + } + + // 将响应转换为 blob 对象 + return response.blob().then(blob => { + // 创建临时的文件 URL + const fileUrl = window.URL.createObjectURL(blob); + + // 创建一个 元素并设置下载链接和文件名 + const anchorElement = document.createElement('a'); + anchorElement.href = fileUrl; + anchorElement.download = resolvedFileName; + anchorElement.style.display = 'none'; + + // 将 元素添加到 body 中并触发下载 + document.body.appendChild(anchorElement); + anchorElement.click(); + document.body.removeChild(anchorElement); + + // 撤销临时的文件 URL + window.URL.revokeObjectURL(fileUrl); + }); + }) + .catch(error => { + console.error('downfile error ', error); + }); +} diff --git a/src/Admin/ThingsGateway.Razor/wwwroot/js/theme.js b/src/Admin/ThingsGateway.Razor/wwwroot/js/theme.js new file mode 100644 index 000000000..7e756c651 --- /dev/null +++ b/src/Admin/ThingsGateway.Razor/wwwroot/js/theme.js @@ -0,0 +1,3 @@ +import { getPreferredTheme, setTheme } from "/_content/BootstrapBlazor/modules/utility.js" + +setTheme(getPreferredTheme(), false) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 964a134c0..0c13c5ab7 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,10 +1,8 @@ - 7.2.0.64 - 7.2.3.9 - 9.0.3.10 - 9.0.3.6 + 10.0.0.17 + 10.0.0.17 @@ -22,9 +20,9 @@ None None - CS8603;CS8618;CS1591;CS8625;CS8602;CS8604;CS8600;CS8601;CS8714;CS8619;CS8629;CS8765;CS8634;CS8621;CS8767;CS8633;CS8620;CS8610;CS8631;CS8605;CS8622;CS8613;NU5100;NU5104;NU1903;NU1902; - net9.0;net8.0;net6.0; - 12.0 + CS8603;CS8618;CS1591;CS8625;CS8602;CS8604;CS8600;CS8601;CS8714;CS8619;CS8629;CS8765;CS8634;CS8621;CS8767;CS8633;CS8620;CS8610;CS8631;CS8605;CS8622;CS8613;NU5100;NU5104;NU1903;NU1902;CA1813; + net8.0; + 13.0 enable enable Diego diff --git a/src/Foundation.props b/src/Foundation.props index cea7de0d6..74b79aa32 100644 --- a/src/Foundation.props +++ b/src/Foundation.props @@ -1,13 +1,8 @@ - net462;netstandard2.0;net9.0;net8.0;net6.0; + net462;netstandard2.0;net6.0; - - - - Never - - + diff --git a/src/Foundation/ThingsGateway.CSScript/CSharpScriptEngineExtension.cs b/src/Foundation/ThingsGateway.CSScript/CSharpScriptEngineExtension.cs index a3b9f0b0e..f7a2b6d58 100644 --- a/src/Foundation/ThingsGateway.CSScript/CSharpScriptEngineExtension.cs +++ b/src/Foundation/ThingsGateway.CSScript/CSharpScriptEngineExtension.cs @@ -12,17 +12,15 @@ using CSScripting; using CSScriptLib; - using System.Reflection; using System.Text; using ThingsGateway.NewLife; using ThingsGateway.NewLife.Caching; -using ThingsGateway.NewLife.Threading; +using ThingsGateway.NewLife.Extension; namespace ThingsGateway.Gateway.Application; - /// /// 脚本扩展方法 /// @@ -32,43 +30,47 @@ public static class CSharpScriptEngineExtension private static object m_waiterLock = new object(); - /// 清理计时器 - private static TimerX? _clearTimer; static CSharpScriptEngineExtension() { - if (_clearTimer == null) + var temp = Environment.GetEnvironmentVariable("CSS_CUSTOM_TEMPDIR"); + if (temp.IsNullOrWhiteSpace()) { - _clearTimer = new TimerX(RemoveNotAlive, null, 30 * 1000, 60 * 1000) { Async = true }; + var tempDir = Path.Combine(AppContext.BaseDirectory, "CSSCRIPT"); + if (Directory.Exists(tempDir)) + { + try + { + Directory.Delete(tempDir); + } + catch + { + + } + } + + Directory.CreateDirectory(tempDir);//重新创建,防止缓存的一些目录信息错误 + Environment.SetEnvironmentVariable("CSS_CUSTOM_TEMPDIR", tempDir); //传入变量 } + Instance.KeyExpired += Instance_KeyExpired; } - private static void RemoveNotAlive(Object? state) + private static void Instance_KeyExpired(object sender, KeyEventArgs e) { - //检测缓存 try { - var data = Instance.GetAll(); - lock (m_waiterLock) + if (Instance.GetAll().TryGetValue(e.Key, out var item)) { - - foreach (var item in data) - { - if (item.Value!.ExpiredTime < item.Value.VisitTime + 1800_000) - { - Instance.Remove(item.Key); - item.Value?.Value?.TryDispose(); - item.Value?.Value?.GetType().Assembly.Unload(); - GC.Collect(); - } - } + item?.Value?.TryDispose(); + item?.Value?.GetType().Assembly.Unload(); + GC.Collect(); } } catch { } - } + private static MemoryCache Instance { get; set; } = new MemoryCache(); /// @@ -119,6 +121,7 @@ public static class CSharpScriptEngineExtension using ThingsGateway.Gateway.Application; using ThingsGateway.NewLife; using ThingsGateway.NewLife.Extension; + using ThingsGateway.NewLife.Json.Extension; using ThingsGateway.Gateway.Application.Extensions; {_using} {_body} diff --git a/src/Foundation/ThingsGateway.CSScript/ExpressionEvaluatorExtension.cs b/src/Foundation/ThingsGateway.CSScript/ExpressionEvaluatorExtension.cs index 505e9984c..eb36ef6e6 100644 --- a/src/Foundation/ThingsGateway.CSScript/ExpressionEvaluatorExtension.cs +++ b/src/Foundation/ThingsGateway.CSScript/ExpressionEvaluatorExtension.cs @@ -16,7 +16,6 @@ using System.Text; using ThingsGateway.NewLife; using ThingsGateway.NewLife.Caching; -using ThingsGateway.NewLife.Threading; using TouchSocket.Core; @@ -46,41 +45,45 @@ public static class ExpressionEvaluatorExtension private static object m_waiterLock = new object(); - /// 清理计时器 - private static TimerX? _clearTimer; static ExpressionEvaluatorExtension() { - if (_clearTimer == null) + var temp = Environment.GetEnvironmentVariable("CSS_CUSTOM_TEMPDIR"); + if (temp.IsNullOrWhiteSpace()) { - _clearTimer = new TimerX(RemoveNotAlive, null, 30 * 1000, 60 * 1000) { Async = true }; + var tempDir = Path.Combine(AppContext.BaseDirectory, "CSSCRIPT"); + if (Directory.Exists(tempDir)) + { + try + { + Directory.Delete(tempDir); + } + catch + { + + } + } + + Directory.CreateDirectory(tempDir);//重新创建,防止缓存的一些目录信息错误 + Environment.SetEnvironmentVariable("CSS_CUSTOM_TEMPDIR", tempDir); //传入变量 } + + Instance.KeyExpired += Instance_KeyExpired; } - private static void RemoveNotAlive(Object? state) + private static void Instance_KeyExpired(object sender, KeyEventArgs e) { - //检测缓存 try { - var data = Instance.GetAll(); - lock (m_waiterLock) + if (Instance.GetAll().TryGetValue(e.Key, out var item)) { - - foreach (var item in data) - { - if (item.Value!.ExpiredTime < item.Value.VisitTime + 1800_000) - { - Instance.Remove(item.Key); - item.Value?.Value?.TryDispose(); - item.Value?.Value?.GetType().Assembly.Unload(); - GC.Collect(); - } - } + item?.Value?.TryDispose(); + item?.Value?.GetType().Assembly.Unload(); + GC.Collect(); } } catch { } - } private static MemoryCache Instance { get; set; } = new MemoryCache(); @@ -125,6 +128,7 @@ public static class ExpressionEvaluatorExtension using ThingsGateway.Gateway.Application; using ThingsGateway.NewLife; using ThingsGateway.NewLife.Extension; + using ThingsGateway.NewLife.Json.Extension; using ThingsGateway.Gateway.Application.Extensions; {_using} public class Script:ReadWriteExpressions diff --git a/src/Foundation/ThingsGateway.CSScript/ThingsGateway.CSScript.csproj b/src/Foundation/ThingsGateway.CSScript/ThingsGateway.CSScript.csproj index 635147e40..ea5609b76 100644 --- a/src/Foundation/ThingsGateway.CSScript/ThingsGateway.CSScript.csproj +++ b/src/Foundation/ThingsGateway.CSScript/ThingsGateway.CSScript.csproj @@ -1,26 +1,17 @@ - - - + + + - - netstandard2.0;net9.0;net8.0;net6.0; - + + netstandard2.0; + - - + + + - - - - - - - - - - - - - + + + diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelDataDebugComponent.razor b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelComponent.razor similarity index 72% rename from src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelDataDebugComponent.razor rename to src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelComponent.razor index d2fcd6d48..98f265d34 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelDataDebugComponent.razor +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelComponent.razor @@ -7,9 +7,9 @@ - + - + @@ -23,7 +23,7 @@ - + @@ -34,13 +34,17 @@ + + + + + - - + diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelComponent.razor.cs b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelComponent.razor.cs new file mode 100644 index 000000000..1a11b3cc4 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelComponent.razor.cs @@ -0,0 +1,102 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Foundation; + +using TouchSocket.Core; + +namespace ThingsGateway.Debug; + +public partial class ChannelComponent : ComponentBase +{ + [Parameter] + public string ClassString { get; set; } + + [Parameter] + public EventCallback OnConnectClick { get; set; } + [Parameter] + public EventCallback<(IChannel, string)> OnConfimClick { get; set; } + + [Parameter] + public EventCallback OnDisConnectClick { get; set; } + + private ChannelOptionsDefault? Model { get; set; } = new(); + + private IChannel? Channel { get; set; } + + [Inject] + private IStringLocalizer Localizer { get; set; } + + [Inject] + private ToastService ToastService { get; set; } + + private async Task DisconnectClick() + { + try + { + if (Channel != null) + { + await Channel.CloseAsync(DefaultResource.Localizer["ProactivelyDisconnect", nameof(DisconnectClick)]); + if (OnDisConnectClick.HasDelegate) + await OnDisConnectClick.InvokeAsync(); + } + + + } + catch (Exception ex) + { + Channel?.Logger?.LogWarning(ex); + } + + } + + ValidateForm ValidateForm { get; set; } + + private async Task ConnectClick() + { + + try + { + var validate = ValidateForm.Validate(); + if (!validate) return; + await DisconnectClick(); + Channel?.SafeDispose(); + Channel = null; + + if (Channel == null) + { + var config = new TouchSocket.Core.TouchSocketConfig(); + var logMessage = new TouchSocket.Core.LoggerGroup() { LogLevel = TouchSocket.Core.LogLevel.Trace }; + var path = Model.Id.ToString().GetDebugLogPath(); + var logger = TextFileLogger.GetMultipleFileLogger(path); + logger.LogLevel = LogLevel.Trace; + logMessage.AddLogger(logger); + config.ConfigureContainer(a => a.RegisterSingleton(logMessage)); + Model.Config = config; + Channel = config.GetChannel(Model); + + if (OnConfimClick.HasDelegate) + await OnConfimClick.InvokeAsync((Channel, path)); + + await Channel.SetupAsync(config); + } + + await Channel.ConnectAsync(Channel.ChannelOptions.ConnectTimeout, default); + + if (OnConnectClick.HasDelegate) + await OnConnectClick.InvokeAsync(Channel); + + } + catch (Exception ex) + { + Channel?.Logger?.LogWarning(ex); + } + } +} diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelDataDebugComponent.razor.cs b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelDataDebugComponent.razor.cs deleted file mode 100644 index a0e32d9ed..000000000 --- a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelDataDebugComponent.razor.cs +++ /dev/null @@ -1,144 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using BootstrapBlazor.Components; - -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Forms; -using Microsoft.Extensions.Localization; - -using ThingsGateway.Foundation; -using ThingsGateway.Razor; - -using TouchSocket.Core; - -namespace ThingsGateway.Debug; - -public partial class ChannelDataDebugComponent : ComponentBase -{ - [Parameter] - public string ClassString { get; set; } - - [Parameter] - public EventCallback OnConnectClick { get; set; } - - [Parameter] - public EventCallback OnConfimClick { get; set; } - - [Parameter] - public EventCallback OnDisConnectClick { get; set; } - - private ChannelData? Model { get; set; } = new(); - - private IEnumerable ChannelDataItems { get; set; } - - [Inject] - private IStringLocalizer Localizer { get; set; } - - [Inject] - private ToastService ToastService { get; set; } - - public Task ValidSubmit(EditContext editContext) - { - CheckInput(Model); - return Task.CompletedTask; - } - - private void CheckInput(ChannelData input) - { - try - { - if (input.ChannelType == ChannelTypeEnum.TcpClient) - { - if (string.IsNullOrEmpty(input.RemoteUrl)) - throw new(Localizer["RemoteUrlNotNull"]); - } - else if (input.ChannelType == ChannelTypeEnum.TcpService) - { - if (string.IsNullOrEmpty(input.BindUrl)) - throw new(Localizer["BindUrlNotNull"]); - } - else if (input.ChannelType == ChannelTypeEnum.UdpSession) - { - if (string.IsNullOrEmpty(input.BindUrl) && string.IsNullOrEmpty(input.RemoteUrl)) - throw new(Localizer["BindUrlOrRemoteUrlNotNull"]); - } - else if (input.ChannelType == ChannelTypeEnum.SerialPort) - { - if (string.IsNullOrEmpty(input.PortName)) - throw new(Localizer["PortNameNotNull"]); - if (input.BaudRate == null) - throw new(Localizer["BaudRateNotNull"]); - if (input.DataBits == null) - throw new(Localizer["DataBitsNotNull"]); - if (input.Parity == null) - throw new(Localizer["ParityNotNull"]); - if (input.StopBits == null) - throw new(Localizer["StopBitsNotNull"]); - } - else - { - throw new(Localizer["NotOther"]); - } - } - catch (Exception ex) - { - ToastService.Warn(ex); - } - } - - private async Task DisconnectClick() - { - if (Model?.Channel != null) - { - try - { - await Model.Channel.CloseAsync(DefaultResource.Localizer["ProactivelyDisconnect", nameof(DisconnectClick)]); - if (OnDisConnectClick.HasDelegate) - await OnDisConnectClick.InvokeAsync(); - } - catch (Exception ex) - { - Model.Channel.Logger?.Exception(ex); - } - } - } - - private async Task ConfimClick() - { - try - { - await ChannelData.CreateChannelAsync(Model); - if (OnConfimClick.HasDelegate) - await OnConfimClick.InvokeAsync(Model); - } - catch (Exception ex) - { - Model.Channel?.Logger?.Exception(ex); - } - } - - private async Task ConnectClick() - { - if (Model != null) - { - try - { - if (Model.Channel != null) - if (OnConnectClick.HasDelegate) - await OnConnectClick.InvokeAsync(Model); - } - catch (Exception ex) - { - Model.Channel.Logger?.Exception(ex); - } - } - } -} diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelOptionsDefault.cs b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelOptionsDefault.cs new file mode 100644 index 000000000..b135e1d4e --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ChannelOptionsDefault.cs @@ -0,0 +1,19 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Yitter.IdGenerator; + +namespace ThingsGateway.Foundation; + +/// +public class ChannelOptionsDefault : ChannelOptions +{ + public long Id { get; } = YitIdHelper.NextId(); +} diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ConverterConfigComponent.razor b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ConverterConfigComponent.razor new file mode 100644 index 000000000..e809822f9 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ConverterConfigComponent.razor @@ -0,0 +1,33 @@ +@using BootstrapBlazor.Components +@using ThingsGateway.Extension +@using ThingsGateway.Foundation +@namespace ThingsGateway.Debug +@using TouchSocket.Core + + + +
@Localizer["Converter"]
+ + + + + + + + +
+ +
+
+
+
+
+ +
diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ConverterConfigComponent.razor.cs b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ConverterConfigComponent.razor.cs new file mode 100644 index 000000000..14d8ae22a --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/ConverterConfigComponent.razor.cs @@ -0,0 +1,36 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +#pragma warning disable CA2007 // 考虑对等待的任务调用 ConfigureAwait + +using System.Text; + +using ThingsGateway.Foundation; + +using LocalizerUtil = ThingsGateway.Razor.LocalizerUtil; + +namespace ThingsGateway.Debug; + +public partial class ConverterConfigComponent : ComponentBase +{ + [Parameter, EditorRequired] + public ConverterConfig Model { get; set; } + [Inject] + IStringLocalizer Localizer { get; set; } + + private List BoolItems; + private List EncodingItems; + + protected override void OnInitialized() + { + BoolItems = LocalizerUtil.GetBoolItems(Model.GetType(), nameof(Model.VariableStringLength), true); + EncodingItems = new List() { new SelectedItem("", "none") }.Concat(Encoding.GetEncodings().Select(a => new SelectedItem(a.CodePage.ToString(), a.DisplayName))).ToList(); + } +} diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/AdapterDebugComponent.razor b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/DeviceComponent.razor similarity index 69% rename from src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/AdapterDebugComponent.razor rename to src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/DeviceComponent.razor index 8438e52f8..c649a3a10 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/AdapterDebugComponent.razor +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/DeviceComponent.razor @@ -1,23 +1,24 @@ @using Microsoft.AspNetCore.Components.Web; @using Microsoft.JSInterop; -@using ThingsGateway.Core.Json.Extension @using ThingsGateway.Extension @using ThingsGateway.Foundation @using BootstrapBlazor.Components +@using ThingsGateway.NewLife.Json.Extension @namespace ThingsGateway.Debug -@inherits AdapterDebugBase +@inherits DeviceComponentBase -
+
@if (ShowDefaultReadWriteContent) { - - - + + + + +
@@ -65,7 +66,7 @@ @if (ShowDefaultOtherContent) { - @foreach (var item in VariableRunTimes) + @foreach (var item in VariableRuntimes) {
@@ -75,7 +76,7 @@
+ ShowLabel="true" class="w-100" />
@@ -106,11 +107,14 @@
- +
diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/AdapterDebugComponent.razor.cs b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/DeviceComponent.razor.cs similarity index 82% rename from src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/AdapterDebugComponent.razor.cs rename to src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/DeviceComponent.razor.cs index 1cee3ec76..f9055f663 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/AdapterDebugComponent.razor.cs +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/DeviceComponent.razor.cs @@ -8,9 +8,6 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.Localization; - using ThingsGateway.Foundation; using TouchSocket.Core; @@ -18,7 +15,7 @@ using TouchSocket.Core; namespace ThingsGateway.Debug; /// -public partial class AdapterDebugComponent : AdapterDebugBase +public partial class DeviceComponent : DeviceComponentBase { /// /// MaxPack @@ -26,9 +23,9 @@ public partial class AdapterDebugComponent : AdapterDebugBase public int MaxPack = 100; /// - /// VariableRunTimes + /// VariableRuntimes /// - public List VariableRunTimes; + public List VariableRuntimes; [Parameter] public string ClassString { get; set; } @@ -38,10 +35,8 @@ public partial class AdapterDebugComponent : AdapterDebugBase [Parameter, EditorRequired] public string LogPath { get; set; } - [Parameter] public ILog Logger { get; set; } - /// /// 自定义模板 /// @@ -59,9 +54,19 @@ public partial class AdapterDebugComponent : AdapterDebugBase [Parameter] public bool ShowDefaultReadWriteContent { get; set; } = true; + [Parameter] + public Func OnShowAddressUI { get; set; } + + private async Task ShowAddressUI() + { + if (OnShowAddressUI != null) + { + await OnShowAddressUI.Invoke(RegisterAddress); + } + } [Inject] - private IStringLocalizer Localizer { get; set; } + private IStringLocalizer Localizer { get; set; } /// /// MulReadAsync @@ -71,7 +76,7 @@ public partial class AdapterDebugComponent : AdapterDebugBase { if (Plc != null) { - var deviceVariableSourceReads = Plc.LoadSourceRead(VariableRunTimes, MaxPack, "1000"); + var deviceVariableSourceReads = Plc.LoadSourceRead(VariableRuntimes, MaxPack, "1000"); foreach (var item in deviceVariableSourceReads) { var result = await Plc.ReadAsync(item.RegisterAddress, item.Length); @@ -79,25 +84,25 @@ public partial class AdapterDebugComponent : AdapterDebugBase { try { - var result1 = item.VariableRunTimes.PraseStructContent(Plc, result.Content, exWhenAny: true); + var result1 = item.VariableRuntimes.PraseStructContent(Plc, result.Content, exWhenAny: true); if (!result1.IsSuccess) { item.LastErrorMessage = result1.ErrorMessage; var time = DateTime.Now; - item.VariableRunTimes.ForEach(a => a.SetValue(null, time, isOnline: false)); + item.VariableRuntimes.ForEach(a => a.SetValue(null, time, isOnline: false)); Plc.Logger?.Warning(result1.ToString()); } } catch (Exception ex) { - Plc.Logger?.Exception(ex); + Plc.Logger?.LogWarning(ex); } } else { item.LastErrorMessage = result.ErrorMessage; var time = DateTime.Now; - item.VariableRunTimes.ForEach(a => a.SetValue(null, time, isOnline: false)); + item.VariableRuntimes.ForEach(a => a.SetValue(null, time, isOnline: false)); Plc.Logger?.Warning(result.ToString()); } } @@ -107,7 +112,7 @@ public partial class AdapterDebugComponent : AdapterDebugBase /// protected override void OnInitialized() { - VariableRunTimes = new() + VariableRuntimes = new() { new VariableClass() { diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/AdapterDebugBase.cs b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/DeviceComponentBase.cs similarity index 79% rename from src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/AdapterDebugBase.cs rename to src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/DeviceComponentBase.cs index e34947f51..1b996171f 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/AdapterDebugBase.cs +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/DeviceComponentBase.cs @@ -13,18 +13,18 @@ namespace ThingsGateway.Debug; using Microsoft.AspNetCore.Components; using Microsoft.Extensions.Localization; -using ThingsGateway.Core.Json.Extension; using ThingsGateway.Foundation; +using ThingsGateway.NewLife.Json.Extension; using TouchSocket.Core; /// /// 调试UI /// -public abstract class AdapterDebugBase : ComponentBase, IDisposable +public abstract class DeviceComponentBase : ComponentBase, IDisposable { /// - ~AdapterDebugBase() + ~DeviceComponentBase() { this.SafeDispose(); } @@ -38,12 +38,27 @@ public abstract class AdapterDebugBase : ComponentBase, IDisposable /// 默认读写设备 /// [Parameter] - public IProtocol Plc { get; set; } + public IDevice Plc { get; set; } /// /// 变量地址 /// - public string RegisterAddress { get; set; } = "400001"; + public string RegisterAddress { get; set; } + + [Parameter] + public string DefaultAddress { get; set; } + + public void SetRegisterAddress(string address) + { + RegisterAddress = address; + StateHasChanged(); + } + + protected override void OnParametersSet() + { + RegisterAddress = DefaultAddress; + base.OnParametersSet(); + } /// /// 写入值 @@ -56,7 +71,7 @@ public abstract class AdapterDebugBase : ComponentBase, IDisposable protected DataTypeEnum DataType { get; set; } = DataTypeEnum.Int16; [Inject] - private IStringLocalizer Localizer { get; set; } + private IStringLocalizer Localizer { get; set; } /// public void Dispose() @@ -84,7 +99,7 @@ public abstract class AdapterDebugBase : ComponentBase, IDisposable } catch (Exception ex) { - Plc.Logger?.Exception(ex); + Plc.Logger?.LogWarning(ex); } } } @@ -108,7 +123,7 @@ public abstract class AdapterDebugBase : ComponentBase, IDisposable } catch (Exception ex) { - Plc.Logger?.Exception(ex); + Plc.Logger?.LogWarning(ex); } } } diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/LogConsole.razor b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/LogConsole.razor index 6b702fc61..e36904e77 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/LogConsole.razor +++ b/src/Foundation/ThingsGateway.Foundation.Razor/DebugPages/LogConsole.razor @@ -6,32 +6,53 @@ @using BootstrapBlazor.Components @namespace ThingsGateway.Debug - +
+ @if (EnableChanged.HasDelegate) + { + +
public ICollection Messages { get; set; } = new List(); - private ICollection CurrentMessages => IsPause ? PauseMessagesText : Messages; + private ICollection CurrentMessages => Pause ? PauseMessagesText : Messages; [Inject] private DownloadService DownloadService { get; set; } + [Inject] + private IStringLocalizer RazorLocalizer { get; set; } /// /// 暂停缓存 @@ -86,7 +91,7 @@ public partial class LogConsole : IDisposable if (LogPath != null) { var files = TextFileReader.GetFiles(LogPath); - if (files == null || files.FirstOrDefault() == null || !files.FirstOrDefault().IsSuccess) + if (!files.IsSuccess) { Messages = new List(); await Task.Delay(1000); @@ -96,7 +101,7 @@ public partial class LogConsole : IDisposable await Task.Run(async () => { Stopwatch sw = Stopwatch.StartNew(); - var result = TextFileReader.LastLog(files.FirstOrDefault().FullName, 0); + var result = TextFileReader.LastLog(files.Content.FirstOrDefault()); if (result.IsSuccess) { Messages = result.Content.Where(a => a.LogLevel >= LogLevel).Select(a => new LogMessage((int)a.LogLevel, $"{a.LogTime} - {a.Message}{(a.ExceptionString.IsNullOrWhiteSpace() ? null : $"{Environment.NewLine}{a.ExceptionString}")}")).ToList(); @@ -116,7 +121,7 @@ public partial class LogConsole : IDisposable } catch (Exception ex) { - System.Console.WriteLine(ex); + NewLife.Log.XTrace.WriteException(ex); } } @@ -131,22 +136,18 @@ public partial class LogConsole : IDisposable if (LogPath != null) { var files = TextFileReader.GetFiles(LogPath); - if (files == null || files.FirstOrDefault() == null || !files.FirstOrDefault().IsSuccess) + if (files.IsSuccess) { - } - else - { - foreach (var item in files) + foreach (var item in files.Content) { - if (File.Exists(item.FullName)) + if (File.Exists(item)) { int error = 0; while (error < 3) { try { - File.SetAttributes(item.FullName, FileAttributes.Normal); - File.Delete(item.FullName); + FileUtil.DeleteFile(item); break; } catch @@ -165,7 +166,7 @@ public partial class LogConsole : IDisposable { try { - if (IsPause) + if (Pause) { using var memoryStream = new MemoryStream(); using StreamWriter writer = new(memoryStream); @@ -193,11 +194,18 @@ public partial class LogConsole : IDisposable await ToastService.Warn(ex); } } - - private Task Pause() + private async Task OnEnable() { - IsPause = !IsPause; - if (IsPause) + if (EnableChanged.HasDelegate) + { + Enable = !Enable; + await EnableChanged.InvokeAsync(Enable); + } + } + private Task OnPause() + { + Pause = !Pause; + if (Pause) PauseMessagesText = Messages.ToList(); return Task.CompletedTask; } @@ -214,7 +222,7 @@ public partial class LogConsole : IDisposable } catch (Exception ex) { - System.Console.WriteLine(ex); + NewLife.Log.XTrace.WriteException(ex); } } } diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/Locales/en-US.json b/src/Foundation/ThingsGateway.Foundation.Razor/Locales/en-US.json index 9f5107318..1f43e4834 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/Locales/en-US.json +++ b/src/Foundation/ThingsGateway.Foundation.Razor/Locales/en-US.json @@ -1,5 +1,5 @@ { - "ThingsGateway.Debug.ChannelDataDebugComponent": { + "ThingsGateway.Debug.ChannelComponent": { "Name": "Name", "Name.Required": "{0} cannot be empty", "ChannelType": "Channel Type", @@ -14,14 +14,7 @@ "StopBits": "Stop Bits", "DtrEnable": "Dtr", "RtsEnable": "Rts", - "RemoteUrlNotNull": "Remote IP Address cannot be empty", - "BindUrlNotNull": "Local Bind IP Address cannot be empty", - "BindUrlOrRemoteUrlNotNull": "Remote IP Address or Local Bind IP Address cannot be empty", - "PortNameNotNull": "COM Port cannot be empty", - "BaudRateNotNull": "Baud Rate cannot be empty", - "DataBitsNotNull": "Data Bits can be empty", - "ParityNotNull": "Parity cannot be empty", - "StopBitsNotNull": "Stop Bits cannot be empty", + "SaveChannel": "Add/Modify Channel", "DeleteChannel": "Delete Channel", "ClearChannel": "Clear Channel", @@ -34,26 +27,44 @@ "Disconnect": "Disconnect", "Channel": "Channel" }, + "ThingsGateway.Foundation.ChannelOptionsDefault": { + "Name": "Name", + "Name.Required": "{0} cannot be empty", + "ChannelType": "ChannelType", - "ThingsGateway.Debug.AdapterDebugBase": { - "WriteSuccess": "Write Successful", - "DefaultSend": "Direct Send", + "RemoteUrl": "RemoteUrl", + "BindUrl": "BindUrl", + + "PortName": "PortName", + "BaudRate": "BaudRate", + "DataBits": "DataBits", + "Parity": "Parity", + "StopBits": "StopBits", + "DtrEnable": "DtrEnable", + "RtsEnable": "RtsEnable", + "CacheTimeout": "CacheTimeout", + "ConnectTimeout": "ConnectTimeout", + "MaxConcurrentCount": "MaxConcurrentCount" + }, + "ThingsGateway.Debug.DeviceComponentBase": { + "WriteSuccess": "WriteSuccess", + "DefaultSend": "DefaultSend", "Send": "Send", - "DataType": "Data Type", - "RegisterAddress": "Register Address", - "ArrayLength": "Array Length", - "WriteValue": "Write Value", + "DataType": "DataType", + "RegisterAddress": "RegisterAddress", + "ArrayLength": "ArrayLength", + "WriteValue": "WriteValue", "SendValue": "Send Raw Message" }, - "ThingsGateway.Debug.AdapterDebugComponent": { + "ThingsGateway.Debug.DeviceComponent": { "HeaderText": "Channel Log", - "WriteSuccess": "Write Successful", - "DefaultSend": "Direct Send", + "WriteSuccess": "WriteSuccess", + "DefaultSend": "DefaultSend", "Send": "Send", - "DataType": "Data Type", - "RegisterAddress": "Register Address", - "ArrayLength": "Array Length", - "WriteValue": "Write Value", + "DataType": "DataType", + "RegisterAddress": "RegisterAddress", + "ArrayLength": "ArrayLength", + "WriteValue": "WriteValue", "SendValue": "Send Raw Message", "CommonFunctions": "Common Functions", "SpecialFunctions": "Special Functions", @@ -61,8 +72,11 @@ "Write": "Write", "MulRead": "Multiple Read" }, - "ThingsGateway.Debug.ChannelDataEditComponent": { + "ThingsGateway.Debug.ChannelEditComponent": { "BasicInformation": "Basic Information", "Connection": "Connection" + }, + "ThingsGateway.Debug.ConverterConfigComponent": { + "Converter": "Converter" } } diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/Locales/zh-CN.json b/src/Foundation/ThingsGateway.Foundation.Razor/Locales/zh-CN.json index 1789a57c3..523f21737 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/Locales/zh-CN.json +++ b/src/Foundation/ThingsGateway.Foundation.Razor/Locales/zh-CN.json @@ -1,5 +1,5 @@ { - "ThingsGateway.Debug.ChannelDataDebugComponent": { + "ThingsGateway.Debug.ChannelComponent": { "Name": "名称", "Name.Required": " {0} 不可为空", "ChannelType": "通道类型", @@ -17,14 +17,6 @@ "DtrEnable": "Dtr", "RtsEnable": "Rts", - "RemoteUrlNotNull": "远程IP地址不可为空", - "BindUrlNotNull": "本地绑定IP地址不可为空", - "BindUrlOrRemoteUrlNotNull": "远程IP地址或本地绑定IP地址不可为空", - "PortNameNotNull": "COM口不可为空", - "BaudRateNotNull": "波特率不可为空", - "DataBitsNotNull": "数据位可为空", - "ParityNotNull": "校验位不可为空", - "StopBitsNotNull": "停止位不可为空", "SaveChannel": "添加/修改通道", "DeleteChannel": "删除通道", @@ -40,8 +32,26 @@ "Disconnect": "断开", "Channel": "通道" }, + "ThingsGateway.Foundation.ChannelOptionsDefault": { + "Name": "名称", + "Name.Required": " {0} 不可为空", + "ChannelType": "通道类型", - "ThingsGateway.Debug.AdapterDebugBase": { + "RemoteUrl": "远程IP地址", + "BindUrl": "本地绑定IP地址", + + "PortName": "COM口", + "BaudRate": "波特率", + "DataBits": "数据位", + "Parity": "校验位", + "StopBits": "停止位", + "DtrEnable": "Dtr", + "RtsEnable": "Rts", + "CacheTimeout": "接收缓存超时", + "ConnectTimeout": "连接超时", + "MaxConcurrentCount": "最大并发数" + }, + "ThingsGateway.Debug.DeviceComponentBase": { "WriteSuccess": "写入成功", "DefaultSend": "直接发送", "Send": "发送", @@ -51,7 +61,7 @@ "WriteValue": "写入值", "SendValue": "发送原始报文" }, - "ThingsGateway.Debug.AdapterDebugComponent": { + "ThingsGateway.Debug.DeviceComponent": { "HeaderText": "通道日志", "WriteSuccess": "写入成功", "DefaultSend": "直接发送", @@ -67,9 +77,11 @@ "Write": "写入", "MulRead": "多读" }, - "ThingsGateway.Debug.ChannelDataEditComponent": { + "ThingsGateway.Debug.ChannelEditComponent": { "BasicInformation": "基础信息", "Connection": "连接" + }, + "ThingsGateway.Debug.ConverterConfigComponent": { + "Converter": "内置数据转换" } - } diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/Locales/zh-TW.json b/src/Foundation/ThingsGateway.Foundation.Razor/Locales/zh-TW.json deleted file mode 100644 index 8e22911d9..000000000 --- a/src/Foundation/ThingsGateway.Foundation.Razor/Locales/zh-TW.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "ThingsGateway.Debug.ChannelDataDebugComponent": { - "Name": "名稱", - "Name.Required": "{0} 不可為空", - "ChannelType": "通道類型", - "Enable": "啟用", - "LogEnable": "啟用調試日誌", - "RemoteUrl": "遠程IP地址", - "BindUrl": "本地綁定IP地址", - "PortName": "COM口", - "BaudRate": "波特率", - "DataBits": "數據位", - "Parity": "校驗位", - "StopBits": "停止位", - "DtrEnable": "Dtr", - "RtsEnable": "Rts", - "RemoteUrlNotNull": "遠程IP地址不可為空", - "BindUrlNotNull": "本地綁定IP地址不可為空", - "BindUrlOrRemoteUrlNotNull": "遠程IP地址或本地綁定IP地址不可為空", - "PortNameNotNull": "COM口不可為空", - "BaudRateNotNull": "波特率不可為空", - "DataBitsNotNull": "數據位可為空", - "ParityNotNull": "校驗位不可為空", - "StopBitsNotNull": "停止位不可為空", - "SaveChannel": "添加/修改通道", - "DeleteChannel": "刪除通道", - "ClearChannel": "清空通道", - "ExportChannel": "導出通道", - "ImportChannel": "導入通道", - "ImportNullError": "無法識別", - "NotOther": "不支持其他通道類型", - "Connect": "連接", - "Confim": "創建", - "Disconnect": "斷開", - "Channel": "通道" - }, - "ThingsGateway.Debug.AdapterDebugBase": { - "WriteSuccess": "寫入成功", - "DefaultSend": "直接發送", - "Send": "發送", - "DataType": "數據類型", - "RegisterAddress": "寄存器地址", - "ArrayLength": "數組長度", - "WriteValue": "寫入值", - "SendValue": "發送原始報文" - }, - "ThingsGateway.Debug.AdapterDebugComponent": { - "HeaderText": "通道日誌", - "WriteSuccess": "寫入成功", - "DefaultSend": "直接發送", - "Send": "發送", - "DataType": "數據類型", - "RegisterAddress": "寄存器地址", - "ArrayLength": "數組長度", - "WriteValue": "寫入值", - "SendValue": "發送原始報文", - "CommonFunctions": "常用功能", - "SpecialFunctions": "特殊功能", - "Read": "讀取", - "Write": "寫入", - "MulRead": "多讀" - }, - "ThingsGateway.Debug.ChannelDataEditComponent": { - "BasicInformation": "基礎資訊", - "Connection": "連接" - } -} diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/Services/PlatformService.cs b/src/Foundation/ThingsGateway.Foundation.Razor/Services/PlatformService.cs index 4fb9c3663..2c9ca080a 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/Services/PlatformService.cs +++ b/src/Foundation/ThingsGateway.Foundation.Razor/Services/PlatformService.cs @@ -12,7 +12,6 @@ using Microsoft.JSInterop; using ThingsGateway.Extension; using ThingsGateway.Foundation; -using ThingsGateway.Razor; namespace ThingsGateway.Debug; @@ -28,7 +27,7 @@ public class PlatformService : IPlatformService public async Task OnLogExport(string logPath) { var files = TextFileReader.GetFiles(logPath); - if (files == null || files.FirstOrDefault() == null || !files.FirstOrDefault().IsSuccess) + if (!files.IsSuccess) { return; } @@ -36,10 +35,10 @@ public class PlatformService : IPlatformService string url = "api/file/download"; //统一web下载 - foreach (var item in files) + foreach (var item in files.Content) { await using var jSObject = await JSRuntime.InvokeAsync("import", $"{WebsiteConst.DefaultResourceUrl}js/downloadFile.js"); - var path = Path.GetRelativePath("wwwroot", item.FullName); + var path = Path.GetRelativePath("wwwroot", item); string fileName = DateTime.Now.ToFileDateTimeFormat(); await jSObject.InvokeVoidAsync("blazor_downloadFile", url, fileName, new { FileName = path }); } diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/ThingsGateway.Foundation.Razor.csproj b/src/Foundation/ThingsGateway.Foundation.Razor/ThingsGateway.Foundation.Razor.csproj index 69f304477..1ee0d3c81 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/ThingsGateway.Foundation.Razor.csproj +++ b/src/Foundation/ThingsGateway.Foundation.Razor/ThingsGateway.Foundation.Razor.csproj @@ -1,29 +1,26 @@ - - - - - - - - - Never - - - - - - - + + + + + net8.0; + - + + - + + + + Never + + + diff --git a/src/Foundation/ThingsGateway.Foundation.Razor/_Imports.razor.cs b/src/Foundation/ThingsGateway.Foundation.Razor/_Imports.razor.cs index 6fc832656..048a0ece8 100644 --- a/src/Foundation/ThingsGateway.Foundation.Razor/_Imports.razor.cs +++ b/src/Foundation/ThingsGateway.Foundation.Razor/_Imports.razor.cs @@ -12,7 +12,6 @@ global using BootstrapBlazor.Components; global using Microsoft.AspNetCore.Components; global using Microsoft.Extensions.Localization; -global using Microsoft.Extensions.Options; global using System.Diagnostics.CodeAnalysis; diff --git a/src/Foundation/ThingsGateway.Foundation.Variable/ThingsGateway.Foundation.Variable.csproj b/src/Foundation/ThingsGateway.Foundation.Variable/ThingsGateway.Foundation.Variable.csproj index b208d6630..cd5812d59 100644 --- a/src/Foundation/ThingsGateway.Foundation.Variable/ThingsGateway.Foundation.Variable.csproj +++ b/src/Foundation/ThingsGateway.Foundation.Variable/ThingsGateway.Foundation.Variable.csproj @@ -1,25 +1,25 @@ - + - - + + - - netstandard2.0;net9.0;net8.0;net6.0; - + + netstandard2.0; + - - - false - none; - - - - + + + false + none; + + + + - - - - + + + + diff --git a/src/Foundation/ThingsGateway.Foundation.Variable/VariableObject.cs b/src/Foundation/ThingsGateway.Foundation.Variable/VariableObject.cs index dcef3965e..2734fdce3 100644 --- a/src/Foundation/ThingsGateway.Foundation.Variable/VariableObject.cs +++ b/src/Foundation/ThingsGateway.Foundation.Variable/VariableObject.cs @@ -27,7 +27,7 @@ public abstract class VariableObject /// 协议对象 /// [JsonIgnore] - public IProtocol Protocol; + public IDevice Device; /// /// VariableRuntimePropertyDict @@ -49,9 +49,9 @@ public abstract class VariableObject /// /// VariableObject /// - public VariableObject(IProtocol protocol, int maxPack) + public VariableObject(IDevice device, int maxPack) { - Protocol = protocol; + Device = device; MaxPack = maxPack; } @@ -73,7 +73,7 @@ public abstract class VariableObject { object rawdata = jToken is JValue jValue ? jValue.Value : jToken is JArray jArray ? jArray : jToken.ToString(); - object data = variableRuntimeProperty.Attribute.WriteExpressions.GetExpressionsResult(rawdata); + object data = variableRuntimeProperty.Attribute.WriteExpressions.GetExpressionsResult(rawdata, Device?.Logger); jToken = JToken.FromObject(data); } @@ -147,15 +147,15 @@ public abstract class VariableObject //连读 foreach (var item in DeviceVariableSourceReads) { - var result = await Protocol.ReadAsync(item.RegisterAddress, item.Length, cancellationToken).ConfigureAwait(false); + var result = await Device.ReadAsync(item.RegisterAddress, item.Length, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) { - var result1 = item.VariableRunTimes.PraseStructContent(Protocol, result.Content, exWhenAny: true); + var result1 = item.VariableRuntimes.PraseStructContent(Device, result.Content, exWhenAny: true); if (!result1.IsSuccess) { item.LastErrorMessage = result1.ErrorMessage; var time = DateTime.Now; - item.VariableRunTimes.ForEach(a => a.SetValue(null, time, isOnline: false)); + item.VariableRuntimes.ForEach(a => a.SetValue(null, time, isOnline: false)); return new OperResult(result1); } } @@ -163,7 +163,7 @@ public abstract class VariableObject { item.LastErrorMessage = result.ErrorMessage; var time = DateTime.Now; - item.VariableRunTimes.ForEach(a => a.SetValue(null, time, isOnline: false)); + item.VariableRuntimes.ForEach(a => a.SetValue(null, time, isOnline: false)); return new OperResult(result); } } @@ -187,7 +187,7 @@ public abstract class VariableObject { if (!string.IsNullOrEmpty(pair.Value.Attribute.ReadExpressions)) { - var data = pair.Value.Attribute.ReadExpressions.GetExpressionsResult(pair.Value.VariableClass.Value); + var data = pair.Value.Attribute.ReadExpressions.GetExpressionsResult(pair.Value.VariableClass.Value, Device?.Logger); pair.Value.Property.SetValue(this, data.ChangeType(pair.Value.Property.PropertyType)); } else @@ -222,7 +222,7 @@ public abstract class VariableObject JToken jToken = GetExpressionsValue(value, variableRuntimeProperty); - var result = await Protocol.WriteAsync(variableRuntimeProperty.VariableClass.RegisterAddress, jToken, variableRuntimeProperty.VariableClass.DataType, cancellationToken).ConfigureAwait(false); + var result = await Device.WriteAsync(variableRuntimeProperty.VariableClass.RegisterAddress, jToken, variableRuntimeProperty.VariableClass.DataType, cancellationToken).ConfigureAwait(false); return result; } catch (Exception ex) @@ -239,7 +239,7 @@ public abstract class VariableObject if (DeviceVariableSourceReads == null) { List variableClasss = GetVariableClass(); - DeviceVariableSourceReads = Protocol.LoadSourceRead(variableClasss, MaxPack, "1000"); + DeviceVariableSourceReads = Device.LoadSourceRead(variableClasss, MaxPack, "1000"); } } } diff --git a/src/Foundation/ThingsGateway.Foundation/Attributes/UriValidationAttribute.cs b/src/Foundation/ThingsGateway.Foundation/Attributes/UriValidationAttribute.cs new file mode 100644 index 000000000..a2ae3f1e5 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation/Attributes/UriValidationAttribute.cs @@ -0,0 +1,60 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + + +using System.ComponentModel.DataAnnotations; +using System.Text.RegularExpressions; + +using ThingsGateway.Foundation; +namespace ThingsGateway; + +public class UriValidationAttribute : ValidationAttribute +{ + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + var uriString = value?.ToString(); + if (uriString.IsNullOrWhiteSpace()) return ValidationResult.Success; + // 正则表达式匹配 IPv4 格式 + var ipv4Pattern = @"^\d{1,3}(\.\d{1,3}){3}(:\d+)?$"; + // 正则表达式匹配 IPv6 格式 + var ipv6Pattern = @"^\[\*::\*\](?::\d+)?$"; + // 匹配域名格式(tcp/http) + var domainPattern = @"^(tcp|http)://([\w.-]+)(:\d+)?$"; + + // 验证端口号 + if (int.TryParse(uriString, out int port)) + { + if (port <= 0 || port > 65535) + { + return new ValidationResult(DefaultResource.Localizer["InvalidPortRange"]); + } + } + else if (Regex.IsMatch(uriString, ipv4Pattern)) + { + // IPv4 验证 + string[] segments = uriString.Split(':')[0].Split('.'); + foreach (var segment in segments) + { + if (int.Parse(segment) > 255) + { + return new ValidationResult(DefaultResource.Localizer["InvalidIPv4Segment"]); + } + } + } + else if (!Regex.IsMatch(uriString, ipv6Pattern) && !Regex.IsMatch(uriString, domainPattern)) + { + // 其他格式验证失败 + return new ValidationResult(DefaultResource.Localizer["InvalidUriFormat"]); + } + + // 验证通过 + return ValidationResult.Success; + } +} diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/ChannelData.cs b/src/Foundation/ThingsGateway.Foundation/Channel/ChannelData.cs deleted file mode 100644 index 6f65b73de..000000000 --- a/src/Foundation/ThingsGateway.Foundation/Channel/ChannelData.cs +++ /dev/null @@ -1,135 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using Newtonsoft.Json; - -using System.IO.Ports; - -using TouchSocket.SerialPorts; - -namespace ThingsGateway.Foundation; - -/// -public class ChannelData -{ - /// - public long Id { get; set; } = IncrementCount.GetCurrentValue(); - - /// - public virtual ChannelTypeEnum ChannelType { get; set; } - - /// - /// 远程地址,可由相互转化 - /// - public string? RemoteUrl { get; set; } = "127.0.0.1:502"; - - /// - /// 本地地址,可由相互转化 - /// - public string? BindUrl { get; set; } - - /// - /// COM - /// - public string? PortName { get; set; } = "COM1"; - - /// - /// 波特率 - /// - public int? BaudRate { get; set; } = 9600; - - /// - /// 数据位 - /// - public int? DataBits { get; set; } = 8; - - /// - /// 校验位 - /// - public Parity? Parity { get; set; } = System.IO.Ports.Parity.None; - - /// - /// 停止位 - /// - public StopBits? StopBits { get; set; } = System.IO.Ports.StopBits.One; - - /// - /// DtrEnable - /// - public bool? DtrEnable { get; set; } = true; - - /// - /// RtsEnable - /// - public bool? RtsEnable { get; set; } = true; - - /// - /// TouchSocketConfig - /// -#if NET6_0_OR_GREATER - - [System.Text.Json.Serialization.JsonIgnore] -#endif - - [JsonIgnore] - public TouchSocketConfig TouchSocketConfig; - - /// - /// Channel - /// -#if NET6_0_OR_GREATER - - [System.Text.Json.Serialization.JsonIgnore] -#endif - - [JsonIgnore] - public IChannel Channel; - - private static IncrementCount IncrementCount = new(long.MaxValue); - - /// - /// 创建通道 - /// - /// - public static async Task CreateChannelAsync(ChannelData channelData) - { - if (channelData.Channel != null) - { - channelData.Channel.Close(); - channelData.Channel.SafeDispose(); - } - channelData.TouchSocketConfig?.Dispose(); - channelData.TouchSocketConfig = new TouchSocket.Core.TouchSocketConfig(); - var logMessage = new TouchSocket.Core.LoggerGroup() { LogLevel = TouchSocket.Core.LogLevel.Trace }; - var logger = TextFileLogger.CreateTextLogger(channelData.Id.GetDebugLogPath()); - logger.LogLevel = LogLevel.Trace; - logMessage.AddLogger(logger); - channelData.TouchSocketConfig.ConfigureContainer(a => a.RegisterSingleton(logMessage)); - - switch (channelData.ChannelType) - { - case ChannelTypeEnum.TcpClient: - channelData.Channel = await channelData.TouchSocketConfig.GetTcpClientWithIPHostAsync(channelData.RemoteUrl, channelData.BindUrl).ConfigureAwait(false); - break; - - case ChannelTypeEnum.TcpService: - channelData.Channel = await channelData.TouchSocketConfig.GetTcpServiceWithBindIPHostAsync(channelData.BindUrl).ConfigureAwait(false); - break; - - case ChannelTypeEnum.SerialPort: - channelData.Channel = await channelData.TouchSocketConfig.GetSerialPortWithOptionAsync(channelData.Map()).ConfigureAwait(false); - break; - - case ChannelTypeEnum.UdpSession: - channelData.Channel = await channelData.TouchSocketConfig.GetUdpSessionWithIPHostAsync(channelData.RemoteUrl, channelData.BindUrl).ConfigureAwait(false); - break; - } - } -} diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/ChannelOptions.cs b/src/Foundation/ThingsGateway.Foundation/Channel/ChannelOptions.cs new file mode 100644 index 000000000..224a52bd4 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation/Channel/ChannelOptions.cs @@ -0,0 +1,47 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Foundation; + +/// +public class ChannelOptions : ChannelOptionsBase, IChannelOptions, IDisposable +{ + public WaitLock WaitLock { get; private set; } = new WaitLock(); + /// + public override int MaxConcurrentCount + { + get + { + return _maxConcurrentCount; + } + set + { + if (value > 0) + { + _maxConcurrentCount = value; + if (WaitLock?.MaxCount != MaxConcurrentCount) + { + var _lock = WaitLock; + WaitLock = new WaitLock(_maxConcurrentCount); + _lock?.SafeDispose(); + } + } + } + } + + private volatile int _maxConcurrentCount = 1; + public TouchSocketConfig Config { get; set; } = new(); + + public void Dispose() + { + Config?.SafeDispose(); + GC.SuppressFinalize(this); + } +} diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/ChannelOptionsBase.cs b/src/Foundation/ThingsGateway.Foundation/Channel/ChannelOptionsBase.cs new file mode 100644 index 000000000..346766bf1 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation/Channel/ChannelOptionsBase.cs @@ -0,0 +1,142 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using System.ComponentModel.DataAnnotations; +using System.IO.Ports; + +namespace ThingsGateway.Foundation +{ + public abstract class ChannelOptionsBase : IValidatableObject + { + /// + /// 通道类型 + /// + public virtual ChannelTypeEnum ChannelType { get; set; } + + #region 以太网 + + /// + /// 远程ip + /// + [UriValidation] + public virtual string RemoteUrl { get; set; } = "127.0.0.1:502"; + + /// + /// 本地绑定ip,分号分隔,例如:192.168.1.1:502;192.168.1.2:502,表示绑定192.168.1.1:502和192.168.1.2:502 + /// + [UriValidation] + public virtual string BindUrl { get; set; } + + #endregion + + #region 串口 + + /// + /// COM + /// + public virtual string PortName { get; set; } = "COM1"; + + /// + /// 波特率 + /// + public virtual int BaudRate { get; set; } = 9600; + + /// + /// 数据位 + /// + public virtual int DataBits { get; set; } = 8; + + /// + /// 校验位 + /// + public virtual Parity Parity { get; set; } = System.IO.Ports.Parity.None; + + /// + /// 停止位 + /// + public virtual StopBits StopBits { get; set; } = System.IO.Ports.StopBits.One; + + /// + /// DtrEnable + /// + public virtual bool DtrEnable { get; set; } = true; + + /// + /// RtsEnable + /// + public virtual bool RtsEnable { get; set; } = true; + + /// + [MinValue(1)] + public virtual int MaxConcurrentCount { get; set; } = 1; + + /// + [MinValue(100)] + public virtual int CacheTimeout { get; set; } = 500; + /// + [MinValue(100)] + public virtual ushort ConnectTimeout { get; set; } = 3000; + + #endregion + + public virtual IEnumerable Validate(ValidationContext validationContext) + { + + if (ChannelType == ChannelTypeEnum.TcpClient) + { + if (string.IsNullOrEmpty(RemoteUrl)) + { + yield return new ValidationResult(DefaultResource.Localizer["RemoteUrlNotNull"], new[] { nameof(RemoteUrl) }); + } + } + else if (ChannelType == ChannelTypeEnum.TcpService) + { + if (string.IsNullOrEmpty(BindUrl)) + { + yield return new ValidationResult(DefaultResource.Localizer["BindUrlNotNull"], new[] { nameof(BindUrl) }); + } + } + else if (ChannelType == ChannelTypeEnum.UdpSession) + { + if (string.IsNullOrEmpty(BindUrl) && string.IsNullOrEmpty(RemoteUrl)) + { + yield return new ValidationResult(DefaultResource.Localizer["BindUrlOrRemoteUrlNotNull"], new[] { nameof(BindUrl), nameof(RemoteUrl) }); + } + } + else if (ChannelType == ChannelTypeEnum.SerialPort) + { + if (string.IsNullOrEmpty(PortName)) + { + yield return new ValidationResult(DefaultResource.Localizer["PortNameNotNull"], new[] { nameof(PortName) }); + } + } + + } + + + public override string ToString() + { + switch (ChannelType) + { + case ChannelTypeEnum.TcpClient: + return RemoteUrl; + case ChannelTypeEnum.TcpService: + return BindUrl; + case ChannelTypeEnum.SerialPort: + return PortName; + case ChannelTypeEnum.UdpSession: + return RemoteUrl; + case ChannelTypeEnum.Other: + return string.Empty; + } + return string.Empty; + } + } +} \ No newline at end of file diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/Extension/ChannelConfigExtensions.cs b/src/Foundation/ThingsGateway.Foundation/Channel/Extension/ChannelOptionsExtensions.cs similarity index 63% rename from src/Foundation/ThingsGateway.Foundation/Channel/Extension/ChannelConfigExtensions.cs rename to src/Foundation/ThingsGateway.Foundation/Channel/Extension/ChannelOptionsExtensions.cs index 044a622d9..7c44c2bf8 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/Extension/ChannelConfigExtensions.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/Extension/ChannelOptionsExtensions.cs @@ -18,7 +18,7 @@ namespace ThingsGateway.Foundation; /// /// 通道扩展 /// -public static class ChannelConfigExtensions +public static class ChannelOptionsExtensions { /// /// 触发通道接收事件 @@ -35,9 +35,10 @@ public static class ChannelConfigExtensions if (funcs.Count > 0) { - foreach (var func in funcs) + for (int i = 0; i < funcs.Count; i++) { - await func.Invoke(clientChannel, e).ConfigureAwait(false); + var func = funcs[i]; + await func.Invoke(clientChannel, e, i == funcs.Count - 1).ConfigureAwait(false); if (e.Handled) { break; @@ -59,14 +60,19 @@ public static class ChannelConfigExtensions if (funcs.Count > 0) { - foreach (var func in funcs) + for (int i = 0; i < funcs.Count; i++) { - var handled = await func.Invoke(clientChannel).ConfigureAwait(false); + var func = funcs[i]; + var handled = await func.Invoke(clientChannel, i == funcs.Count - 1).ConfigureAwait(false); if (handled) { break; } } + foreach (var func in funcs) + { + + } } } @@ -74,36 +80,32 @@ public static class ChannelConfigExtensions /// 获取一个新的通道。传入通道类型,远程服务端地址,绑定地址,串口配置信息 /// /// 配置 - /// 通道类型 - /// 远端IP端口配置 - /// 本地IP端口配置 - /// 串口配置 + /// 通道配置 /// /// - public static async ValueTask GetChannelAsync(this TouchSocketConfig config, ChannelTypeEnum channelType, string? remoteUrl = default, string? bindUrl = default, SerialPortOption? serialPortOption = default) + public static IChannel? GetChannel(this TouchSocketConfig config, IChannelOptions channelOptions) { config.ThrowIfNull(nameof(TouchSocketConfig)); + channelOptions.ThrowIfNull(nameof(IChannelOptions)); + var channelType = channelOptions.ChannelType; channelType.ThrowIfNull(nameof(ChannelTypeEnum)); - - switch (channelType) { case ChannelTypeEnum.TcpClient: - remoteUrl.ThrowIfNull(nameof(IPHost)); - return await config.GetTcpClientWithIPHostAsync(remoteUrl, bindUrl).ConfigureAwait(false); + return config.GetTcpClientWithIPHost(channelOptions); case ChannelTypeEnum.TcpService: - bindUrl.ThrowIfNull(nameof(IPHost)); - return await config.GetTcpServiceWithBindIPHostAsync(bindUrl).ConfigureAwait(false); + return config.GetTcpServiceWithBindIPHost(channelOptions); case ChannelTypeEnum.SerialPort: - serialPortOption.ThrowIfNull(nameof(SerialPortOption)); - return await config.GetSerialPortWithOptionAsync(serialPortOption).ConfigureAwait(false); + return config.GetSerialPortWithOption(channelOptions); case ChannelTypeEnum.UdpSession: - if (string.IsNullOrEmpty(remoteUrl) && string.IsNullOrEmpty(bindUrl)) - throw new ArgumentNullException(nameof(IPHost)); - return await config.GetUdpSessionWithIPHostAsync(remoteUrl, bindUrl).ConfigureAwait(false); + return config.GetUdpSessionWithIPHost(channelOptions); + case ChannelTypeEnum.Other: + channelOptions.Config = config; + OtherChannel otherChannel = new OtherChannel(channelOptions); + return otherChannel; } return default; } @@ -112,17 +114,17 @@ public static class ChannelConfigExtensions /// 获取一个新的串口通道。传入串口配置信息 /// /// 配置 - /// 串口配置 + /// 串口配置 /// - public static async ValueTask GetSerialPortWithOptionAsync(this TouchSocketConfig config, SerialPortOption serialPortOption) + public static SerialPortChannel GetSerialPortWithOption(this TouchSocketConfig config, IChannelOptions channelOptions) { + var serialPortOption = channelOptions.Map(); serialPortOption.ThrowIfNull(nameof(SerialPortOption)); + channelOptions.Config = config; config.SetSerialPortOption(serialPortOption); //载入配置 - SerialPortChannel serialPortChannel = new SerialPortChannel(); - await serialPortChannel.SetupAsync(config).ConfigureAwait(false); - + SerialPortChannel serialPortChannel = new SerialPortChannel(channelOptions); return serialPortChannel; } @@ -130,20 +132,21 @@ public static class ChannelConfigExtensions /// 获取一个新的Tcp客户端通道。传入远程服务端地址和绑定地址 ///
/// 配置 - /// 远端IP端口配置 - /// 本地IP端口配置 + /// 通道配置 /// /// - public static async ValueTask GetTcpClientWithIPHostAsync(this TouchSocketConfig config, string remoteUrl, string? bindUrl = default) + public static TcpClientChannel GetTcpClientWithIPHost(this TouchSocketConfig config, IChannelOptions channelOptions) { - remoteUrl.ThrowIfNull(nameof(IPHost)); + var remoteUrl = channelOptions.RemoteUrl; + var bindUrl = channelOptions.BindUrl; + remoteUrl.ThrowIfNull(nameof(remoteUrl)); + channelOptions.Config = config; config.SetRemoteIPHost(remoteUrl); - if (!string.IsNullOrEmpty(bindUrl)) + if (!string.IsNullOrWhiteSpace(bindUrl)) config.SetBindIPHost(bindUrl); //载入配置 - TcpClientChannel tcpClientChannel = new TcpClientChannel(); - await tcpClientChannel.SetupAsync(config).ConfigureAwait(false); + TcpClientChannel tcpClientChannel = new TcpClientChannel(channelOptions); return tcpClientChannel; } @@ -151,18 +154,19 @@ public static class ChannelConfigExtensions /// 获取一个新的Tcp服务会话通道。传入远程服务端地址和绑定地址 ///
/// 配置 - /// 本地IP端口配置 + /// 通道配置 /// /// - public static async ValueTask GetTcpServiceWithBindIPHostAsync(this TouchSocketConfig config, string bindUrl) + public static TcpServiceChannel GetTcpServiceWithBindIPHost(this TouchSocketConfig config, IChannelOptions channelOptions) { - bindUrl.ThrowIfNull(nameof(IPHost)); + var bindUrl = channelOptions.BindUrl; + bindUrl.ThrowIfNull(nameof(bindUrl)); + channelOptions.Config = config; var urls = bindUrl.SplitStringBySemicolon(); config.SetListenIPHosts(IPHost.ParseIPHosts(urls)); //载入配置 - TcpServiceChannel tcpServiceChannel = new TcpServiceChannel(); - await tcpServiceChannel.SetupAsync(config).ConfigureAwait(false); + TcpServiceChannel tcpServiceChannel = new TcpServiceChannel(channelOptions); return tcpServiceChannel; } @@ -170,14 +174,16 @@ public static class ChannelConfigExtensions /// 获取一个新的Udp会话通道。传入远程服务端地址和绑定地址 /// /// 配置 - /// 远端IP端口配置 - /// 本地IP端口配置 + /// 通道配置 /// /// - public static async ValueTask GetUdpSessionWithIPHostAsync(this TouchSocketConfig config, string? remoteUrl, string? bindUrl) + public static UdpSessionChannel GetUdpSessionWithIPHost(this TouchSocketConfig config, IChannelOptions channelOptions) { + var remoteUrl = channelOptions.RemoteUrl; + var bindUrl = channelOptions.BindUrl; if (string.IsNullOrEmpty(remoteUrl) && string.IsNullOrEmpty(bindUrl)) throw new ArgumentNullException(nameof(IPHost)); + channelOptions.Config = config; if (!string.IsNullOrEmpty(remoteUrl)) config.SetRemoteIPHost(remoteUrl); @@ -188,14 +194,13 @@ public static class ChannelConfigExtensions config.SetBindIPHost(new IPHost(0)); //载入配置 - UdpSessionChannel udpSessionChannel = new UdpSessionChannel(); -#if NET6_0_OR_GREATER - if (OperatingSystem.IsWindows()) + UdpSessionChannel udpSessionChannel = new UdpSessionChannel(channelOptions); +#if NETSTANDARD || NET6_0_OR_GREATER + if (System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) { config.UseUdpConnReset(); } #endif - await udpSessionChannel.SetupAsync(config).ConfigureAwait(false); return udpSessionChannel; } } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/IChannel.cs b/src/Foundation/ThingsGateway.Foundation/Channel/IChannel.cs index 51007a409..ae36e3484 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/IChannel.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/IChannel.cs @@ -8,28 +8,34 @@ // QQ群:605534569 //------------------------------------------------------------------------------ +using System.Collections.Concurrent; + namespace ThingsGateway.Foundation; /// /// 通道管理 /// -public interface IChannel : ISetupConfigObject, IDisposable, IClosableClient +public interface IChannel : ISetupConfigObject, IDisposable, IClosableClient, IConnectableClient { - /// /// 接收数据事件 /// - public ChannelReceivedEventHandler ChannelReceived { get; set; } + public ChannelReceivedEventHandler ChannelReceived { get; } + + /// + /// 通道配置 + /// + public IChannelOptions ChannelOptions { get; } /// /// 通道类型 /// - public ChannelTypeEnum ChannelType { get; } + ChannelTypeEnum ChannelType { get; } /// /// 通道下的所有设备 /// - public ConcurrentList Collects { get; } + public ConcurrentList Collects { get; } /// /// Online @@ -39,61 +45,45 @@ public interface IChannel : ISetupConfigObject, IDisposable, IClosableClient /// /// MaxSign /// - public int MaxSign { get; set; } + int MaxSign { get; set; } + /// /// 通道启动成功后 /// - public ChannelEventHandler Started { get; set; } + public ChannelEventHandler Started { get; } /// /// 通道启动即将成功 /// - public ChannelEventHandler Starting { get; set; } + public ChannelEventHandler Starting { get; } /// /// 通道停止 /// - public ChannelEventHandler Stoped { get; set; } + public ChannelEventHandler Stoped { get; } /// /// 通道停止前 /// - public ChannelEventHandler Stoping { get; set; } + public ChannelEventHandler Stoping { get; } /// - /// 关闭客户端。 + /// 主动请求时的等待池 /// - /// 关闭消息 - public void Close(string msg); + public ConcurrentDictionary> ChannelReceivedWaitDict { get; } - /// - /// 启动 - /// - /// 最大等待时间 - /// 可取消令箭 - /// - /// - public void Connect(int millisecondsTimeout = 3000, CancellationToken token = default); - - /// - /// 异步连接 - /// - /// 最大等待时间 - /// 可取消令箭 - /// - /// - public Task ConnectAsync(int millisecondsTimeout = 3000, CancellationToken token = default); } /// /// 接收事件回调类 /// -public class ChannelReceivedEventHandler : List> +public class ChannelReceivedEventHandler : List> { } + /// /// 通道事件回调类 /// -public class ChannelEventHandler : List>> +public class ChannelEventHandler : List>> { } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/IChannelOptions.cs b/src/Foundation/ThingsGateway.Foundation/Channel/IChannelOptions.cs new file mode 100644 index 000000000..d80f0a441 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation/Channel/IChannelOptions.cs @@ -0,0 +1,97 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using System.IO.Ports; + +namespace ThingsGateway.Foundation; + +public interface IChannelOptions +{ + + /// + /// 通道类型 + /// + ChannelTypeEnum ChannelType { get; set; } + + #region 以太网 + + /// + /// 远程ip + /// + string RemoteUrl { get; set; } + + /// + /// 本地绑定ip,分号分隔,例如:192.168.1.1:502;192.168.1.2:502,表示绑定192.168.1.1:502和192.168.1.2:502 + /// + string BindUrl { get; set; } + + #endregion + + #region 串口 + + /// + /// COM + /// + string PortName { get; set; } + + /// + /// 波特率 + /// + int BaudRate { get; set; } + + /// + /// 数据位 + /// + int DataBits { get; set; } + + /// + /// 校验位 + /// + Parity Parity { get; set; } + + /// + /// 停止位 + /// + StopBits StopBits { get; set; } + + /// + /// DtrEnable + /// + bool DtrEnable { get; set; } + + /// + /// RtsEnable + /// + bool RtsEnable { get; set; } + + + #endregion + /// + /// 最大并发数量 + /// + int MaxConcurrentCount { get; set; } + + /// + /// 组包缓存时间 + /// + int CacheTimeout { get; set; } + + /// + /// 连接超时时间 + /// + ushort ConnectTimeout { get; set; } + + /// + /// 通道并发控制锁 + /// + WaitLock WaitLock { get; } + + TouchSocketConfig Config { get; set; } +} \ No newline at end of file diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/IClientChannel.cs b/src/Foundation/ThingsGateway.Foundation/Channel/IClientChannel.cs index 749028e77..98f52216e 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/IClientChannel.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/IClientChannel.cs @@ -13,7 +13,7 @@ namespace ThingsGateway.Foundation; /// /// 终端通道 /// -public interface IClientChannel : IChannel, ISender, IClient, IClientSender, IOnlineClient, IAdapterObject +public interface IClientChannel : IChannel, ISender, IClient, IClientSender, IOnlineClient { /// /// 当前通道的数据处理适配器 @@ -25,9 +25,15 @@ public interface IClientChannel : IChannel, ISender, IClient, IClientSender, IOn /// WaitHandlePool WaitHandlePool { get; } + /// - /// 收发等待锁,对于大部分工业主从协议是必须的,一个通道一个实现 + /// 通讯并发限制 /// WaitLock WaitLock { get; } + /// + /// 设置数据处理适配器 + /// + /// 适配器 + void SetDataHandlingAdapter(DataHandlingAdapter adapter); } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/OtherChannel.cs b/src/Foundation/ThingsGateway.Foundation/Channel/OtherChannel.cs new file mode 100644 index 000000000..bceea7aa3 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation/Channel/OtherChannel.cs @@ -0,0 +1,214 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace ThingsGateway.Foundation; + +/// +/// 测试通道 +/// +public class OtherChannel : SetupConfigObject, IClientChannel +{ + private SingleStreamDataHandlingAdapter m_dataHandlingAdapter; + public DataHandlingAdapter ReadOnlyDataHandlingAdapter => m_dataHandlingAdapter; + + public OtherChannel(IChannelOptions channelOptions) + { + ChannelOptions = channelOptions; + + WaitHandlePool.MaxSign = ushort.MaxValue; + } + + public override TouchSocketConfig Config => base.Config ?? ChannelOptions.Config; + + /// + public int MaxSign { get => WaitHandlePool.MaxSign; set => WaitHandlePool.MaxSign = value; } + + /// + public ChannelReceivedEventHandler ChannelReceived { get; set; } = new(); + + /// + public IChannelOptions ChannelOptions { get; } + + /// + public ChannelTypeEnum ChannelType => ChannelOptions.ChannelType; + + /// + public ConcurrentList Collects { get; } = new(); + + /// + public ChannelEventHandler Started { get; set; } = new(); + + /// + public ChannelEventHandler Starting { get; set; } = new(); + + /// + public ChannelEventHandler Stoped { get; set; } = new(); + + /// + public ChannelEventHandler Stoping { get; set; } = new(); + /// + /// 等待池 + /// + public WaitHandlePool WaitHandlePool { get; } = new(); + + /// + public WaitLock WaitLock => ChannelOptions.WaitLock; + + /// + public ConcurrentDictionary> ChannelReceivedWaitDict { get; } = new(); + + private readonly WaitLock _connectLock = new WaitLock(); + + /// + public void SetDataHandlingAdapter(DataHandlingAdapter adapter) + { + if (adapter is SingleStreamDataHandlingAdapter singleStreamDataHandlingAdapter) + SetAdapter(singleStreamDataHandlingAdapter); + } + /// + /// 设置数据处理适配器。 + /// + /// 要设置的适配器实例。 + /// 如果提供的适配器实例为null,则抛出此异常。 + protected void SetAdapter(SingleStreamDataHandlingAdapter adapter) + { + // 检查当前实例是否已被释放,如果是,则抛出异常。 + ThrowIfDisposed(); + // 检查adapter参数是否为null,如果是,则抛出ArgumentNullException异常。 + if (adapter is null) + { + throw new ArgumentNullException(nameof(adapter)); + } + + // 如果当前实例的配置不为空,则将配置应用到适配器上。 + if (Config != null) + { + adapter.Config(Config); + } + + // 设置适配器的日志记录器和加载、接收数据的回调方法。 + adapter.Logger = Logger; + adapter.OnLoaded(this); + adapter.ReceivedAsyncCallBack = PrivateHandleReceivedData; + //adapter.SendCallBack = this.ProtectedDefaultSend; + adapter.SendAsyncCallBack = ProtectedDefaultSendAsync; + + // 将提供的适配器实例设置为当前实例的数据处理适配器。 + m_dataHandlingAdapter = adapter; + } + + private async Task PrivateHandleReceivedData(ByteBlock byteBlock, IRequestInfo requestInfo) + { + LastReceivedTime = DateTime.Now; + await this.OnChannelReceivedEvent(new ReceivedDataEventArgs(byteBlock, requestInfo), ChannelReceived).ConfigureAwait(false); + } + + /// + /// 异步发送数据,保护方法。 + /// + /// 待发送的字节数据内存。 + /// 异步任务。 + protected Task ProtectedDefaultSendAsync(ReadOnlyMemory memory) + { + LastSentTime = DateTime.Now; + return Task.CompletedTask; + } + + public Protocol Protocol => new Protocol("Other"); + + public DateTime LastReceivedTime { get; private set; } + + public DateTime LastSentTime { get; private set; } + + public bool IsClient => true; + + public bool Online => true; + + public Task CloseAsync(string msg) + { + return Task.CompletedTask; + } + + public Task ConnectAsync(int millisecondsTimeout, CancellationToken token) + { + return Task.CompletedTask; + } + + public async Task SendAsync(IList> transferBytes) + { + // 检查数据处理适配器是否存在且支持拼接发送 + if (m_dataHandlingAdapter == null || !m_dataHandlingAdapter.CanSplicingSend) + { + // 如果不支持拼接发送,则计算所有字节片段的总长度 + var length = 0; + foreach (var item in transferBytes) + { + length += item.Count; + } + // 使用计算出的总长度创建一个连续的内存块 + using (var byteBlock = new ByteBlock(length)) + { + // 将每个字节片段写入连续的内存块 + foreach (var item in transferBytes) + { + byteBlock.Write(new ReadOnlySpan(item.Array, item.Offset, item.Count)); + } + // 根据数据处理适配器的存在与否,选择不同的发送方式 + if (m_dataHandlingAdapter == null) + { + // 如果没有数据处理适配器,则使用默认方式发送 + await ProtectedDefaultSendAsync(byteBlock.Memory).ConfigureAwait(EasyTask.ContinueOnCapturedContext); + } + else + { + // 如果有数据处理适配器,则通过适配器发送 + await m_dataHandlingAdapter.SendInputAsync(byteBlock.Memory).ConfigureAwait(EasyTask.ContinueOnCapturedContext); + } + } + } + else + { + // 如果数据处理适配器支持拼接发送,则直接发送字节列表 + await m_dataHandlingAdapter.SendInputAsync(transferBytes).ConfigureAwait(EasyTask.ContinueOnCapturedContext); + } + } + + public Task SendAsync(ReadOnlyMemory memory) + { + if (m_dataHandlingAdapter == null) + { + return ProtectedDefaultSendAsync(memory); + } + else + { + // 否则,使用适配器的发送方法进行数据发送。 + return m_dataHandlingAdapter.SendInputAsync(memory); + } + } + + public Task SendAsync(IRequestInfo requestInfo) + { + // 检查是否具备发送请求的条件,如果不具备则抛出异常 + ThrowIfCannotSendRequestInfo(); + + // 使用数据处理适配器异步发送输入请求 + return m_dataHandlingAdapter.SendInputAsync(requestInfo); + } + private void ThrowIfCannotSendRequestInfo() + { + if (m_dataHandlingAdapter == null || !m_dataHandlingAdapter.CanSendRequestInfo) + { + throw new NotSupportedException($"当前适配器为空或者不支持对象发送。"); + } + } + +} diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/DtuPlugin.cs b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/DtuPlugin.cs index e4f555870..43966d7d4 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/DtuPlugin.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/DtuPlugin.cs @@ -8,7 +8,7 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using ThingsGateway.Foundation.Extension.String; +using System.Text; namespace ThingsGateway.Foundation; @@ -17,14 +17,27 @@ namespace ThingsGateway.Foundation; public class DtuPlugin : PluginBase, ITcpReceivingPlugin { /// - /// 心跳16进制字符串 + /// 心跳字符串 /// - public string HeartbeatHexString { get; set; } + public string Heartbeat + { + get + { + return _heartbeat; + } + set + { + _heartbeat = value; + HeartbeatByte = new ArraySegment(Encoding.UTF8.GetBytes(value)); + } + } + private string _heartbeat; + private ArraySegment HeartbeatByte; /// public async Task OnTcpReceiving(ITcpSession client, ByteBlockEventArgs e) { - var len = HeartbeatHexString.HexStringToBytes().Length; + var len = HeartbeatByte.Count; if (client is TcpSessionClientChannel socket && socket.Service is TcpServiceChannel tcpServiceChannel) { if (!socket.Id.StartsWith("ID=")) @@ -65,14 +78,14 @@ public class DtuPlugin : PluginBase, ITcpReceivingPlugin if (len > 0) { - if (HeartbeatHexString == e.ByteBlock.AsSegment(0, len).ToHexString(default)) + if (HeartbeatByte.SequenceEqual(e.ByteBlock.AsSegment(0, len))) { if (DateTime.UtcNow - socket.LastSentTime.ToUniversalTime() < TimeSpan.FromMilliseconds(200)) { await Task.Delay(200).ConfigureAwait(false); } //回应心跳包 - await socket.SendAsync(e.ByteBlock.AsSegment()).ConfigureAwait(false); + await socket.SendAsync(HeartbeatByte).ConfigureAwait(false); e.Handled = true; if (socket.Logger?.LogLevel <= LogLevel.Trace) socket.Logger?.Trace($"{socket}- Heartbeat"); diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/HeartbeatAndReceivePlugin.cs b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/HeartbeatAndReceivePlugin.cs index fd8af5fbc..28a6d0619 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/HeartbeatAndReceivePlugin.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/HeartbeatAndReceivePlugin.cs @@ -8,15 +8,47 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using ThingsGateway.Foundation.Extension.String; +using System.Text; namespace ThingsGateway.Foundation; [PluginOption(Singleton = true)] internal sealed class HeartbeatAndReceivePlugin : PluginBase, ITcpConnectedPlugin, ITcpReceivingPlugin { - public string DtuId { get; set; } = "DtuId"; - public string HeartbeatHexString { get; set; } = "HeartbeatHexString"; + public string DtuId + { + get + { + return _dtuId; + } + set + { + _dtuId = value; + DtuIdByte = new ArraySegment(Encoding.UTF8.GetBytes(value)); + } + } + private string _dtuId; + private ArraySegment DtuIdByte; + + /// + /// 心跳字符串 + /// + public string Heartbeat + { + get + { + return _heartbeat; + } + set + { + _heartbeat = value; + HeartbeatByte = new ArraySegment(Encoding.UTF8.GetBytes(value)); + } + } + private string _heartbeat; + private ArraySegment HeartbeatByte; + + public int HeartbeatTime { get; set; } = 3; public async Task OnTcpConnected(ITcpSession client, ConnectedEventArgs e) @@ -30,14 +62,14 @@ internal sealed class HeartbeatAndReceivePlugin : PluginBase, ITcpConnectedPlugi if (client is ITcpClient tcpClient) { - await tcpClient.SendAsync(DtuId.ToUTF8Bytes()).ConfigureAwait(false); + await tcpClient.SendAsync(DtuIdByte).ConfigureAwait(false); - _ = Task.Run(async () => + _ = Task.Factory.StartNew(async () => { var failedCount = 0; - while (true) + while (client.Online) { - await Task.Delay(HeartbeatTime * 1000).ConfigureAwait(false); + await Task.Delay(HeartbeatTime).ConfigureAwait(false); if (!client.Online) { return; @@ -50,7 +82,8 @@ internal sealed class HeartbeatAndReceivePlugin : PluginBase, ITcpConnectedPlugi await Task.Delay(200).ConfigureAwait(false); } - await tcpClient.SendAsync(HeartbeatHexString.HexStringToBytes()).ConfigureAwait(false); + await tcpClient.SendAsync(HeartbeatByte).ConfigureAwait(false); + tcpClient.Logger?.Trace($"{tcpClient}- Heartbeat"); failedCount = 0; } catch @@ -79,10 +112,10 @@ internal sealed class HeartbeatAndReceivePlugin : PluginBase, ITcpConnectedPlugi if (client is ITcpClient tcpClient) { - var len = HeartbeatHexString.HexStringToBytes().Length; + var len = HeartbeatByte.Count; if (len > 0) { - if (HeartbeatHexString == e.ByteBlock.AsSegment(0, len).ToHexString(default)) + if (HeartbeatByte.SequenceEqual(e.ByteBlock.AsSegment(0, len))) { e.Handled = true; } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/IDtu.cs b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/IDtu.cs index b705c3698..44be969af 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/IDtu.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/IDtu.cs @@ -14,12 +14,12 @@ namespace ThingsGateway.Foundation; public interface IDtu : ITcpService { /// - /// 心跳检测(大写16进制字符串) + /// 心跳检测(utf8) /// - public string HeartbeatHexString { get; set; } + public string Heartbeat { get; set; } /// - /// 默认Dtu注册包,utf-8字符串 + /// 默认Dtu注册包(utf-8) /// public string DtuId { get; set; } } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/IDtuClient.cs b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/IDtuClient.cs index c960a4fef..cdacb11d8 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/IDtuClient.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/IDtuClient.cs @@ -11,18 +11,8 @@ namespace ThingsGateway.Foundation; /// -public interface IDtuClient +public interface IDtuClient : IDtu { - /// - /// DtuId - /// - public string DtuId { get; set; } - - /// - /// 心跳内容 - /// - public string HeartbeatHexString { get; set; } - /// /// 心跳时间 /// diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/PluginUtil.cs b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/PluginUtil.cs index 83c72cf75..a4a99a7ef 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/PluginUtil.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/Plugin/PluginUtil.cs @@ -21,7 +21,7 @@ public static class PluginUtil action += a => { var plugin = a.Add(); - plugin.HeartbeatHexString = dtuClient.HeartbeatHexString; + plugin.Heartbeat = dtuClient.Heartbeat; plugin.DtuId = dtuClient.DtuId; plugin.HeartbeatTime = dtuClient.HeartbeatTime; }; @@ -37,18 +37,18 @@ public static class PluginUtil { a.UseCheckClear() .SetCheckClearType(CheckClearType.All) - .SetTick(TimeSpan.FromSeconds(dtu.CheckClearTime)) + .SetTick(TimeSpan.FromMilliseconds(dtu.CheckClearTime)) .SetOnClose((c, t) => { c.TryShutdown(); - c.SafeClose($"{dtu.CheckClearTime}s Timeout"); + c.SafeClose($"{dtu.CheckClearTime}ms Timeout"); }); }; action += a => { var plugin = a.Add(); - plugin.HeartbeatHexString = dtu.HeartbeatHexString; + plugin.Heartbeat = dtu.Heartbeat; }; return action; } @@ -62,14 +62,27 @@ public static class PluginUtil { a.UseCheckClear() .SetCheckClearType(CheckClearType.All) - .SetTick(TimeSpan.FromSeconds(tcpService.CheckClearTime)) + .SetTick(TimeSpan.FromMilliseconds(tcpService.CheckClearTime)) .SetOnClose((c, t) => { c.TryShutdown(); - c.SafeClose($"{tcpService.CheckClearTime}s Timeout"); + c.SafeClose($"{tcpService.CheckClearTime}ms Timeout"); }); }; return action; } + + /// + public static Action GetTcpReconnectionPlugin(ITcpClient tcpClient) + { + Action action = a => { }; + + action += a => + { + a.UseTcpReconnection(); + }; + + return action; + } } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/SerialPortChannel.cs b/src/Foundation/ThingsGateway.Foundation/Channel/SerialPortChannel.cs index a7ebfab9e..7aa04ac3a 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/SerialPortChannel.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/SerialPortChannel.cs @@ -8,6 +8,8 @@ // QQ群:605534569 //------------------------------------------------------------------------------ +using System.Collections.Concurrent; + using TouchSocket.SerialPorts; namespace ThingsGateway.Foundation; @@ -17,23 +19,30 @@ namespace ThingsGateway.Foundation; /// public class SerialPortChannel : SerialPortClient, IClientChannel { - private readonly WaitLock m_semaphoreForConnect = new WaitLock(); - /// - public SerialPortChannel() + public SerialPortChannel(IChannelOptions channelOptions) { + ChannelOptions = channelOptions; + WaitHandlePool.MaxSign = ushort.MaxValue; } + + public override TouchSocketConfig Config => base.Config ?? ChannelOptions.Config; + + /// public int MaxSign { get => WaitHandlePool.MaxSign; set => WaitHandlePool.MaxSign = value; } /// public ChannelReceivedEventHandler ChannelReceived { get; set; } = new(); /// - public ChannelTypeEnum ChannelType => ChannelTypeEnum.SerialPort; + public IChannelOptions ChannelOptions { get; } /// - public ConcurrentList Collects { get; } = new(); + public ChannelTypeEnum ChannelType => ChannelOptions.ChannelType; + + /// + public ConcurrentList Collects { get; } = new(); /// public DataHandlingAdapter ReadOnlyDataHandlingAdapter => ProtectedDataHandlingAdapter; @@ -55,36 +64,39 @@ public class SerialPortChannel : SerialPortClient, IClientChannel public WaitHandlePool WaitHandlePool { get; } = new(); /// - public WaitLock WaitLock { get; } = new WaitLock(); + public WaitLock WaitLock => ChannelOptions.WaitLock; /// - public void Close(string msg) - { - CloseAsync(msg).ConfigureAwait(false).GetAwaiter().GetResult(); - } + public ConcurrentDictionary> ChannelReceivedWaitDict { get; } = new(); + private readonly WaitLock _connectLock = new WaitLock(); /// public override async Task CloseAsync(string msg) { if (Online) { - await this.OnChannelEvent(Stoping).ConfigureAwait(false); + try + { + await _connectLock.WaitAsync().ConfigureAwait(false); + if (Online) + { + await this.OnChannelEvent(Stoping).ConfigureAwait(false); - await base.CloseAsync(msg).ConfigureAwait(false); - Logger?.Debug($"{ToString()} Closed{msg}"); + await base.CloseAsync(msg).ConfigureAwait(false); + Logger?.Debug($"{ToString()} Closed{msg}"); - await this.OnChannelEvent(Stoped).ConfigureAwait(false); + await this.OnChannelEvent(Stoped).ConfigureAwait(false); + } + } + finally + { + _connectLock.Release(); + } } } - /// - public void Connect(int millisecondsTimeout = 3000, CancellationToken token = default) - { - ConnectAsync(millisecondsTimeout, token).ConfigureAwait(false).GetAwaiter().GetResult(); - } - /// public new async Task ConnectAsync(int millisecondsTimeout, CancellationToken token) { @@ -92,22 +104,24 @@ public class SerialPortChannel : SerialPortClient, IClientChannel { try { - await m_semaphoreForConnect.WaitAsync(token).ConfigureAwait(false); + await _connectLock.WaitAsync(token).ConfigureAwait(false); if (!Online) { - await SetupAsync(Config.Clone()).ConfigureAwait(false); + //await SetupAsync(Config.Clone()).ConfigureAwait(false); await base.ConnectAsync(millisecondsTimeout, token).ConfigureAwait(false); - Logger?.Debug($"{ToString()} Connected"); + Logger?.Debug($"{ToString()} Connected"); await this.OnChannelEvent(Started).ConfigureAwait(false); } } finally { - m_semaphoreForConnect.Release(); + _connectLock.Release(); } } } + + /// public void SetDataHandlingAdapter(DataHandlingAdapter adapter) { @@ -118,9 +132,16 @@ public class SerialPortChannel : SerialPortClient, IClientChannel /// public override string? ToString() { - var port = Config?.GetValue(SerialPortConfigExtension.SerialPortOptionProperty); - if (port != null) - return $"{port.PortName}[{port.BaudRate},{port.DataBits},{port.StopBits},{port.Parity}]"; + if (ProtectedMainSerialPort != null) + { + return $"{ProtectedMainSerialPort.PortName}"; + } + else + { + var port = Config?.GetValue(SerialPortConfigExtension.SerialPortOptionProperty); + if (port != null) + return $"{port.PortName}"; + } return base.ToString(); } @@ -137,8 +158,6 @@ public class SerialPortChannel : SerialPortClient, IClientChannel { Logger?.Debug($"{ToString()} Connecting{(e.Message.IsNullOrEmpty() ? string.Empty : $" -{e.Message}")}"); await this.OnChannelEvent(Starting).ConfigureAwait(false); - - await base.OnSerialConnecting(e).ConfigureAwait(false); } @@ -146,6 +165,25 @@ public class SerialPortChannel : SerialPortClient, IClientChannel protected override async Task OnSerialReceived(ReceivedDataEventArgs e) { await base.OnSerialReceived(e).ConfigureAwait(false); + if (e.RequestInfo is MessageBase response) + { + if (ChannelReceivedWaitDict.TryRemove(response.Sign, out var func)) + { + await func.Invoke(this, e, ChannelReceived.Count == 1).ConfigureAwait(false); + e.Handled = true; + } + } + if (e.Handled) + return; + await this.OnChannelReceivedEvent(e, ChannelReceived).ConfigureAwait(false); } + + + /// + protected override void Dispose(bool disposing) + { + WaitHandlePool.SafeDispose(); + base.Dispose(disposing); + } } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/TcpClientChannel.cs b/src/Foundation/ThingsGateway.Foundation/Channel/TcpClientChannel.cs index d6af2bc38..05a3c23da 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/TcpClientChannel.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/TcpClientChannel.cs @@ -8,6 +8,11 @@ // QQ群:605534569 //------------------------------------------------------------------------------ + + + +using System.Collections.Concurrent; + namespace ThingsGateway.Foundation; /// @@ -15,70 +20,76 @@ namespace ThingsGateway.Foundation; /// public class TcpClientChannel : TcpClient, IClientChannel { - private readonly WaitLock m_semaphoreForConnect = new WaitLock(); - /// - public TcpClientChannel() + public TcpClientChannel(IChannelOptions channelOptions) { + ChannelOptions = channelOptions; + WaitHandlePool.MaxSign = ushort.MaxValue; } + public override TouchSocketConfig Config => base.Config ?? ChannelOptions.Config; public int MaxSign { get => WaitHandlePool.MaxSign; set => WaitHandlePool.MaxSign = value; } /// - public ChannelReceivedEventHandler ChannelReceived { get; set; } = new(); + public ChannelReceivedEventHandler ChannelReceived { get; } = new(); /// - public ChannelTypeEnum ChannelType => ChannelTypeEnum.TcpClient; + public IChannelOptions ChannelOptions { get; } /// - public ConcurrentList Collects { get; } = new(); + public ChannelTypeEnum ChannelType => ChannelOptions.ChannelType; + + /// + public ConcurrentList Collects { get; } = new(); /// public DataHandlingAdapter ReadOnlyDataHandlingAdapter => DataHandlingAdapter; /// - public ChannelEventHandler Started { get; set; } = new(); + public ChannelEventHandler Started { get; } = new(); /// - public ChannelEventHandler Starting { get; set; } = new(); + public ChannelEventHandler Starting { get; } = new(); /// - public ChannelEventHandler Stoped { get; set; } = new(); + public ChannelEventHandler Stoped { get; } = new(); /// - public ChannelEventHandler Stoping { get; set; } = new(); + public ChannelEventHandler Stoping { get; } = new(); /// /// 等待池 /// public WaitHandlePool WaitHandlePool { get; } = new(); - /// - public WaitLock WaitLock { get; } = new WaitLock(); /// - public void Close(string msg) - { - CloseAsync(msg).ConfigureAwait(false).GetAwaiter().GetResult(); - } + public WaitLock WaitLock => ChannelOptions.WaitLock; + /// + public ConcurrentDictionary> ChannelReceivedWaitDict { get; } = new(); + private readonly WaitLock _connectLock = new WaitLock(); /// public override async Task CloseAsync(string msg) { if (Online) { - await base.CloseAsync(msg).ConfigureAwait(false); - Logger?.Debug($"{ToString()} Closed{msg}"); - await this.OnChannelEvent(Stoped).ConfigureAwait(false); - + try + { + await _connectLock.WaitAsync().ConfigureAwait(false); + if (Online) + { + await base.CloseAsync(msg).ConfigureAwait(false); + Logger?.Debug($"{ToString()} Closed{msg}"); + await this.OnChannelEvent(Stoped).ConfigureAwait(false); + } + } + finally + { + _connectLock.Release(); + } } } - /// - public void Connect(int millisecondsTimeout = 3000, CancellationToken token = default) - { - ConnectAsync(millisecondsTimeout, token).ConfigureAwait(false).GetAwaiter().GetResult(); - } - /// public override async Task ConnectAsync(int millisecondsTimeout, CancellationToken token) { @@ -86,10 +97,10 @@ public class TcpClientChannel : TcpClient, IClientChannel { try { - await m_semaphoreForConnect.WaitAsync(token).ConfigureAwait(false); + await _connectLock.WaitAsync(token).ConfigureAwait(false); if (!Online) { - await SetupAsync(Config.Clone()).ConfigureAwait(false); + //await SetupAsync(Config.Clone()).ConfigureAwait(false); await base.ConnectAsync(millisecondsTimeout, token).ConfigureAwait(false); Logger?.Debug($"{ToString()} Connected"); await this.OnChannelEvent(Started).ConfigureAwait(false); @@ -98,7 +109,7 @@ public class TcpClientChannel : TcpClient, IClientChannel } finally { - m_semaphoreForConnect.Release(); + _connectLock.Release(); } } } @@ -137,7 +148,26 @@ public class TcpClientChannel : TcpClient, IClientChannel protected override async Task OnTcpReceived(ReceivedDataEventArgs e) { await base.OnTcpReceived(e).ConfigureAwait(false); + if (e.RequestInfo is MessageBase response) + { + if (ChannelReceivedWaitDict.TryRemove(response.Sign, out var func)) + { + await func.Invoke(this, e, ChannelReceived.Count == 1).ConfigureAwait(false); + e.Handled = true; + } + } + if (e.Handled) + return; + + await this.OnChannelReceivedEvent(e, ChannelReceived).ConfigureAwait(false); } + + /// + protected override void Dispose(bool disposing) + { + WaitHandlePool.SafeDispose(); + base.Dispose(disposing); + } } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/TcpServiceChannel.cs b/src/Foundation/ThingsGateway.Foundation/Channel/TcpServiceChannel.cs index 9a945d774..fa932337d 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/TcpServiceChannel.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/TcpServiceChannel.cs @@ -8,6 +8,8 @@ // QQ群:605534569 //------------------------------------------------------------------------------ +using System.Collections.Concurrent; + namespace ThingsGateway.Foundation; /// @@ -16,10 +18,9 @@ namespace ThingsGateway.Foundation; /// public abstract class TcpServiceChannelBase : TcpService, ITcpService where TClient : TcpSessionClientChannel, new() { - private readonly WaitLock m_semaphoreForConnect = new WaitLock(); /// - public ConcurrentList Collects { get; } = new(); + public ConcurrentList Collects { get; } = new(); /// /// 停止时是否发送ShutDown @@ -35,6 +36,7 @@ public abstract class TcpServiceChannelBase : TcpService, ITcp { if (ShutDownEnable) client.TryShutdown(); + await client.CloseAsync().ConfigureAwait(false); client.SafeDispose(); } @@ -43,6 +45,8 @@ public abstract class TcpServiceChannelBase : TcpService, ITcp } } } + + public async Task ClientDisposeAsync(string id) { if (this.TryGetClient(id, out var client)) @@ -53,32 +57,37 @@ public abstract class TcpServiceChannelBase : TcpService, ITcp client.SafeDispose(); } } + + private readonly WaitLock _connectLock = new WaitLock(); /// public override async Task StartAsync() { - try + if (ServerState != ServerState.Running) { - await m_semaphoreForConnect.WaitAsync().ConfigureAwait(false); - - if (ServerState != ServerState.Running) + try { - await base.StopAsync().ConfigureAwait(false); - await SetupAsync(Config.Clone()).ConfigureAwait(false); - await base.StartAsync().ConfigureAwait(false); - if (ServerState == ServerState.Running) + await _connectLock.WaitAsync().ConfigureAwait(false); + + if (ServerState != ServerState.Running) { - Logger?.Info($"{Monitors.FirstOrDefault()?.Option.IpHost}{DefaultResource.Localizer["ServiceStarted"]}"); + if (ServerState != ServerState.Stopped) + { + await base.StopAsync().ConfigureAwait(false); + } + + //await SetupAsync(Config.Clone()).ConfigureAwait(false); + await base.StartAsync().ConfigureAwait(false); + if (ServerState == ServerState.Running) + { + Logger?.Info($"{Monitors.FirstOrDefault()?.Option.IpHost}{DefaultResource.Localizer["ServiceStarted"]}"); + } } } - else + finally { - await base.StartAsync().ConfigureAwait(false); + _connectLock.Release(); } } - finally - { - m_semaphoreForConnect.Release(); - } } /// @@ -86,11 +95,25 @@ public abstract class TcpServiceChannelBase : TcpService, ITcp { if (Monitors.Any()) { - await ClearAsync().ConfigureAwait(false); - var iPHost = Monitors.FirstOrDefault()?.Option.IpHost; - await base.StopAsync().ConfigureAwait(false); - if (!Monitors.Any()) - Logger?.Info($"{iPHost}{DefaultResource.Localizer["ServiceStoped"]}"); + try + { + await _connectLock.WaitAsync().ConfigureAwait(false); + if (Monitors.Any()) + { + + await ClearAsync().ConfigureAwait(false); + var iPHost = Monitors.FirstOrDefault()?.Option.IpHost; + await base.StopAsync().ConfigureAwait(false); + if (!Monitors.Any()) + Logger?.Info($"{iPHost}{DefaultResource.Localizer["ServiceStoped"]}"); + + } + } + finally + { + _connectLock.Release(); + } + } else { @@ -98,11 +121,7 @@ public abstract class TcpServiceChannelBase : TcpService, ITcp } } - /// - public override string? ToString() - { - return Monitors.FirstOrDefault()?.Option?.IpHost.ToString(); - } + /// protected override Task OnTcpClosed(TClient socketClient, ClosedEventArgs e) @@ -138,11 +157,22 @@ public abstract class TcpServiceChannelBase : TcpService, ITcp /// public class TcpServiceChannel : TcpServiceChannelBase, IChannel { + + /// + public TcpServiceChannel(IChannelOptions channelOptions) + { + ChannelOptions = channelOptions; + } + public override TouchSocketConfig Config => base.Config ?? ChannelOptions.Config; + /// public ChannelReceivedEventHandler ChannelReceived { get; set; } = new(); /// - public ChannelTypeEnum ChannelType => ChannelTypeEnum.TcpService; + public IChannelOptions ChannelOptions { get; } + + /// + public ChannelTypeEnum ChannelType => ChannelOptions.ChannelType; /// public bool Online => ServerState == ServerState.Running; @@ -158,24 +188,12 @@ public class TcpServiceChannel : TcpServiceChannelBase, /// public ChannelEventHandler Stoping { get; set; } = new(); - /// - public void Close(string msg) - { - CloseAsync(msg).ConfigureAwait(false).GetAwaiter().GetResult(); - } - /// public Task CloseAsync(string msg) { return StopAsync(); } - /// - public void Connect(int millisecondsTimeout = 3000, CancellationToken token = default) - { - ConnectAsync(millisecondsTimeout, token).ConfigureAwait(false).GetAwaiter().GetResult(); - } - /// public Task ConnectAsync(int timeout = 3000, CancellationToken token = default) { @@ -184,7 +202,11 @@ public class TcpServiceChannel : TcpServiceChannelBase, return StartAsync(); } - + /// + public override string? ToString() + { + return $"{ChannelOptions.BindUrl} {ChannelOptions.RemoteUrl}"; + } /// protected override TcpSessionClientChannel NewClient() { @@ -221,11 +243,34 @@ public class TcpServiceChannel : TcpServiceChannelBase, await base.OnTcpConnecting(socketClient, e).ConfigureAwait(false); } + + /// protected override async Task OnTcpReceived(TcpSessionClientChannel socketClient, ReceivedDataEventArgs e) { await base.OnTcpReceived(socketClient, e).ConfigureAwait(false); + + if (e.RequestInfo is MessageBase response) + { + if (ChannelReceivedWaitDict.TryRemove(response.Sign, out var func)) + { + await func.Invoke(socketClient, e, ChannelReceived.Count == 1).ConfigureAwait(false); + e.Handled = true; + } + } + if (e.Handled) + return; + await socketClient.OnChannelReceivedEvent(e, ChannelReceived).ConfigureAwait(false); } + + + /// + public ConcurrentDictionary> ChannelReceivedWaitDict { get; } = new(); + protected override void ClientInitialized(TcpSessionClientChannel client) + { + client.ChannelOptions = ChannelOptions; + base.ClientInitialized(client); + } } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/TcpSessionClientChannel.cs b/src/Foundation/ThingsGateway.Foundation/Channel/TcpSessionClientChannel.cs index bb3411fe8..659b2fd50 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/TcpSessionClientChannel.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/TcpSessionClientChannel.cs @@ -8,6 +8,9 @@ // QQ群:605534569 //------------------------------------------------------------------------------ + +using System.Collections.Concurrent; + namespace ThingsGateway.Foundation; /// @@ -18,18 +21,23 @@ public class TcpSessionClientChannel : TcpSessionClient, IClientChannel /// public TcpSessionClientChannel() { + WaitHandlePool.MaxSign = ushort.MaxValue; } + public int MaxSign { get => WaitHandlePool.MaxSign; set => WaitHandlePool.MaxSign = value; } /// public ChannelReceivedEventHandler ChannelReceived { get; set; } = new(); /// - public ChannelTypeEnum ChannelType => ChannelTypeEnum.TcpService; + public IChannelOptions ChannelOptions { get; internal set; } /// - public ConcurrentList Collects { get; } = new(); + public ChannelTypeEnum ChannelType => ChannelOptions.ChannelType; + + /// + public ConcurrentList Collects { get; } = new(); /// public DataHandlingAdapter ReadOnlyDataHandlingAdapter => DataHandlingAdapter; @@ -51,13 +59,7 @@ public class TcpSessionClientChannel : TcpSessionClient, IClientChannel public WaitHandlePool WaitHandlePool { get; private set; } = new(); /// - public WaitLock WaitLock { get; } = new WaitLock(); - - /// - public void Close(string msg) - { - CloseAsync(msg).ConfigureAwait(false); - } + public WaitLock WaitLock => ChannelOptions.WaitLock; /// public override Task CloseAsync(string msg) @@ -67,10 +69,7 @@ public class TcpSessionClientChannel : TcpSessionClient, IClientChannel } /// - public void Connect(int millisecondsTimeout = 3000, CancellationToken token = default) => throw new NotSupportedException(); - - /// - public Task ConnectAsync(int timeout, CancellationToken token) => throw new NotImplementedException(); + public Task ConnectAsync(int timeout, CancellationToken token) => Task.CompletedTask; /// public void SetDataHandlingAdapter(DataHandlingAdapter adapter) @@ -80,15 +79,10 @@ public class TcpSessionClientChannel : TcpSessionClient, IClientChannel } /// - public Task SetupAsync(TouchSocketConfig config) - { - return EasyTask.CompletedTask; - } + public Task SetupAsync(TouchSocketConfig config) => Task.CompletedTask; - public override async Task ResetIdAsync(string newId) - { - await base.ResetIdAsync(newId).ConfigureAwait(false); - } + /// + public ConcurrentDictionary> ChannelReceivedWaitDict { get; } = new(); /// public override string ToString() @@ -99,7 +93,6 @@ public class TcpSessionClientChannel : TcpSessionClient, IClientChannel /// protected override void Dispose(bool disposing) { - if (DisposedValue) return; WaitHandlePool.SafeDispose(); base.Dispose(disposing); } @@ -123,9 +116,7 @@ public class TcpSessionClientChannel : TcpSessionClient, IClientChannel /// protected override async Task OnTcpConnected(ConnectedEventArgs e) { - //Logger?.Debug($"{ToString()}{FoundationConst.Connected}"); await this.OnChannelEvent(Started).ConfigureAwait(false); - await base.OnTcpConnected(e).ConfigureAwait(false); } @@ -133,7 +124,6 @@ public class TcpSessionClientChannel : TcpSessionClient, IClientChannel protected override async Task OnTcpConnecting(ConnectingEventArgs e) { await this.OnChannelEvent(Starting).ConfigureAwait(false); - await base.OnTcpConnecting(e).ConfigureAwait(false); } @@ -141,7 +131,18 @@ public class TcpSessionClientChannel : TcpSessionClient, IClientChannel protected override async Task OnTcpReceived(ReceivedDataEventArgs e) { await base.OnTcpReceived(e).ConfigureAwait(false); - await this.OnChannelReceivedEvent(e, ChannelReceived).ConfigureAwait(false); + if (e.RequestInfo is MessageBase response) + { + if (ChannelReceivedWaitDict.TryRemove(response.Sign, out var func)) + { + await func.Invoke(this, e, ChannelReceived.Count == 1).ConfigureAwait(false); + e.Handled = true; + } + } + if (e.Handled) + return; + await this.OnChannelReceivedEvent(e, ChannelReceived).ConfigureAwait(false); } + } diff --git a/src/Foundation/ThingsGateway.Foundation/Channel/UdpSessionChannel.cs b/src/Foundation/ThingsGateway.Foundation/Channel/UdpSessionChannel.cs index ad31c905f..3bb6422dd 100644 --- a/src/Foundation/ThingsGateway.Foundation/Channel/UdpSessionChannel.cs +++ b/src/Foundation/ThingsGateway.Foundation/Channel/UdpSessionChannel.cs @@ -8,6 +8,8 @@ // QQ群:605534569 //------------------------------------------------------------------------------ +using System.Collections.Concurrent; + namespace ThingsGateway.Foundation; /// @@ -15,23 +17,29 @@ namespace ThingsGateway.Foundation; /// public class UdpSessionChannel : UdpSession, IClientChannel { - private readonly WaitLock m_semaphoreForConnect = new WaitLock(); + private readonly WaitLock _connectLock = new WaitLock(); /// - public UdpSessionChannel() + public UdpSessionChannel(IChannelOptions channelOptions) { + ChannelOptions = channelOptions; WaitHandlePool.MaxSign = ushort.MaxValue; } + public override TouchSocketConfig Config => base.Config ?? ChannelOptions.Config; + public int MaxSign { get => WaitHandlePool.MaxSign; set => WaitHandlePool.MaxSign = value; } /// public ChannelReceivedEventHandler ChannelReceived { get; set; } = new(); /// - public ChannelTypeEnum ChannelType => ChannelTypeEnum.UdpSession; + public IChannelOptions ChannelOptions { get; } /// - public ConcurrentList Collects { get; } = new(); + public ChannelTypeEnum ChannelType => ChannelOptions.ChannelType; + + /// + public ConcurrentList Collects { get; } = new(); /// public bool Online => ServerState == ServerState.Running; @@ -56,13 +64,10 @@ public class UdpSessionChannel : UdpSession, IClientChannel public WaitHandlePool WaitHandlePool { get; set; } = new(); /// - public WaitLock WaitLock { get; } = new WaitLock(); + public WaitLock WaitLock => ChannelOptions.WaitLock; /// - public void Close(string msg) - { - CloseAsync(msg).ConfigureAwait(false).GetAwaiter().GetResult(); - } + public ConcurrentDictionary> ChannelReceivedWaitDict { get; } = new(); /// public Task CloseAsync(string msg) @@ -70,12 +75,6 @@ public class UdpSessionChannel : UdpSession, IClientChannel return StopAsync(); } - /// - public void Connect(int millisecondsTimeout = 3000, CancellationToken token = default) - { - ConnectAsync(millisecondsTimeout, token).ConfigureAwait(false).GetAwaiter().GetResult(); - } - /// public async Task ConnectAsync(int timeout = 3000, CancellationToken token = default) { @@ -96,29 +95,32 @@ public class UdpSessionChannel : UdpSession, IClientChannel /// public override async Task StartAsync() { - try + if (ServerState != ServerState.Running) { - await m_semaphoreForConnect.WaitAsync().ConfigureAwait(false); + try + { + await _connectLock.WaitAsync().ConfigureAwait(false); - if (ServerState != ServerState.Running) - { - await base.StopAsync().ConfigureAwait(false); - await SetupAsync(Config.Clone()).ConfigureAwait(false); - await base.StartAsync().ConfigureAwait(false); - if (ServerState == ServerState.Running) + if (ServerState != ServerState.Running) { - Logger?.Info($"{Monitor.IPHost}{DefaultResource.Localizer["ServiceStarted"]}"); + if (ServerState != ServerState.Stopped) + { + await base.StopAsync().ConfigureAwait(false); + } + //await SetupAsync(Config.Clone()).ConfigureAwait(false); + await base.StartAsync().ConfigureAwait(false); + if (ServerState == ServerState.Running) + { + Logger?.Info($"{Monitor.IPHost}{DefaultResource.Localizer["ServiceStarted"]}"); + } } + } - else + finally { - await base.StartAsync().ConfigureAwait(false); + _connectLock.Release(); } } - finally - { - m_semaphoreForConnect.Release(); - } } /// @@ -126,33 +128,64 @@ public class UdpSessionChannel : UdpSession, IClientChannel { if (Monitor != null) { - await this.OnChannelEvent(Stoping).ConfigureAwait(false); - await base.StopAsync().ConfigureAwait(false); - if (Monitor == null) + try { - await this.OnChannelEvent(Stoped).ConfigureAwait(false); - Logger?.Info($"{DefaultResource.Localizer["ServiceStoped"]}"); + await _connectLock.WaitAsync().ConfigureAwait(false); + if (Monitor != null) + { + await this.OnChannelEvent(Stoping).ConfigureAwait(false); + await base.StopAsync().ConfigureAwait(false); + if (Monitor == null) + { + await this.OnChannelEvent(Stoped).ConfigureAwait(false); + Logger?.Info($"{DefaultResource.Localizer["ServiceStoped"]}"); + } + } + else + { + await base.StopAsync().ConfigureAwait(false); + } + } + finally + { + _connectLock.Release(); } } else { await base.StopAsync().ConfigureAwait(false); } - } - /// public override string? ToString() { - return RemoteIPHost?.ToString().Replace("tcp", "udp"); + return $"{ChannelOptions.BindUrl} {ChannelOptions.RemoteUrl}"; } /// protected override async Task OnUdpReceived(UdpReceivedDataEventArgs e) { await base.OnUdpReceived(e).ConfigureAwait(false); - await this.OnChannelReceivedEvent(e, ChannelReceived).ConfigureAwait(false); + if (e.RequestInfo is MessageBase response) + { + if (ChannelReceivedWaitDict.TryRemove(response.Sign, out var func)) + { + await func.Invoke(this, e, ChannelReceived.Count == 1).ConfigureAwait(false); + e.Handled = true; + } + } + if (e.Handled) + return; + + await this.OnChannelReceivedEvent(e, ChannelReceived).ConfigureAwait(false); + } + + /// + protected override void Dispose(bool disposing) + { + WaitHandlePool.SafeDispose(); + base.Dispose(disposing); } } diff --git a/src/Foundation/ThingsGateway.Foundation/Common/IncrementCount.cs b/src/Foundation/ThingsGateway.Foundation/Common/IncrementCount.cs index 64f450d35..5cc339116 100644 --- a/src/Foundation/ThingsGateway.Foundation/Common/IncrementCount.cs +++ b/src/Foundation/ThingsGateway.Foundation/Common/IncrementCount.cs @@ -13,19 +13,18 @@ namespace ThingsGateway.Foundation; /// /// 自增数据类,用于自增数据,可以设置最大值,初始值,自增步长等。 /// -public sealed class IncrementCount : DisposableObject +public sealed class IncrementCount { - private readonly WaitLock easyLock = new(); - private long current = 0; - private long max = long.MaxValue; - private long start = 0; + private long _current = 0; + private long _max = long.MaxValue; + private long _start = 0; /// public IncrementCount(long max, long start = 0, int tick = 1) { - this.start = start; - this.max = max; - current = start; + _start = start; + _max = max; + _current = start; IncreaseTick = tick; } @@ -37,27 +36,28 @@ public sealed class IncrementCount : DisposableObject /// /// 获取当前的计数器的最大的设置值 /// - public long MaxValue => max; + public long MaxValue => _max; /// /// 获取自增信息,获得数据之后,下一次获取将会自增,如果自增后大于最大值,则会重置为最小值,如果小于最小值,则会重置为最大值。 /// public long GetCurrentValue() { - easyLock.Wait(); - long current = this.current; - this.current += IncreaseTick; - if (this.current > max) + lock (this) { - this.current = start; - } - else if (this.current < start) - { - this.current = max; - } + long current = _current; + _current += IncreaseTick; + if (_current > _max) + { + _current = _start; + } + else if (_current < _start) + { + _current = _max; + } - easyLock.Release(); - return current; + return current; + } } /// @@ -65,9 +65,10 @@ public sealed class IncrementCount : DisposableObject /// public void ResetCurrentValue() { - easyLock.Wait(); - current = start; - easyLock.Release(); + lock (this) + { + _current = _start; + } } /// @@ -76,9 +77,11 @@ public sealed class IncrementCount : DisposableObject /// 指定值 public void ResetCurrentValue(long value) { - easyLock.Wait(); - current = value <= max ? value >= start ? value : start : max; - easyLock.Release(); + lock (this) + { + _current = value <= _max ? value >= _start ? value : _start : _max; + + } } /// @@ -86,17 +89,18 @@ public sealed class IncrementCount : DisposableObject /// public void ResetMaxValue(long max) { - easyLock.Wait(); - if (max > start) + lock (this) { - if (max < current) + if (max > _start) { - current = start; - } + if (max < _current) + { + _current = _start; + } - this.max = max; + _max = max; + } } - easyLock.Release(); } /// @@ -105,23 +109,19 @@ public sealed class IncrementCount : DisposableObject /// 初始值 public void ResetStartValue(long start) { - easyLock.Wait(); - if (start < max) + lock (this) { - if (current < start) + if (start < _max) { - current = start; + if (_current < start) + { + _current = start; + } + + _start = start; } - - this.start = start; } - easyLock.Release(); } - /// - protected override void Dispose(bool disposing) - { - easyLock.SafeDispose(); - base.Dispose(disposing); - } + } diff --git a/src/Foundation/ThingsGateway.Foundation/Common/TimeTick.cs b/src/Foundation/ThingsGateway.Foundation/Common/TimeTick.cs index 78d4c971e..6d1b73061 100644 --- a/src/Foundation/ThingsGateway.Foundation/Common/TimeTick.cs +++ b/src/Foundation/ThingsGateway.Foundation/Common/TimeTick.cs @@ -1,39 +1,31 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using ThingsGateway.NewLife.Threading; +using ThingsGateway.NewLife.Threading; namespace ThingsGateway.Foundation; /// -/// 时间刻度器,最小时间间隔10毫秒 +/// 最小时间间隔 10 毫秒 /// public class TimeTick { /// /// 时间间隔(毫秒) /// - private readonly int intervalMilliseconds = 1000; - private readonly Cron cron; + private int _intervalMilliseconds = 1000; + + private readonly Cron? cron; + /// public TimeTick(string delay) { - if (int.TryParse(delay, out intervalMilliseconds)) + // 尝试解析延迟时间 + if (int.TryParse(delay, out int intervalMilliseconds)) { - if (intervalMilliseconds < 10) - intervalMilliseconds = 10; - LastTime = DateTime.Now.AddMilliseconds(-intervalMilliseconds); + _intervalMilliseconds = intervalMilliseconds < 10 ? 10 : intervalMilliseconds; + LastTime = DateTime.UtcNow.AddMilliseconds(-_intervalMilliseconds); // 初始化上次时间 } else { - cron = new Cron(delay); + cron = new Cron(delay); // 解析 Cron 表达式 } } @@ -49,39 +41,38 @@ public class TimeTick /// 是否触发时间刻度 public bool IsTickHappen(DateTime currentTime) { - DateTime nextTime = DateTime.MinValue; + // 在没有 Cron 表达式的情况下,使用固定间隔 if (cron == null) { - nextTime = LastTime.AddMilliseconds(intervalMilliseconds); + var nextTime = LastTime.AddMilliseconds(_intervalMilliseconds); var diffMilliseconds = (currentTime - nextTime).TotalMilliseconds; - if (diffMilliseconds < 0) - return false; - else if (diffMilliseconds * 2 < intervalMilliseconds) - LastTime = nextTime; - else - LastTime = nextTime;//选择当前时间 - return true; + + var result = diffMilliseconds >= 0; + if (result) + { + if (diffMilliseconds > _intervalMilliseconds) + LastTime = currentTime; + else + LastTime = nextTime; + } + return result; } + // 使用 Cron 表达式 else { - nextTime = cron.GetNext(LastTime); + var nextTime = cron.GetNext(LastTime); if (currentTime >= nextTime) { LastTime = nextTime; return true; } - else - { - return false; - } + return false; } - - } /// /// 是否到达设置的时间间隔 /// /// 是否到达设置的时间间隔 - public bool IsTickHappen() => IsTickHappen(DateTime.Now); + public bool IsTickHappen() => IsTickHappen(DateTime.UtcNow); } diff --git a/src/Foundation/ThingsGateway.Foundation/Common/WaitLock.cs b/src/Foundation/ThingsGateway.Foundation/Common/WaitLock.cs index 14fcdb16a..73a2ab43f 100644 --- a/src/Foundation/ThingsGateway.Foundation/Common/WaitLock.cs +++ b/src/Foundation/ThingsGateway.Foundation/Common/WaitLock.cs @@ -11,40 +11,50 @@ namespace ThingsGateway.Foundation; /// -/// WaitLock,使用轻量级SemaphoreSlim锁,只允许一个并发量,并记录并发信息 +/// WaitLock,使用轻量级SemaphoreSlim锁 /// public sealed class WaitLock : DisposableObject { - private readonly SemaphoreSlim m_waiterLock = new SemaphoreSlim(1, 1); + private readonly SemaphoreSlim _waiterLock; - /// - public WaitLock(bool initialState = true) + /// + /// 构造方法 + /// + /// 最大并发数 + /// 初始无信号量 + public WaitLock(int maxCount = 1, bool initialZeroState = false) { - if (!initialState) - m_waiterLock.Wait(); + if (initialZeroState) + _waiterLock = new SemaphoreSlim(0, maxCount); + else + _waiterLock = new SemaphoreSlim(maxCount, maxCount); + + MaxCount = maxCount; } + /// + /// 最大并发数 + /// + public int MaxCount { get; } + /// ~WaitLock() { this.SafeDispose(); } - /// - /// 当前锁是否在等待当中 - /// - public bool IsWaitting => m_waiterLock.CurrentCount == 0; + public bool Waited => _waiterLock.CurrentCount == 0; + + public int CurrentCount => _waiterLock.CurrentCount; + public bool Waitting => _waiterLock.CurrentCount < MaxCount; /// /// 离开锁 /// public void Release() { - lock (this) - { - if (IsWaitting) - m_waiterLock.Release(); - } + if (DisposedValue) return; + _waiterLock.Release(); } /// @@ -52,16 +62,15 @@ public sealed class WaitLock : DisposableObject /// public void Wait(CancellationToken cancellationToken = default) { - m_waiterLock.Wait(cancellationToken); + _waiterLock.Wait(cancellationToken); } /// /// 进入锁 /// - public bool Wait(TimeSpan timeSpan, CancellationToken cancellationToken = default) + public bool Wait(int millisecondsTimeout, CancellationToken cancellationToken = default) { - var data = m_waiterLock.Wait(timeSpan, cancellationToken); - return data; + return _waiterLock.Wait(millisecondsTimeout, cancellationToken); } /// @@ -69,21 +78,21 @@ public sealed class WaitLock : DisposableObject /// public Task WaitAsync(CancellationToken cancellationToken = default) { - return m_waiterLock.WaitAsync(cancellationToken); + return _waiterLock.WaitAsync(cancellationToken); } /// /// 进入锁 /// - public Task WaitAsync(TimeSpan timeSpan, CancellationToken cancellationToken = default) + public Task WaitAsync(int millisecondsTimeout, CancellationToken cancellationToken = default) { - return m_waiterLock.WaitAsync(timeSpan, cancellationToken); + return _waiterLock.WaitAsync(millisecondsTimeout, cancellationToken); } /// protected override void Dispose(bool disposing) { - m_waiterLock.SafeDispose(); base.Dispose(disposing); + _waiterLock.SafeDispose(); } } diff --git a/src/Foundation/ThingsGateway.Foundation/Trans/EncodingConverter.cs b/src/Foundation/ThingsGateway.Foundation/Converter/EncodingConverter.cs similarity index 100% rename from src/Foundation/ThingsGateway.Foundation/Trans/EncodingConverter.cs rename to src/Foundation/ThingsGateway.Foundation/Converter/EncodingConverter.cs diff --git a/src/Foundation/ThingsGateway.Foundation/Converter/JsonStringToClassSerializerFormatter.cs b/src/Foundation/ThingsGateway.Foundation/Converter/JsonToClassConverter.cs similarity index 77% rename from src/Foundation/ThingsGateway.Foundation/Converter/JsonStringToClassSerializerFormatter.cs rename to src/Foundation/ThingsGateway.Foundation/Converter/JsonToClassConverter.cs index 1a016802e..4cc9988e6 100644 --- a/src/Foundation/ThingsGateway.Foundation/Converter/JsonStringToClassSerializerFormatter.cs +++ b/src/Foundation/ThingsGateway.Foundation/Converter/JsonToClassConverter.cs @@ -15,24 +15,19 @@ namespace ThingsGateway.Foundation; /// /// Json字符串转到对应类 /// -public class JsonStringToClassSerializerFormatter : ISerializerFormatter +public class JsonToClassConverter : ISerializerFormatter { - /// - /// JsonSettings - /// - public JsonSerializerSettings JsonSettings { get; set; } = new JsonSerializerSettings(); - /// /// /// - public int Order { get; set; } = -99; + public int Order { get; set; } = 100; /// public bool TryDeserialize(TState state, in string source, Type targetType, out object target) { try { - target = JsonConvert.DeserializeObject(source, targetType, JsonSettings)!; + target = JsonConvert.DeserializeObject(source, targetType); return true; } catch @@ -47,12 +42,12 @@ public class JsonStringToClassSerializerFormatter : ISerializerFormatter { try { - source = JsonConvert.SerializeObject(target, JsonSettings); + source = JsonConvert.SerializeObject(target); return true; } catch (Exception) { - source = null; + source = default; return false; } } diff --git a/src/Foundation/ThingsGateway.Foundation/Converter/StringConverter.cs b/src/Foundation/ThingsGateway.Foundation/Converter/StringConverter.cs index 97492d805..b1f96bc90 100644 --- a/src/Foundation/ThingsGateway.Foundation/Converter/StringConverter.cs +++ b/src/Foundation/ThingsGateway.Foundation/Converter/StringConverter.cs @@ -20,7 +20,7 @@ public class StringToClassConverter : ISerializerFormatter /// /// - public int Order { get; set; } = -100; + public int Order { get; set; } = 0; /// public bool TryDeserialize(TState state, in string source, Type targetType, out object target) diff --git a/src/Foundation/ThingsGateway.Foundation/Converter/StringToEncodingConverter.cs b/src/Foundation/ThingsGateway.Foundation/Converter/StringToEncodingConverter.cs deleted file mode 100644 index 28cfc4671..000000000 --- a/src/Foundation/ThingsGateway.Foundation/Converter/StringToEncodingConverter.cs +++ /dev/null @@ -1,60 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using System.Text; - -namespace ThingsGateway.Foundation; - -/// -public class StringToEncodingConverter : ISerializerFormatter -{ - /// - public int Order { get; set; } - - /// - public bool TryDeserialize(object state, in string source, Type targetType, out object target) - { - try - { - target = Encoding.Default; - if (targetType == typeof(Encoding)) - { - target = Encoding.GetEncoding(source); - return true; - } - } - catch - { - target = default; - return false; - } - return false; - } - - /// - public bool TrySerialize(object state, in object target, out string source) - { - try - { - if (target?.GetType() == typeof(Encoding)) - { - source = (target as Encoding).WebName; - return true; - } - source = target.ToJsonString(); - return true; - } - catch (Exception) - { - source = null; - return false; - } - } -} diff --git a/src/Foundation/ThingsGateway.Foundation/Converter/ThingsGatewayStringConverter.cs b/src/Foundation/ThingsGateway.Foundation/Converter/ThingsGatewayStringConverter.cs index 3ab7d3de3..2aacababd 100644 --- a/src/Foundation/ThingsGateway.Foundation/Converter/ThingsGatewayStringConverter.cs +++ b/src/Foundation/ThingsGateway.Foundation/Converter/ThingsGatewayStringConverter.cs @@ -26,6 +26,6 @@ public class ThingsGatewayStringConverter : StringSerializerConverter public ThingsGatewayStringConverter(params ISerializerFormatter[] converters) : base(converters) { Add(new StringToClassConverter()); - Add(new JsonStringToClassSerializerFormatter()); + Add(new JsonToClassConverter()); } } diff --git a/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/DeviceSingleStreamDataHandleAdapter.cs b/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/DeviceSingleStreamDataHandleAdapter.cs new file mode 100644 index 000000000..d6012d847 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/DeviceSingleStreamDataHandleAdapter.cs @@ -0,0 +1,209 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Text; + +namespace ThingsGateway.Foundation; + +/// +/// TCP/Serial适配器基类 +/// +public class DeviceSingleStreamDataHandleAdapter : CustomDataHandlingAdapter where TRequest : MessageBase, new() +{ + /// + public DeviceSingleStreamDataHandleAdapter() + { + CacheTimeoutEnable = true; + SurLength = int.MaxValue; + } + + /// + public override bool CanSendRequestInfo => true; + + /// + public override bool CanSplicingSend => false; + + /// + /// 报文输出时采用字符串还是HexString + /// + public virtual bool IsHexLog { get; set; } = true; + + public virtual bool IsSingleThread { get; set; } = true; + + /// + /// 非并发协议中,每次交互的对象,会在发送时重新获取 + /// + public TRequest Request { get; set; } + + /// + public void SetRequest(ISendMessage sendMessage, ref ValueByteBlock byteBlock) + { + var request = GetInstance(); + request.Sign = sendMessage.Sign; + request.SendInfo(sendMessage, ref byteBlock); + Request = request; + } + + /// + public override string? ToString() + { + return Owner?.ToString(); + } + + /// + protected override FilterResult Filter(ref TByteBlock byteBlock, bool beCached, ref TRequest request, ref int tempCapacity) + { + if (Logger?.LogLevel <= LogLevel.Trace) + Logger?.Trace($"{ToString()}- Receive:{(IsHexLog ? byteBlock.AsSegmentTake().ToHexString() : byteBlock.ToString(byteBlock.Position))}"); + + try + { + if (IsSingleThread) + request = Request == null ? GetInstance() : Request; + else + { + if (!beCached) + request = GetInstance(); + } + + var pos = byteBlock.Position; + + if (request.HeaderLength > byteBlock.CanReadLength) + { + return FilterResult.Cache;//当头部都无法解析时,直接缓存 + } + + //检查头部合法性 + if (request.CheckHead(ref byteBlock)) + { + byteBlock.Position = pos; + if (request.BodyLength > MaxPackageSize) + { + request.OperCode = -1; + request.ErrorMessage = $"Received BodyLength={request.BodyLength}, greater than the set MaxPackageSize={MaxPackageSize}"; + OnError(default, request.ErrorMessage, true, true); + SetResult(request); + return FilterResult.GoOn; + } + if (request.BodyLength + request.HeaderLength > byteBlock.CanReadLength) + { + //body不满足解析,开始缓存,然后保存对象 + tempCapacity = request.BodyLength + request.HeaderLength; + return FilterResult.Cache; + } + //if (request.BodyLength <= 0) + //{ + // //如果body长度无法确定,直接读取全部 + // request.BodyLength = byteBlock.Length; + //} + var headPos = pos + request.HeaderLength; + byteBlock.Position = headPos; + var result = request.CheckBody(ref byteBlock); + if (result == FilterResult.Cache) + { + if (Logger?.LogLevel <= LogLevel.Trace) + Logger?.Trace($"{ToString()}-Received incomplete, cached message, current length:{byteBlock.Length} {request?.ErrorMessage}"); + tempCapacity = request.BodyLength + request.HeaderLength; + request.OperCode = -1; + } + else if (result == FilterResult.GoOn) + { + byteBlock.Position = pos + request.BodyLength + request.HeaderLength; + Logger?.Trace($"{ToString()}-{request?.ToString()}"); + request.OperCode = -1; + SetResult(request); + } + else if (result == FilterResult.Success) + { + byteBlock.Position = request.HeaderLength + request.BodyLength + pos; + } + return result; + } + else + { + byteBlock.Position = pos + 1;//移动游标 + request.OperCode = -1; + SetResult(request); + return FilterResult.GoOn;//放弃解析 + } + } + catch (Exception ex) + { + Logger?.LogWarning(ex, $"{ToString()} Received parsing error"); + byteBlock.Position = byteBlock.Length;//移动游标 + request.Exception = ex; + request.OperCode = -1; + SetResult(request); + return FilterResult.GoOn;//放弃解析 + } + } + + private void SetResult(TRequest request) + { + if ((Owner as IClientChannel)?.WaitHandlePool?.TryGetDataAsync(request.Sign, out var waitDataAsync) == true) + { + waitDataAsync.SetResult(request); + } + } + + /// + /// 获取泛型实例。 + /// + /// + protected virtual TRequest GetInstance() + { + return new TRequest() { OperCode = -1, Sign = -1 }; + } + + /// + protected override void OnReceivedSuccess(TRequest request) + { + Request = null; + } + + + + /// + protected override async Task PreviewSendAsync(ReadOnlyMemory memory) + { + if (Logger?.LogLevel <= LogLevel.Trace) + Logger?.Trace($"{ToString()}- Send:{(IsHexLog ? memory.Span.ToHexString() : (memory.Span.ToString(Encoding.UTF8)))}"); + + //发送 + await GoSendAsync(memory).ConfigureAwait(false); + } + + /// + protected override async Task PreviewSendAsync(IRequestInfo requestInfo) + { + if (!(requestInfo is ISendMessage sendMessage)) + { + throw new Exception($"Unable to convert {nameof(requestInfo)} to {nameof(ISendMessage)}"); + } + + var byteBlock = new ValueByteBlock(sendMessage.MaxLength); + try + { + sendMessage.Build(ref byteBlock); + if (Logger?.LogLevel <= LogLevel.Trace) + Logger?.Trace($"{ToString()}- Send:{(IsHexLog ? byteBlock.Span.ToHexString() : (byteBlock.Span.ToString(Encoding.UTF8)))}"); + //非并发主从协议 + if (IsSingleThread) + { + SetRequest(sendMessage, ref byteBlock); + } + await GoSendAsync(byteBlock.Memory).ConfigureAwait(false); + } + finally + { + byteBlock.SafeDispose(); + } + } +} diff --git a/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/ProtocolUdpDataHandleAdapter.cs b/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/DeviceUdpDataHandleAdapter.cs similarity index 86% rename from src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/ProtocolUdpDataHandleAdapter.cs rename to src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/DeviceUdpDataHandleAdapter.cs index 637ccfe80..c2df5ff2a 100644 --- a/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/ProtocolUdpDataHandleAdapter.cs +++ b/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/DeviceUdpDataHandleAdapter.cs @@ -16,7 +16,7 @@ namespace ThingsGateway.Foundation; /// /// UDP适配器基类 /// -public class ProtocolUdpDataHandleAdapter : UdpDataHandlingAdapter where TRequest : MessageBase, new() +public class DeviceUdpDataHandleAdapter : UdpDataHandlingAdapter where TRequest : MessageBase, new() { /// public override bool CanSendRequestInfo => true; @@ -27,12 +27,9 @@ public class ProtocolUdpDataHandleAdapter : UdpDataHandlingAdapter whe /// /// 报文输出时采用字符串还是HexString /// - public virtual bool IsHexData { get; init; } = true; + public virtual bool IsHexLog { get; set; } = true; - /// - /// 是否非并发协议 - /// - public virtual bool IsSingleThread { get; init; } = true; + public virtual bool IsSingleThread { get; set; } = true; /// /// 非并发协议中,每次交互的对象,会在发送时重新获取 @@ -68,8 +65,10 @@ public class ProtocolUdpDataHandleAdapter : UdpDataHandlingAdapter whe { try { + byteBlock.Position = 0; + if (Logger?.LogLevel <= LogLevel.Trace) - Logger?.Trace($"{ToString()}- Receive:{(IsHexData ? byteBlock.AsSegmentTake().ToHexString() : byteBlock.ToString(byteBlock.Position))}"); + Logger?.Trace($"{ToString()}- Receive:{(IsHexLog ? byteBlock.AsSegmentTake().ToHexString() : byteBlock.ToString(byteBlock.Position))}"); TRequest request = null; if (IsSingleThread) @@ -120,6 +119,10 @@ public class ProtocolUdpDataHandleAdapter : UdpDataHandlingAdapter whe byteBlock.Position = pos + request.BodyLength + request.HeaderLength; Logger?.Trace($"{ToString()}-{request?.ToString()}"); request.OperCode = -1; + if ((Owner as IClientChannel)?.WaitHandlePool?.TryGetDataAsync(request.Sign, out var waitDataAsync) == true) + { + waitDataAsync.SetResult(request); + } } else if (result == FilterResult.Success) { @@ -147,7 +150,7 @@ public class ProtocolUdpDataHandleAdapter : UdpDataHandlingAdapter whe protected override async Task PreviewSendAsync(EndPoint endPoint, ReadOnlyMemory memory) { if (Logger?.LogLevel <= LogLevel.Trace) - Logger?.Trace($"{ToString()}- Send:{(IsHexData ? memory.Span.ToHexString() : (memory.Span.ToString(Encoding.UTF8)))}"); + Logger?.Trace($"{ToString()}- Send:{(IsHexLog ? memory.Span.ToHexString() : (memory.Span.ToString(Encoding.UTF8)))}"); //发送 await GoSendAsync(endPoint, memory).ConfigureAwait(false); @@ -167,8 +170,8 @@ public class ProtocolUdpDataHandleAdapter : UdpDataHandlingAdapter whe { sendMessage.Build(ref byteBlock); if (Logger?.LogLevel <= LogLevel.Trace) - Logger?.Trace($"{ToString()}- Send:{(IsHexData ? byteBlock.Span.ToHexString() : (byteBlock.Span.ToString(Encoding.UTF8)))}"); - //非并发主从协议 + Logger?.Trace($"{ToString()}- Send:{(IsHexLog ? byteBlock.Span.ToHexString() : (byteBlock.Span.ToString(Encoding.UTF8)))}"); + if (IsSingleThread) { SetRequest(sendMessage, ref byteBlock); diff --git a/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/ProtocolSingleStreamDataHandleAdapter.cs b/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/ProtocolSingleStreamDataHandleAdapter.cs deleted file mode 100644 index 913306723..000000000 --- a/src/Foundation/ThingsGateway.Foundation/DataHandleAdapter/ProtocolSingleStreamDataHandleAdapter.cs +++ /dev/null @@ -1,196 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using System.Text; - -namespace ThingsGateway.Foundation; - -/// -/// TCP/Serial适配器基类 -/// -public class ProtocolSingleStreamDataHandleAdapter : CustomDataHandlingAdapter where TRequest : MessageBase, new() -{ - /// - public ProtocolSingleStreamDataHandleAdapter() - { - CacheTimeoutEnable = true; - } - - /// - public override bool CanSendRequestInfo => true; - - /// - public override bool CanSplicingSend => false; - - /// - /// 报文输出时采用字符串还是HexString - /// - public virtual bool IsHexData { get; init; } = true; - - /// - /// 是否非并发协议 - /// - public virtual bool IsSingleThread { get; init; } = true; - - /// - /// 非并发协议中,每次交互的对象,会在发送时重新获取 - /// - public TRequest Request { get; set; } - - /// - public void SetRequest(ISendMessage sendMessage, ref ValueByteBlock byteBlock) - { - var request = GetInstance(); - request.Sign = sendMessage.Sign; - request.SendInfo(sendMessage, ref byteBlock); - Request = request; - } - - /// - public override string? ToString() - { - return Owner?.ToString(); - } - - /// - protected override FilterResult Filter(ref TByteBlock byteBlock, bool beCached, ref TRequest request, ref int tempCapacity) - { - if (Logger?.LogLevel <= LogLevel.Trace) - Logger?.Trace($"{ToString()}- Receive:{(IsHexData ? byteBlock.AsSegmentTake().ToHexString() : byteBlock.ToString(byteBlock.Position))}"); - - try - { - { - //非并发协议,复用对象 - if (IsSingleThread) - request = Request == null ? GetInstance() : Request; - else - { - if (!beCached) - request = GetInstance(); - } - - var pos = byteBlock.Position; - - if (request.HeaderLength > byteBlock.CanReadLength) - { - return FilterResult.Cache;//当头部都无法解析时,直接缓存 - } - - //检查头部合法性 - if (request.CheckHead(ref byteBlock)) - { - byteBlock.Position = pos; - if (request.BodyLength > MaxPackageSize) - { - OnError(default, $"Received BodyLength={request.BodyLength}, greater than the set MaxPackageSize={MaxPackageSize}", true, true); - return FilterResult.GoOn; - } - if (request.BodyLength + request.HeaderLength > byteBlock.CanReadLength) - { - //body不满足解析,开始缓存,然后保存对象 - tempCapacity = request.BodyLength + request.HeaderLength; - return FilterResult.Cache; - } - //if (request.BodyLength <= 0) - //{ - // //如果body长度无法确定,直接读取全部 - // request.BodyLength = byteBlock.Length; - //} - var headPos = pos + request.HeaderLength; - byteBlock.Position = headPos; - var result = request.CheckBody(ref byteBlock); - if (result == FilterResult.Cache) - { - if (Logger?.LogLevel <= LogLevel.Trace) - Logger?.Trace($"{ToString()}-Received incomplete, cached message, current length:{byteBlock.Length} {request?.ErrorMessage}"); - tempCapacity = request.BodyLength + request.HeaderLength; - request.OperCode = -1; - } - else if (result == FilterResult.GoOn) - { - byteBlock.Position = pos + request.BodyLength + request.HeaderLength; - Logger?.Trace($"{ToString()}-{request?.ToString()}"); - request.OperCode = -1; - } - else if (result == FilterResult.Success) - { - byteBlock.Position = request.HeaderLength + request.BodyLength + pos; - } - return result; - } - else - { - byteBlock.Position = pos + 1;//移动游标 - request.OperCode = -1; - return FilterResult.GoOn;//放弃解析 - } - } - } - catch (Exception ex) - { - Logger?.LogWarning(ex, $"{ToString()} Received parsing error"); - byteBlock.Position = byteBlock.Length;//移动游标 - return FilterResult.GoOn;//放弃解析 - } - } - - /// - /// 获取泛型实例。 - /// - /// - protected virtual TRequest GetInstance() - { - return new TRequest() { OperCode = -1 }; - } - - /// - protected override void OnReceivedSuccess(TRequest request) - { - Request = null; - } - - /// - protected override async Task PreviewSendAsync(ReadOnlyMemory memory) - { - if (Logger?.LogLevel <= LogLevel.Trace) - Logger?.Trace($"{ToString()}- Send:{(IsHexData ? memory.Span.ToHexString() : (memory.Span.ToString(Encoding.UTF8)))}"); - - //发送 - await GoSendAsync(memory).ConfigureAwait(false); - } - - /// - protected override async Task PreviewSendAsync(IRequestInfo requestInfo) - { - if (!(requestInfo is ISendMessage sendMessage)) - { - throw new Exception($"Unable to convert {nameof(requestInfo)} to {nameof(ISendMessage)}"); - } - - var byteBlock = new ValueByteBlock(sendMessage.MaxLength); - try - { - sendMessage.Build(ref byteBlock); - if (Logger?.LogLevel <= LogLevel.Trace) - Logger?.Trace($"{ToString()}- Send:{(IsHexData ? byteBlock.Span.ToHexString() : (byteBlock.Span.ToString(Encoding.UTF8)))}"); - //非并发主从协议 - if (IsSingleThread) - { - SetRequest(sendMessage, ref byteBlock); - } - await GoSendAsync(byteBlock.Memory).ConfigureAwait(false); - } - finally - { - byteBlock.SafeDispose(); - } - } -} diff --git a/src/Foundation/ThingsGateway.Foundation/Protocol/ProtocolBase.cs b/src/Foundation/ThingsGateway.Foundation/Device/DeviceBase.cs similarity index 78% rename from src/Foundation/ThingsGateway.Foundation/Protocol/ProtocolBase.cs rename to src/Foundation/ThingsGateway.Foundation/Device/DeviceBase.cs index 74f74a45e..cbda20667 100644 --- a/src/Foundation/ThingsGateway.Foundation/Protocol/ProtocolBase.cs +++ b/src/Foundation/ThingsGateway.Foundation/Device/DeviceBase.cs @@ -12,6 +12,7 @@ using Newtonsoft.Json.Linq; using ThingsGateway.Foundation.Extension.Generic; using ThingsGateway.Foundation.Extension.String; +using ThingsGateway.NewLife; using ThingsGateway.NewLife.Extension; using TouchSocket.Resources; @@ -21,45 +22,118 @@ namespace ThingsGateway.Foundation; /// /// 协议基类 /// -public abstract class ProtocolBase : DisposableObject, IProtocol +public abstract class DeviceBase : DisposableObject, IDevice { /// - public ProtocolBase(IChannel channel) + public IChannel Channel { get; private set; } + + public virtual bool SupportMultipleDevice() + { + return true; + } + + /// + public virtual void InitChannel(IChannel channel, ILog? deviceLog = default) { if (channel == null) throw new ArgumentNullException(nameof(channel)); - lock (channel) + if (channel.Collects.Contains(this)) + return; + Channel = channel; + _deviceLogger = deviceLog; + lock (Channel) { + if (channel.Collects.Contains(this)) + return; + if (channel.Collects.Count > 0) + { + var device = channel.Collects.First(); + if (device.GetType() != GetType()) + throw new InvalidOperationException("The channel already exists in the device of another type"); + + if (!SupportMultipleDevice()) + throw new InvalidOperationException("The proactive response device does not support multiple devices"); + } + + if (channel.Collects.Count == 0) + { + channel.Config.ConfigurePlugins(ConfigurePlugins(channel.Config)); + + if (Channel is IClientChannel clientChannel) + { + + if (clientChannel.ChannelType == ChannelTypeEnum.UdpSession) + { + channel.Config.SetUdpDataHandlingAdapter(() => + { + var adapter = GetDataAdapter() as UdpDataHandlingAdapter; + return adapter; + }); + } + else + { + channel.Config.SetTcpDataHandlingAdapter(() => + { + var adapter = GetDataAdapter() as SingleStreamDataHandlingAdapter; + return adapter; + }); + } + clientChannel.SetDataHandlingAdapter(GetDataAdapter()); + } + else if (Channel is TcpServiceChannel serviceChannel) + { + channel.Config.SetTcpDataHandlingAdapter(() => + { + var adapter = GetDataAdapter() as SingleStreamDataHandlingAdapter; + return adapter; + }); + } + + + } + channel.Collects.Add(this); - Channel = channel; - Logger = channel.Logger; Channel.Starting.Add(ChannelStarting); Channel.Stoped.Add(ChannelStoped); Channel.Stoping.Add(ChannelStoping); Channel.Started.Add(ChannelStarted); Channel.ChannelReceived.Add(ChannelReceived); - Channel.Config.ConfigurePlugins(ConfigurePlugins()); } } /// - ~ProtocolBase() + ~DeviceBase() { this.SafeDispose(); } #region 属性 - /// - public virtual int CacheTimeout { get; set; } = 1000; - /// public virtual int SendDelayTime { get; set; } /// public virtual int Timeout { get; set; } = 3000; + private ILog? _deviceLogger; + /// - public virtual ushort ConnectTimeout { get; set; } = 3000; + public virtual ILog? Logger + { + get + { + return _deviceLogger ?? Channel?.Logger; + } + } + + /// + public virtual int RegisterByteLength { get; protected set; } = 1; + + /// + public virtual IThingsGatewayBitConverter ThingsGatewayBitConverter { get; protected set; } = new ThingsGatewayBitConverter(); + + /// + public bool OnLine => Channel.Online; + /// /// @@ -83,21 +157,6 @@ public abstract class ProtocolBase : DisposableObject, IProtocol set => ThingsGatewayBitConverter.DataFormat = value; } - /// - public virtual IChannel Channel { get; } - - /// - public virtual ILog? Logger { get; protected set; } - - /// - public virtual int RegisterByteLength { get; protected set; } = 1; - - /// - public virtual IThingsGatewayBitConverter ThingsGatewayBitConverter { get; protected set; } = new ThingsGatewayBitConverter(); - - /// - public bool OnLine => Channel.Online; - #endregion 属性 #region 适配器 @@ -108,28 +167,23 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// /// 通道连接成功时,如果通道存在其他设备并且不希望其他设备处理时,返回true /// - /// - /// - protected virtual Task ChannelStarted(IClientChannel channel) + protected virtual ValueTask ChannelStarted(IClientChannel channel, bool last) { - return Task.FromResult(false); - } - /// - /// 通道断开连接前,如果通道存在其他设备并且不希望其他设备处理时,返回null - /// - /// - /// - protected virtual Task ChannelStoping(IClientChannel channel) - { - return Task.FromResult(false); + return EasyValueTask.FromResult(true); } /// - /// 通道断开连接后,如果通道存在其他设备并且不希望其他设备处理时,返回null + /// 通道断开连接前,如果通道存在其他设备并且不希望其他设备处理时,返回true /// - /// - /// - protected Task ChannelStoped(IClientChannel channel) + protected virtual ValueTask ChannelStoping(IClientChannel channel, bool last) + { + return EasyValueTask.FromResult(true); + } + + /// + /// 通道断开连接后,如果通道存在其他设备并且不希望其他设备处理时,返回true + /// + protected ValueTask ChannelStoped(IClientChannel channel, bool last) { try { @@ -139,33 +193,36 @@ public abstract class ProtocolBase : DisposableObject, IProtocol { } - return Task.FromResult(false); + return EasyValueTask.FromResult(true); } /// - /// 通道即将连接成功时,会设置适配器,如果通道存在其他设备并且不希望其他设备处理时,返回null + /// 通道即将连接成功时,会设置适配器,如果通道存在其他设备并且不希望其他设备处理时,返回true /// - /// - /// - protected virtual Task ChannelStarting(IClientChannel channel) + protected virtual ValueTask ChannelStarting(IClientChannel channel, bool last) { - channel.SetDataHandlingAdapter(GetDataAdapter()); - return Task.FromResult(false); - } - - /// - /// 检测通道是否存在其他设备,如果有的话会重新设置,没有则无任何操作 - /// - protected virtual void SetDataAdapter() - { - if (Channel.Collects.Count > 1) + if (channel.ReadOnlyDataHandlingAdapter != null) { - if (Channel is IClientChannel clientChannel) - { - var dataHandlingAdapter = GetDataAdapter(); - if (dataHandlingAdapter.GetType() != clientChannel.ReadOnlyDataHandlingAdapter?.GetType()) - clientChannel.SetDataHandlingAdapter(dataHandlingAdapter); - } + channel.ReadOnlyDataHandlingAdapter.Logger = Logger; + } + return EasyValueTask.FromResult(true); + } + + /// + /// 设置适配器 + /// + protected virtual void SetDataAdapter(IClientChannel clientChannel) + { + var adapter = clientChannel.ReadOnlyDataHandlingAdapter; + if (adapter == null) + { + var dataHandlingAdapter = GetDataAdapter(); + clientChannel.SetDataHandlingAdapter(dataHandlingAdapter); + dataHandlingAdapter.Logger = Logger; + } + else + { + adapter.Logger = Logger; } } @@ -228,40 +285,27 @@ public abstract class ProtocolBase : DisposableObject, IProtocol #region 设备异步返回 - /// - public Task ConnectAsync(CancellationToken cancellationToken = default) - { - return Channel.ConnectAsync(ConnectTimeout, cancellationToken); - } - - /// - public Task CloseAsync(string msg = default) - { - return Channel.CloseAsync(msg); - } + /// + /// 日志输出16进制 + /// + public virtual bool IsHexLog { get; init; } = true; /// /// 接收,非主动发送的情况,重写实现非主从并发通讯协议,如果通道存在其他设备并且不希望其他设备处理时,设置 为true /// - /// - /// - /// - protected virtual Task ChannelReceived(IClientChannel client, ReceivedDataEventArgs e) + protected virtual Task ChannelReceived(IClientChannel client, ReceivedDataEventArgs e, bool last) { if (e.RequestInfo is MessageBase response) { try { - if (!client.WaitHandlePool.SetRun(response)) - { - //非主动发送的情况,重写实现非主从并发通讯协议 - } - else + + if (client.WaitHandlePool.SetRun(response)) { e.Handled = true; - //Logger?.LogTrace($"MessageBase.Sign : {response.Sign}"); } + } catch (Exception ex) { @@ -273,57 +317,56 @@ public abstract class ProtocolBase : DisposableObject, IProtocol } /// - public virtual async ValueTask SendAsync(ISendMessage sendMessage, IClientChannel channel = default, CancellationToken token = default) + private async ValueTask SendAsync(ISendMessage sendMessage, IClientChannel channel = default, CancellationToken token = default) { try { + if (channel == default) + { + if (Channel is not IClientChannel clientChannel) { throw new ArgumentNullException(nameof(channel)); } + channel = clientChannel; + } + + SetDataAdapter(channel); try { if (!Channel.Online) - await Channel.ConnectAsync(ConnectTimeout, token).ConfigureAwait(false); + await Channel.ConnectAsync(Channel.ChannelOptions.ConnectTimeout, token).ConfigureAwait(false); } catch (Exception ex) { - await Task.Delay(200, token).ConfigureAwait(false); + await Task.Delay(1000, token).ConfigureAwait(false); return new(ex); } - if (SendDelayTime != 0) - await Task.Delay(SendDelayTime, token).ConfigureAwait(false); - if (token.IsCancellationRequested) return new(new OperationCanceledException()); - if (channel == default) + + if (token.IsCancellationRequested) + return new(new OperationCanceledException()); + + + if (SendDelayTime != 0) + await Task.Delay(SendDelayTime, token).ConfigureAwait(false); + + try { - if (Channel is not IClientChannel clientChannel) { throw new ArgumentNullException(nameof(channel)); } - try - { - if (IsSingleThread) - await clientChannel.WaitLock.WaitAsync(token).ConfigureAwait(false); - await clientChannel.SendAsync(sendMessage).ConfigureAwait(false); - } - finally - { - if (IsSingleThread) - clientChannel.WaitLock.Release(); - } + if (!Channel.Online) + await Channel.ConnectAsync(Channel.ChannelOptions.ConnectTimeout, token).ConfigureAwait(false); } - else + catch (Exception ex) { - try - { - if (IsSingleThread) - await channel.WaitLock.WaitAsync(token).ConfigureAwait(false); - await channel.SendAsync(sendMessage).ConfigureAwait(false); - } - finally - { - if (IsSingleThread) - channel.WaitLock.Release(); - } + await Task.Delay(1000, token).ConfigureAwait(false); + return new(ex); } + + if (token.IsCancellationRequested) + return new(new OperationCanceledException()); + + await channel.SendAsync(sendMessage).ConfigureAwait(false); + return OperResult.Success; } catch (Exception ex) @@ -333,33 +376,50 @@ public abstract class ProtocolBase : DisposableObject, IProtocol } /// - public virtual async ValueTask SendAsync(ISendMessage sendMessage, string socketId, CancellationToken cancellationToken) + public virtual async ValueTask SendAsync(ISendMessage sendMessage, CancellationToken cancellationToken) { try { - var channelResult = await GetChannelAsync(socketId).ConfigureAwait(false); + + var channelResult = await GetChannelAsync(this is IDtu dtu ? dtu.DtuId : null).ConfigureAwait(false); if (!channelResult.IsSuccess) return new OperResult(channelResult); - return await SendAsync(sendMessage, channelResult.Content, cancellationToken).ConfigureAwait(false); + try + { + await channelResult.Content.WaitLock.WaitAsync(cancellationToken).ConfigureAwait(false); + return await SendAsync(sendMessage, channelResult.Content, cancellationToken).ConfigureAwait(false); + } + finally + { + channelResult.Content.WaitLock.Release(); + } } catch (Exception ex) { return new(ex); } + } /// public virtual async ValueTask> GetChannelAsync(string socketId) { - if (Channel.ChannelType == ChannelTypeEnum.TcpService) + if (string.IsNullOrWhiteSpace(socketId)) + return new OperResult() { Content = (IClientChannel)Channel }; + + if (Channel is TcpServiceChannel serviceChannel) { - if (((TcpServiceChannel)Channel).TryGetClient($"ID={socketId}", out TcpSessionClientChannel? client)) + if (serviceChannel.TryGetClient($"ID={socketId}", out TcpSessionClientChannel? client)) { return new OperResult() { Content = client }; } else { await Task.Delay(1000).ConfigureAwait(false); + if (serviceChannel.TryGetClient($"ID={socketId}", out TcpSessionClientChannel? client1)) + { + return new OperResult() { Content = client1 }; + } return (new OperResult(DefaultResource.Localizer["DtuNoConnectedWaining", socketId])); } } @@ -368,27 +428,27 @@ public abstract class ProtocolBase : DisposableObject, IProtocol } /// - public virtual async ValueTask> SendThenReturnAsync(ISendMessage sendMessage, string socketId, WaitDataAsync waitData = default, CancellationToken cancellationToken = default) + public virtual async ValueTask> SendThenReturnAsync(ISendMessage sendMessage, CancellationToken cancellationToken = default) { - var channelResult = await GetChannelAsync(socketId).ConfigureAwait(false); + var channelResult = await GetChannelAsync(this is IDtu dtu ? dtu.DtuId : null).ConfigureAwait(false); if (!channelResult.IsSuccess) return new OperResult(channelResult); - return await SendThenReturnAsync(sendMessage, channelResult.Content, waitData, cancellationToken).ConfigureAwait(false); + return await SendThenReturnAsync(sendMessage, channelResult.Content, cancellationToken).ConfigureAwait(false); } /// - protected virtual async ValueTask SendThenReturnMessageAsync(ISendMessage sendMessage, string socketId, WaitDataAsync waitData = default, CancellationToken cancellationToken = default) + protected virtual async ValueTask SendThenReturnMessageAsync(ISendMessage sendMessage, CancellationToken cancellationToken = default) { - var channelResult = await GetChannelAsync(socketId).ConfigureAwait(false); + var channelResult = await GetChannelAsync(this is IDtu dtu ? dtu.DtuId : null).ConfigureAwait(false); if (!channelResult.IsSuccess) return new MessageBase(channelResult); - return await SendThenReturnMessageBaseAsync(sendMessage, channelResult.Content, waitData, cancellationToken).ConfigureAwait(false); + return await SendThenReturnMessageBaseAsync(sendMessage, channelResult.Content, cancellationToken).ConfigureAwait(false); } /// - public virtual async ValueTask> SendThenReturnAsync(ISendMessage sendMessage, IClientChannel channel = default, WaitDataAsync waitData = default, CancellationToken cancellationToken = default) + public virtual async ValueTask> SendThenReturnAsync(ISendMessage sendMessage, IClientChannel channel, CancellationToken cancellationToken = default) { try { - var result = await SendThenReturnMessageBaseAsync(sendMessage, channel, waitData, cancellationToken).ConfigureAwait(false); + var result = await SendThenReturnMessageBaseAsync(sendMessage, channel, cancellationToken).ConfigureAwait(false); return new OperResult(result) { Content = result.Content }; } catch (Exception ex) @@ -398,46 +458,13 @@ public abstract class ProtocolBase : DisposableObject, IProtocol } /// - protected virtual async ValueTask SendThenReturnMessageBaseAsync(ISendMessage command, IClientChannel channel = default, WaitDataAsync waitData = default, CancellationToken cancellationToken = default) + protected virtual async ValueTask SendThenReturnMessageBaseAsync(ISendMessage command, IClientChannel clientChannel = default, CancellationToken cancellationToken = default) { try { - try - { - if (!Channel.Online) - await Channel.ConnectAsync(ConnectTimeout, cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - await Task.Delay(200, cancellationToken).ConfigureAwait(false); - return new(ex); - } - if (SendDelayTime != 0) - await Task.Delay(SendDelayTime, cancellationToken).ConfigureAwait(false); - MessageBase? result; + return await GetResponsedDataAsync(command, clientChannel, Timeout, cancellationToken).ConfigureAwait(false); - if (channel == default) - { - if (Channel is not IClientChannel clientChannel) { throw new ArgumentNullException(nameof(channel)); } - if (waitData == default) - { - waitData = clientChannel.WaitHandlePool.GetWaitDataAsync(out var sign); - command.Sign = sign; - } - result = await GetResponsedDataAsync(command, clientChannel, waitData, Timeout, cancellationToken).ConfigureAwait(false); - } - else - { - if (waitData == default) - { - waitData = channel.WaitHandlePool.GetWaitDataAsync(out var sign); - command.Sign = sign; - } - result = await GetResponsedDataAsync(command, channel, waitData, Timeout, cancellationToken).ConfigureAwait(false); - } - - return result; } catch (Exception ex) { @@ -445,31 +472,24 @@ public abstract class ProtocolBase : DisposableObject, IProtocol } } - /// - public virtual bool IsSingleThread { get; } = true; - /// /// 发送并等待数据 /// - protected virtual async ValueTask GetResponsedDataAsync(ISendMessage command, IClientChannel clientChannel, WaitDataAsync waitData = default, int timeout = 3000, CancellationToken cancellationToken = default) + protected async ValueTask GetResponsedDataAsync(ISendMessage command, IClientChannel clientChannel, int timeout = 3000, CancellationToken cancellationToken = default) { - if (IsSingleThread) - await clientChannel.WaitLock.WaitAsync(cancellationToken).ConfigureAwait(false); - if (waitData == default) - { - waitData = clientChannel.WaitHandlePool.GetWaitDataAsync(out var sign); - command.Sign = sign; - } + + var waitData = clientChannel.WaitHandlePool.GetWaitDataAsync(out var sign); + command.Sign = sign; try { - SetDataAdapter(); waitData.SetCancellationToken(cancellationToken); + await clientChannel.WaitLock.WaitAsync(cancellationToken).ConfigureAwait(false); - //Logger?.LogTrace($"Command.Sign : {command.Sign}"); + Channel.ChannelReceivedWaitDict.TryAdd(sign, ChannelReceived); + await SendAsync(command, clientChannel, cancellationToken).ConfigureAwait(false); + await waitData.WaitAsync(timeout).ConfigureAwait(false); - await clientChannel.SendAsync(command).ConfigureAwait(false); - var waitDataStatus = await waitData.WaitAsync(timeout).ConfigureAwait(false); - var result = waitDataStatus.Check(); + var result = waitData.Check(); if (result.IsSuccess) { var response = waitData.WaitResult; @@ -479,12 +499,13 @@ public abstract class ProtocolBase : DisposableObject, IProtocol { throw result.Exception ?? new(TouchSocketCoreResource.UnknownError); } + } finally { + clientChannel.WaitLock.Release(); clientChannel.WaitHandlePool.Destroy(waitData); - if (IsSingleThread) - clientChannel.WaitLock.Release(); + Channel.ChannelReceivedWaitDict.TryRemove(sign, out _); } } @@ -517,6 +538,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol { try { + var bitConverter = ThingsGatewayBitConverter.GetTransByAddress(address); if (value is JArray jArray) { return dataType switch @@ -537,11 +559,6 @@ public abstract class ProtocolBase : DisposableObject, IProtocol } else { - var bitConverter = ThingsGatewayBitConverter.GetTransByAddress(ref address); - if (bitConverter.ArrayLength > 1) - { - return new OperResult("The array length is explicitly configured in the variable address, but the written value is not an array"); - } return dataType switch { DataTypeEnum.String => await WriteAsync(address, value.ToObject(), bitConverter, cancellationToken).ConfigureAwait(false), @@ -575,7 +592,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadBooleanAsync(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var result = await ReadAsync(address, GetLength(address, length, RegisterByteLength, true), cancellationToken).ConfigureAwait(false); @@ -585,7 +602,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadInt16Async(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var result = await ReadAsync(address, GetLength(address, length, 2), cancellationToken).ConfigureAwait(false); return result.OperResultFrom(() => bitConverter.ToInt16(result.Content, 0, length)); } @@ -593,7 +610,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadUInt16Async(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var result = await ReadAsync(address, GetLength(address, length, 2), cancellationToken).ConfigureAwait(false); return result.OperResultFrom(() => bitConverter.ToUInt16(result.Content, 0, length)); } @@ -601,7 +618,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadInt32Async(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var result = await ReadAsync(address, GetLength(address, length, 4), cancellationToken).ConfigureAwait(false); return result.OperResultFrom(() => bitConverter.ToInt32(result.Content, 0, length)); } @@ -609,7 +626,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadUInt32Async(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var result = await ReadAsync(address, GetLength(address, length, 4), cancellationToken).ConfigureAwait(false); return result.OperResultFrom(() => bitConverter.ToUInt32(result.Content, 0, length)); } @@ -617,7 +634,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadInt64Async(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var result = await ReadAsync(address, GetLength(address, length, 8), cancellationToken).ConfigureAwait(false); return result.OperResultFrom(() => bitConverter.ToInt64(result.Content, 0, length)); } @@ -625,7 +642,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadUInt64Async(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var result = await ReadAsync(address, GetLength(address, length, 8), cancellationToken).ConfigureAwait(false); return result.OperResultFrom(() => bitConverter.ToUInt64(result.Content, 0, length)); } @@ -633,7 +650,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadSingleAsync(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var result = await ReadAsync(address, GetLength(address, length, 4), cancellationToken).ConfigureAwait(false); return result.OperResultFrom(() => bitConverter.ToSingle(result.Content, 0, length)); } @@ -641,7 +658,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadDoubleAsync(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var result = await ReadAsync(address, GetLength(address, length, 8), cancellationToken).ConfigureAwait(false); return result.OperResultFrom(() => bitConverter.ToDouble(result.Content, 0, length)); } @@ -649,7 +666,7 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual async ValueTask> ReadStringAsync(string address, int length, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); if (bitConverter.StringLength == null) return new OperResult(DefaultResource.Localizer["StringAddressError"]); var len = bitConverter.StringLength * length; @@ -686,70 +703,70 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual ValueTask WriteAsync(string address, byte value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, new byte[] { value }, cancellationToken); } /// public virtual ValueTask WriteAsync(string address, short value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, ushort value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, int value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, uint value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, long value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, ulong value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, float value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, double value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, string value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); var data = bitConverter.GetBytes(value); return WriteAsync(address, data.ArrayExpandToLength(bitConverter.StringLength ?? data.Length), cancellationToken); } @@ -761,63 +778,63 @@ public abstract class ProtocolBase : DisposableObject, IProtocol /// public virtual ValueTask WriteAsync(string address, short[] value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, ushort[] value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, int[] value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, uint[] value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, long[] value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, ulong[] value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, float[] value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual ValueTask WriteAsync(string address, double[] value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); return WriteAsync(address, bitConverter.GetBytes(value), cancellationToken); } /// public virtual async ValueTask WriteAsync(string address, string[] value, IThingsGatewayBitConverter bitConverter = null, CancellationToken cancellationToken = default) { - bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(ref address); + bitConverter ??= ThingsGatewayBitConverter.GetTransByAddress(address); if (bitConverter.StringLength == null) return new OperResult(DefaultResource.Localizer["StringAddressError"]); List bytes = new(); foreach (var a in value) @@ -838,8 +855,22 @@ public abstract class ProtocolBase : DisposableObject, IProtocol lock (Channel) { + Channel.Starting.Remove(ChannelStarting); + Channel.Stoped.Remove(ChannelStoped); + Channel.Started.Remove(ChannelStarted); + Channel.Stoping.Remove(ChannelStoping); + Channel.ChannelReceived.Remove(ChannelReceived); + if (Channel.Collects.Count == 1) { + if (Channel is TcpServiceChannel tcpServiceChannel) + { + tcpServiceChannel.Clients.ForEach(a => + { + a.WaitHandlePool.SafeDispose(); + }); + } + try { //只关闭,不释放 @@ -854,27 +885,31 @@ public abstract class ProtocolBase : DisposableObject, IProtocol Logger?.LogWarning(ex); } } - if (Channel is TcpServiceChannel tcpServiceChannel) + else { - tcpServiceChannel.Clients.ForEach(a => + if (Channel is TcpServiceChannel tcpServiceChannel && this is IDtu dtu) { - a.WaitHandlePool.SafeDispose(); - }); - tcpServiceChannel.SafeClose(); + if (tcpServiceChannel.TryGetClient(dtu.DtuId, out var client)) + { + client.WaitHandlePool?.SafeDispose(); + client.TryShutdown(); + client.SafeClose(); + } + + } } - Channel.Starting.Remove(ChannelStarting); - Channel.Stoped.Remove(ChannelStoped); - Channel.Started.Remove(ChannelStarted); - Channel.Stoping.Remove(ChannelStoping); - Channel.ChannelReceived.Remove(ChannelReceived); + Channel.Collects.Remove(this); + } } + + _deviceLogger?.TryDispose(); base.Dispose(disposing); } /// - public virtual Action ConfigurePlugins() + public virtual Action ConfigurePlugins(TouchSocketConfig config) { return a => { }; } diff --git a/src/Foundation/ThingsGateway.Foundation/Device/DeviceExtension.cs b/src/Foundation/ThingsGateway.Foundation/Device/DeviceExtension.cs new file mode 100644 index 000000000..10fa55d53 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation/Device/DeviceExtension.cs @@ -0,0 +1,155 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using TouchSocket.Resources; + +namespace ThingsGateway.Foundation; + +/// +/// 协议基类 +/// +public static partial class DeviceExtension +{ + #region 读取 + + /// + public static async ValueTask> ReadBooleanAsync(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadBooleanAsync(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + /// + public static async ValueTask> ReadDoubleAsync(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadDoubleAsync(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + /// + public static async ValueTask> ReadInt16Async(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadInt16Async(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + /// + public static async ValueTask> ReadInt32Async(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadInt32Async(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + /// + public static async ValueTask> ReadInt64Async(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadInt64Async(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + /// + public static async ValueTask> ReadSingleAsync(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadSingleAsync(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + /// + public static async ValueTask> ReadStringAsync(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadStringAsync(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + /// + public static async ValueTask> ReadUInt16Async(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadUInt16Async(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + /// + public static async ValueTask> ReadUInt32Async(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadUInt32Async(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + /// + public static async ValueTask> ReadUInt64Async(this IDevice device, string address, CancellationToken cancellationToken = default) + { + var result = await device.ReadUInt64Async(address, 1, null, cancellationToken).ConfigureAwait(false); + return result.OperResultFrom(() => result.Content[0]); + } + + #endregion 读取 + + + + /// + /// 在返回的字节数组中解析每个变量的值 + /// 根据每个变量的 + /// 不支持变长字符串类型变量,不能存在于变量List中 + /// + /// 设备 + /// 设备变量List + /// 返回的字节数组 + /// 任意一个失败时抛出异常 + /// 解析结果 + public static OperResult PraseStructContent(this IEnumerable variables, IDevice device, byte[] buffer, bool exWhenAny) where T : IVariable + { + var time = DateTime.Now; + var result = OperResult.Success; + foreach (var variable in variables) + { + IThingsGatewayBitConverter byteConverter = variable.ThingsGatewayBitConverter; + var dataType = variable.DataType; + int index = variable.Index; + try + { + var data = byteConverter.GetDataFormBytes(device, variable.RegisterAddress, buffer, index, dataType, variable.ArrayLength ?? 1); + result = Set(variable, data); + if (exWhenAny) + if (!result.IsSuccess) + return result; + } + catch (Exception ex) + { + return new OperResult($"Error parsing byte array, address: {variable.RegisterAddress}, array length: {buffer.Length}, index: {index}, type: {dataType}", ex); + } + } + return result; + OperResult Set(IVariable organizedVariable, object num) + { + return organizedVariable.SetValue(num, time); + } + } + + /// + /// 当状态不是时返回异常。 + /// + public static OperResult Check(this WaitDataAsync waitDataAsync) + { + switch (waitDataAsync.Status) + { + case WaitDataStatus.SetRunning: + return new(); + + case WaitDataStatus.Canceled: return new(new OperationCanceledException()); + case WaitDataStatus.Overtime: return waitDataAsync.WaitResult == null ? new(new TimeoutException()) : new(waitDataAsync.WaitResult); + case WaitDataStatus.Disposed: + case WaitDataStatus.Default: + default: + { + return waitDataAsync.WaitResult == null ? new(new Exception(TouchSocketCoreResource.UnknownError)) : new(waitDataAsync.WaitResult); + } + } + } +} diff --git a/src/Foundation/ThingsGateway.Foundation/Protocol/IProtocol.cs b/src/Foundation/ThingsGateway.Foundation/Device/IDevice.cs similarity index 91% rename from src/Foundation/ThingsGateway.Foundation/Protocol/IProtocol.cs rename to src/Foundation/ThingsGateway.Foundation/Device/IDevice.cs index 69a090e4c..3243fe9ee 100644 --- a/src/Foundation/ThingsGateway.Foundation/Protocol/IProtocol.cs +++ b/src/Foundation/ThingsGateway.Foundation/Device/IDevice.cs @@ -13,37 +13,17 @@ using Newtonsoft.Json.Linq; namespace ThingsGateway.Foundation; /// -/// 协议接口 +/// 协议设备接口 /// -public interface IProtocol : IDisposable +public interface IDevice : IDisposable { #region 属性 - /// - /// 组包缓存时间 - /// - int CacheTimeout { get; set; } - /// /// 通道 /// IChannel Channel { get; } - /// - /// 连接超时时间 - /// - ushort ConnectTimeout { get; set; } - - /// - /// 数据解析规则 - /// - DataFormatEnum DataFormat { get; set; } - - /// - /// 是否需要并发锁,默认为true,对于工业主从协议,通常是必须的 - /// - bool IsSingleThread { get; } - /// /// 日志 /// @@ -72,6 +52,16 @@ public interface IProtocol : IDisposable /// int Timeout { get; set; } + /// + /// 字节顺序 + /// + DataFormatEnum DataFormat { get; set; } + + /// + /// 字符串翻转 + /// + bool IsStringReverseByteWord { get; set; } + #endregion 属性 #region 适配器 @@ -427,24 +417,10 @@ public interface IProtocol : IDisposable /// bool BitReverse(string address); - /// - /// 断开连接 - /// - /// - /// - Task CloseAsync(string msg = null); - /// /// 配置IPluginManager /// - Action ConfigurePlugins(); - - /// - /// 连接 - /// - /// - /// - Task ConnectAsync(CancellationToken cancellationToken = default); + Action ConfigurePlugins(TouchSocketConfig config); /// /// 获取通道 @@ -453,41 +429,41 @@ public interface IProtocol : IDisposable /// ValueTask> GetChannelAsync(string socketId); - /// - /// 发送,会经过适配器,可传入,如果为空,则默认通道必须为类型 - /// - /// 发送字节数组 - /// 取消令箭 - /// 通道 - /// 返回消息体 - ValueTask SendAsync(ISendMessage sendMessage, IClientChannel channel = default, CancellationToken token = default); - /// /// 发送,会经过适配器,可传入socketId,如果为空,则默认通道必须为类型 /// - /// 通道 /// 发送字节数组 /// 取消令箭 /// 返回消息体 - ValueTask SendAsync(ISendMessage sendMessage, string socketId, CancellationToken cancellationToken); + ValueTask SendAsync(ISendMessage sendMessage, CancellationToken cancellationToken); /// /// 发送并等待返回,会经过适配器,可传入,如果为空,则默认通道必须为类型 /// /// 发送字节数组 - /// waitData /// 取消令箭 /// 通道 /// 返回消息体 - ValueTask> SendThenReturnAsync(ISendMessage command, IClientChannel channel = default, WaitDataAsync waitData = default, CancellationToken cancellationToken = default); + ValueTask> SendThenReturnAsync(ISendMessage command, IClientChannel channel = default, CancellationToken cancellationToken = default); /// /// 发送并等待返回,会经过适配器,可传入socketId,如果为空,则默认通道必须为类型 /// - /// 通道 /// 发送字节数组 - /// waitData /// 取消令箭 /// 返回消息体 - ValueTask> SendThenReturnAsync(ISendMessage sendMessage, string socketId, WaitDataAsync waitData = default, CancellationToken cancellationToken = default); + ValueTask> SendThenReturnAsync(ISendMessage sendMessage, CancellationToken cancellationToken = default); + + /// + /// 支持通道多设备 + /// + /// + bool SupportMultipleDevice(); + + /// + /// 初始化通道信息 + /// + /// 通道 + /// 单独设备日志 + void InitChannel(IChannel channel, ILog? deviceLog = null); } diff --git a/src/Foundation/ThingsGateway.Foundation/OperResult/ErrorCodeEnum.cs b/src/Foundation/ThingsGateway.Foundation/Enums/ErrorTypeEnum.cs similarity index 88% rename from src/Foundation/ThingsGateway.Foundation/OperResult/ErrorCodeEnum.cs rename to src/Foundation/ThingsGateway.Foundation/Enums/ErrorTypeEnum.cs index e6e086d47..e097f18b4 100644 --- a/src/Foundation/ThingsGateway.Foundation/OperResult/ErrorCodeEnum.cs +++ b/src/Foundation/ThingsGateway.Foundation/Enums/ErrorTypeEnum.cs @@ -13,18 +13,20 @@ namespace ThingsGateway.Foundation; /// /// 操作返回类型 /// -public enum ErrorCodeEnum +public enum ErrorTypeEnum { - /// - /// 设备返回错误,默认失败类型 - /// - RetuenError, + /// - /// 执行失败报错 + /// 执行失败报错,默认失败类型 /// InvokeFail, + /// + /// 设备返回错误 + /// + DeviceError, + /// /// 超时取消 /// diff --git a/src/Foundation/ThingsGateway.Foundation/Extensions/ExpandoObjectExtensions.cs b/src/Foundation/ThingsGateway.Foundation/Extensions/ExpandoObjectExtensions.cs index 0785146f9..7b351da1d 100644 --- a/src/Foundation/ThingsGateway.Foundation/Extensions/ExpandoObjectExtensions.cs +++ b/src/Foundation/ThingsGateway.Foundation/Extensions/ExpandoObjectExtensions.cs @@ -28,7 +28,6 @@ public static class ExpandoObjectExtensions public static object ConvertToEntity(this ExpandoObject expandoObject, Type type, Dictionary properties) { var entity = Activator.CreateInstance(type); - // 遍历动态对象的属性 expandoObject.ForEach(keyValuePair => { diff --git a/src/Foundation/ThingsGateway.Foundation/Extensions/LoggerExtensions.cs b/src/Foundation/ThingsGateway.Foundation/Extensions/LoggerExtensions.cs index 1a1d3b5d3..c54f05317 100644 --- a/src/Foundation/ThingsGateway.Foundation/Extensions/LoggerExtensions.cs +++ b/src/Foundation/ThingsGateway.Foundation/Extensions/LoggerExtensions.cs @@ -10,8 +10,6 @@ using System.Text.RegularExpressions; -using ThingsGateway.NewLife.Extension; - namespace ThingsGateway.Foundation; /// @@ -78,6 +76,15 @@ public static class LoggerExtensions { return GetLogBasePath().CombinePath(channelId.ToString()).FileNameReplace(); } + /// + /// 获取日志路径 + /// + /// + /// + public static string GetLogPath(this string channelId) + { + return GetLogBasePath().CombinePath(channelId.ToString()).FileNameReplace(); + } #region 日志 diff --git a/src/Foundation/ThingsGateway.Foundation/Locales/en-US.json b/src/Foundation/ThingsGateway.Foundation/Locales/en-US.json index 05ec05b62..0631d0904 100644 --- a/src/Foundation/ThingsGateway.Foundation/Locales/en-US.json +++ b/src/Foundation/ThingsGateway.Foundation/Locales/en-US.json @@ -1,5 +1,10 @@ { "ThingsGateway.Foundation.DefaultResource": { + "EmptyUriString": "The URI string cannot be null or consist only of whitespace.", + "InvalidPortRange": "The URI string cannot be null or consist only of whitespace.", + "InvalidIPv4Segment": "The URI string cannot be null or consist only of whitespace.", + "InvalidUriFormat": "The provided URI string format is not valid.", + "CannotSendIRequestInfo": "The current adapter does not support object sending", "CannotSet": "Not allowed to freely call {0} for assignment", "CannotSplicingSend": "This adapter does not support splicing sending", @@ -32,9 +37,19 @@ "StringAddressError": "String read and write must specify length in register address, for example len=10;", "TransBytesError": "Conversion failed - original byte array {0}, length {1}", "UnknownError": "Unknown error, code: {0}", - "StringTypePackError": "When the data type is string, the string length must be specified in order to pack it" + "StringTypePackError": "When the data type is string, the string length must be specified in order to pack it", + + "RemoteUrlNotNull": "Remote IP Address cannot be empty", + "BindUrlNotNull": "Local Bind IP Address cannot be empty", + "BindUrlOrRemoteUrlNotNull": "Remote IP Address or Local Bind IP Address cannot be empty", + "PortNameNotNull": "COM Port cannot be empty", + "BaudRateNotNull": "Baud Rate cannot be empty", + "DataBitsNotNull": "Data Bits can be empty", + "ParityNotNull": "Parity cannot be empty", + "StopBitsNotNull": "Stop Bits cannot be empty" + }, - "ThingsGateway.Foundation.ChannelData": { + "ThingsGateway.Foundation.ChannelOptions": { "Name": "Name", "Name.Required": "{0} cannot be empty", "ChannelType": "ChannelType", @@ -48,9 +63,20 @@ "Parity": "Parity", "StopBits": "StopBits", "DtrEnable": "DtrEnable", - "RtsEnable": "RtsEnable" + "RtsEnable": "RtsEnable", + "CacheTimeout": "CacheTimeout", + "ConnectTimeout": "ConnectTimeout", + "MaxConcurrentCount": "MaxConcurrentCount" }, "ThingsGateway.Foundation.VariableClass": { "RegisterAddress": "RegisterAddress" + }, + "ThingsGateway.Foundation.ConverterConfig": { + "DataFormat": "DataFormat", + "Encoding": "Encoding", + "EncodingName": "字符串编码", + "VariableStringLength": "VariableStringLength", + "Stringlength": "Stringlength", + "BcdFormat": "BcdFormat格式" } } diff --git a/src/Foundation/ThingsGateway.Foundation/Locales/zh-CN.json b/src/Foundation/ThingsGateway.Foundation/Locales/zh-CN.json index e730dfc4a..ff5687523 100644 --- a/src/Foundation/ThingsGateway.Foundation/Locales/zh-CN.json +++ b/src/Foundation/ThingsGateway.Foundation/Locales/zh-CN.json @@ -1,5 +1,10 @@ { "ThingsGateway.Foundation.DefaultResource": { + "EmptyUriString": "URI 字符串不能为空或仅包含空白字符", + "InvalidPortRange": "端口号必须是 1 到 65535 之间的整数", + "InvalidIPv4Segment": "IPv4 地址的每段数值必须在 0 到 255 之间", + "InvalidUriFormat": "输入的 URI 字符串格式不符合要求", + "CannotSendIRequestInfo": "当前适配器不支持对象发送", "CannotSet": "不允许自由调用 {0} 进行赋值", "CannotSplicingSend": "该适配器不支持拼接发送", @@ -32,10 +37,20 @@ "StringAddressError": "字符串读写必须在寄存器地址中指定长度,例如 len=10;", "TransBytesError": "转换失败-原始字节数组 {0},长度 {1}", "UnknownError": "未知错误,错误代码:{0}", - "StringTypePackError": "数据类型为字符串时,必须指定字符串长度,才能进行打包" + "StringTypePackError": "数据类型为字符串时,必须指定字符串长度,才能进行打包", + + "RemoteUrlNotNull": "远程IP地址不可为空", + "BindUrlNotNull": "本地绑定IP地址不可为空", + "BindUrlOrRemoteUrlNotNull": "远程IP地址或本地绑定IP地址不可为空", + "PortNameNotNull": "COM口不可为空", + "BaudRateNotNull": "波特率不可为空", + "DataBitsNotNull": "数据位可为空", + "ParityNotNull": "校验位不可为空", + "StopBitsNotNull": "停止位不可为空" + }, - "ThingsGateway.Foundation.ChannelData": { + "ThingsGateway.Foundation.ChannelOptions": { "Name": "名称", "Name.Required": " {0} 不可为空", "ChannelType": "通道类型", @@ -49,9 +64,21 @@ "Parity": "校验位", "StopBits": "停止位", "DtrEnable": "Dtr", - "RtsEnable": "Rts" + "RtsEnable": "Rts", + "CacheTimeout": "接收缓存超时", + "ConnectTimeout": "连接超时", + "MaxConcurrentCount": "最大并发数" }, "ThingsGateway.Foundation.VariableClass": { "RegisterAddress": "寄存器地址" + }, + "ThingsGateway.Foundation.ConverterConfig": { + "DataFormat": "字节顺序", + "Encoding": "字符串编码", + "EncodingName": "字符串编码", + "VariableStringLength": "变长字符串", + "Stringlength": "字符串长度", + "BcdFormat": "BCD格式" } } + \ No newline at end of file diff --git a/src/Foundation/ThingsGateway.Foundation/Locales/zh-TW.json b/src/Foundation/ThingsGateway.Foundation/Locales/zh-TW.json deleted file mode 100644 index f37268278..000000000 --- a/src/Foundation/ThingsGateway.Foundation/Locales/zh-TW.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "ThingsGateway.Foundation.DefaultResource": { - "CannotSendIRequestInfo": "當前適配器不支援物件發送", - "CannotSet": "不允許自由調用 {0} 進行賦值", - "CannotSplicingSend": "該適配器不支援拼接發送", - "CannotUseAdapterAgain": "此適配器已被其他終端使用,請重新創建物件", - "ConfigNotNull": "配置文件不能為空", - "Connected": "連接成功", - "Connecting": "正在連接", - "ConnectTimeout": "連接超時", - "DataLengthError": "數據長度錯誤 {0}", - "DataTypeNotSupported": "{0} 數據類型未實現", - "DefaultAddressDes": "————————————————————\n 4字節數據轉換格式:data=ABCD;可選ABCD=>Big-Endian;BADC=>Big-Endian Byte Swap;CDAB=>Little-Endian Byte Swap;DCBA=>Little-Endian。\n 字串長度:len=1。\n 陣列長度:arraylen=1。\n Bcd格式:bcd=C8421,可選C8421;C5421;C2421;C3;Gray。\n 字元格式:encoding=UTF-8,可選UTF-8;ASCII;Default;Unicode等。\n————————————————————", - "Disconnected": "斷開連接", - "Disconnecting": "正在斷開連接", - "DtuConnected": "Dtu標識 {0} 連接成功", - "DtuNoConnectedWaining": "客戶端(Dtu)未連接,id:{0}", - "ErrorMessage": "錯誤資訊", - "EventError": "在事件 {0} 中發生錯誤", - "Exception": "異常堆疊", - "LengthShortError": "數據長度不足,原始數據:{0}", - "NotActiveQueryError": "接收數據正確,但主機並沒有主動請求數據", - "ProactivelyDisconnect": " {0} 主動斷開", - "ProcessReceiveError": "接收出現錯誤 {0},錯誤代碼 {1}", - "Receive": "接收", - "ReceiveError": "在處理數據時發生錯誤", - "RemoteClose": "遠程終端已關閉", - "Send": "發送", - "SerialPortNotClient": "新的SerialPort必須在連接狀態", - "ServiceStarted": "啟動", - "ServiceStoped": "停止", - "StringAddressError": "字串讀寫必須在寄存器地址中指定長度,例如 len=10;", - "TransBytesError": "轉換失敗-原始字節數組 {0},長度 {1}", - "UnknownError": "未知錯誤,錯誤代碼:{0}", - "StringTypePackError": "數據類型為字串時,必須指定字串長度,才能進行打包" - }, - - "ThingsGateway.Foundation.ChannelData": { - "Name": "名稱", - "Name.Required": " {0} 不可為空", - "ChannelType": "通道類型", - "RemoteUrl": "遠程IP地址", - "BindUrl": "本地綁定IP地址", - "PortName": "COM口", - "BaudRate": "波特率", - "DataBits": "數據位", - "Parity": "校驗位", - "StopBits": "停止位", - "DtrEnable": "Dtr", - "RtsEnable": "Rts" - }, - "ThingsGateway.Foundation.VariableClass": { - "RegisterAddress": "寄存器地址" - } -} diff --git a/src/Foundation/ThingsGateway.Foundation/Logger/TextFileLogger.cs b/src/Foundation/ThingsGateway.Foundation/Logger/TextFileLogger.cs index 6a36b10c7..dcc4cc939 100644 --- a/src/Foundation/ThingsGateway.Foundation/Logger/TextFileLogger.cs +++ b/src/Foundation/ThingsGateway.Foundation/Logger/TextFileLogger.cs @@ -19,7 +19,7 @@ namespace ThingsGateway.Foundation; /// public class TextFileLogger : ThingsGateway.NewLife.Log.TextFileLog, TouchSocket.Core.ILog, IDisposable { - private static string separator = Environment.NewLine + "-----ThingsGateway-Log-Separator-----" + Environment.NewLine; + private static string separator = Environment.NewLine + "-----分隔符-----" + Environment.NewLine; /// /// 分隔符 @@ -36,7 +36,8 @@ public class TextFileLogger : ThingsGateway.NewLife.Log.TextFileLog, TouchSocket separatorBytes = Encoding.UTF8.GetBytes(separator); } } - private static byte[] separatorBytes = Encoding.UTF8.GetBytes(Environment.NewLine + "-----ThingsGateway-Log-Separator-----" + Environment.NewLine); + + private static byte[] separatorBytes = Encoding.UTF8.GetBytes(Environment.NewLine + "-----分隔符-----" + Environment.NewLine); internal static byte[] SeparatorBytes { @@ -60,11 +61,21 @@ public class TextFileLogger : ThingsGateway.NewLife.Log.TextFileLog, TouchSocket _headEnable = false; } + + /// 每个目录的日志实例应该只有一个,所以采用静态创建 + /// 日志目录或日志文件路径 + /// + public static TextFileLogger CreateSingleFileLogger(String path) + { + if (path.IsNullOrEmpty()) throw new ArgumentNullException(nameof(path)); + + return cache.GetOrAdd(path, k => new TextFileLogger(k, true)); + } /// 每个目录的日志实例应该只有一个,所以采用静态创建 /// 日志目录或日志文件路径 /// /// - public static TextFileLogger CreateTextLogger(String path, String? fileFormat = null) + public static TextFileLogger GetMultipleFileLogger(String path, String? fileFormat = null) { //if (path.IsNullOrEmpty()) path = XTrace.LogPath; if (path.IsNullOrEmpty()) path = "Log"; @@ -73,16 +84,6 @@ public class TextFileLogger : ThingsGateway.NewLife.Log.TextFileLog, TouchSocket return cache.GetOrAdd(key, k => new TextFileLogger(path, false, fileFormat)); } - /// 每个目录的日志实例应该只有一个,所以采用静态创建 - /// 日志目录或日志文件路径 - /// - public static TextFileLogger CreateTextFileLogger(String path) - { - if (path.IsNullOrEmpty()) throw new ArgumentNullException(nameof(path)); - - return cache.GetOrAdd(path, k => new TextFileLogger(k, true)); - } - /// /// TimeFormat diff --git a/src/Foundation/ThingsGateway.Foundation/Logger/TextFileReader.cs b/src/Foundation/ThingsGateway.Foundation/Logger/TextFileReader.cs index fa97aa3f6..e7f618e19 100644 --- a/src/Foundation/ThingsGateway.Foundation/Logger/TextFileReader.cs +++ b/src/Foundation/ThingsGateway.Foundation/Logger/TextFileReader.cs @@ -10,42 +10,15 @@ using System.Text; +using ThingsGateway.NewLife.Caching; + namespace ThingsGateway.Foundation; -/// -public class LastLogResult : OperResultClass> +public class LogDataCache { - /// - public LastLogResult() : base() - { - } - - /// - public LastLogResult(IOperResult operResult) : base(operResult) - { - } - - /// - public LastLogResult(string msg) : base(msg) - { - } - - /// - public LastLogResult(Exception ex) : base(ex) - { - } - - /// - public LastLogResult(string msg, Exception ex) : base(msg, ex) - { - } - - /// - /// 流位置 - /// - public long Position { set; get; } + public List LogDatas { get; set; } + public long Length { get; set; } } - /// /// 日志数据 /// @@ -72,21 +45,6 @@ public class LogData public string Message { get; set; } } -/// -/// -/// -public class LogInfoResult : OperResultClass -{ - /// - /// 全名称 - /// - public string FullName { set; get; } - - /// - /// 流位置 - /// - public long Length { set; get; } -} /// 日志文本文件倒序读取 public class TextFileReader @@ -96,12 +54,15 @@ public class TextFileReader /// /// 目录路径 /// 包含文件信息的列表 - public static List GetFiles(string directoryPath) + public static OperResult> GetFiles(string directoryPath) { + OperResult> result = new(); // 初始化结果对象 // 检查目录是否存在 if (!Directory.Exists(directoryPath)) { - return new List(); + result.OperCode = 999; + result.ErrorMessage = "Directory not exists"; + return result; } // 获取目录下所有文件路径 @@ -110,106 +71,108 @@ public class TextFileReader // 如果文件列表为空,则返回空列表 if (files == null || files.Length == 0) { - return new List(); + result.OperCode = 999; + result.ErrorMessage = "Canot found files"; + return result; } // 获取文件信息并按照最后写入时间降序排序 var fileInfos = files.Select(filePath => new FileInfo(filePath)) .OrderByDescending(x => x.LastWriteTime) - .Select(x => new LogInfoResult() { FullName = x.FullName, Length = x.Length }) + .Select(x => x.FullName) .ToList(); - - return fileInfos; + result.OperCode = 0; + result.Content = fileInfos; + return result; } - /// - /// 从指定位置开始倒序读取文本文件,并解析日志数据 - /// - /// 文件路径 - /// 读取流的起始位置,如果为0,则从文件末尾开始读取 - /// 读取行数,默认为200行 - /// 返回最后读取的日志内容、新的起始位置和操作状态 - public static LastLogResult LastLog(string file, long position, int lineCount = 200) + static MemoryCache _cache = new() { Expire = 30 }; + public static OperResult> LastLog(string file, int lineCount = 200) { - LastLogResult result = new(); // 初始化结果对象 - try + lock (_cache) { - if (!File.Exists(file)) // 检查文件是否存在 + + OperResult> result = new(); // 初始化结果对象 + try { - result.OperCode = 999; - result.ErrorMessage = "The file path is invalid"; - return result; + if (!File.Exists(file)) // 检查文件是否存在 + { + result.OperCode = 999; + result.ErrorMessage = "The file path is invalid"; + return result; + } + + List txt = new(); // 存储读取的文本内容 + long ps = 0; // 保存起始位置 + var key = $"{nameof(TextFileReader)}_{nameof(LastLog)}_{file})"; + long length = 0; + using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + length = fs.Length; + var dataCache = _cache.Get(key); + if (dataCache != null && dataCache.Length == length) + { + result.Content = dataCache.LogDatas; + result.OperCode = 0; // 操作状态设为成功 + return result; // 返回解析结果 + } + + if (ps <= 0) // 如果起始位置小于等于0,将起始位置设置为文件长度 + ps = length - 1; + + // 循环读取指定行数的文本内容 + for (int i = 0; i < lineCount; i++) + { + ps = InverseReadRow(fs, ps, out var value); // 使用逆序读取 + txt.Add(value); + if (ps <= 0) // 如果已经读取到文件开头则跳出循环 + break; + } + } + + // 使用单次 LINQ 操作进行过滤和解析 + result.Content = txt + .Select(a => ParseCSV(a)) + .Where(data => data.Count >= 3) + .Select(data => + { + var log = new LogData + { + LogTime = data[0].Trim(), + LogLevel = Enum.TryParse(data[1].Trim(), out LogLevel level) ? level : LogLevel.Info, + Message = data[2].Trim(), + ExceptionString = data.Count > 3 ? data[3].Trim() : null + }; + return log; + }) + .ToList(); + + result.OperCode = 0; // 操作状态设为成功 + var data = _cache.Set(key, new LogDataCache() { Length = length, LogDatas = result.Content }); + + return result; // 返回解析结果 } - - List txt = new(); // 存储读取的文本内容 - long ps = position; // 保存起始位置 - - using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + catch (Exception ex) // 捕获异常 { - if (ps <= 0) // 如果起始位置小于等于0,将起始位置设置为文件长度 - ps = fs.Length; - - // 循环读取指定行数的文本内容 - for (int i = 0; i < lineCount; i++) - { - ps = InverseReadRow(fs, ps, txt); // 调用方法逆向读取一行文本并存储 - if (ps <= 0) // 如果已经读取到文件开头则跳出循环 - break; - } + result = new(ex); // 创建包含异常信息的结果对象 + return result; // 返回异常结果 } - - // 解析读取的文本为日志数据 - result.Content = txt.Select(a => - { - var data = ParseCSV(a); // 解析CSV格式的文本 - if (data.Count > 2) // 如果解析出的数据列数大于2,则认为是有效日志数据 - { - var log = new LogData(); // 创建日志数据对象 - log.LogTime = data[0].Trim(); // 日志时间 - log.LogLevel = (LogLevel)Enum.Parse(typeof(LogLevel), data[1].Trim()); // 日志级别 - log.Message = data[2].Trim(); // 日志消息 - log.ExceptionString = data.Count > 3 ? data[3].Trim() : null; // 异常信息(如果有) - return log; // 返回解析后的日志数据 - } - else - { - return null; // 数据列数不足2则返回空 - } - }).Where(a => a != null).ToList()!; // 过滤空值并转换为列表 - - result.Position = ps; // 更新起始位置 - result.OperCode = 0; // 操作状态设为成功 - return result; // 返回解析结果 - } - catch (Exception ex) // 捕获异常 - { - result = new LastLogResult(ex); // 创建包含异常信息的结果对象 - return result; // 返回异常结果 } } - /// - /// 从后向前按行读取文本文件,并根据特定规则筛选行数据 - /// - /// 文件流 - /// 读取位置 - /// 存储读取的文本行 - /// 每次最多读取字节数,默认为10kb - /// 返回新的读取位置 - private static long InverseReadRow(FileStream fs, long position, List strs, int maxRead = 10240) + private static long InverseReadRow(FileStream fs, long position, out string value, int maxRead = 10240) { byte n = 0xD; // 换行符 byte a = 0xA; // 回车符 - + value = string.Empty; if (fs.Length == 0) return 0; // 若文件长度为0,则直接返回0作为新的位置 - var newPos = position - 1; // 新的位置从指定位置减一开始 + var newPos = position; + List buffer = new List(maxRead); // 缓存读取的数据 try { - int curVal; var readLength = 0; - LinkedList arr = new LinkedList(); // 存储读取的字节数据 while (true) // 循环读取一行数据,TextFileLogger.Separator行判定 { @@ -218,38 +181,36 @@ public class TextFileReader newPos = 0; fs.Position = newPos; - curVal = fs.ReadByte(); - if (curVal == -1) break; // 到达文件开头时跳出循环 + int byteRead = fs.ReadByte(); - arr.AddFirst((byte)curVal); // 将读取的字节插入列表头部 + if (byteRead == -1) break; // 到达文件开头时跳出循环 - if (curVal == n || curVal == a)//判断当前字符是换行符 // TextFileLogger.Separator + buffer.Add((byte)byteRead); + + if (byteRead == n || byteRead == a)//判断当前字符是换行符 // TextFileLogger.Separator { - if (MatchSeparator(arr)) + if (MatchSeparator(buffer)) { // 去掉匹配的指定字符串 - for (int i = 0; i < TextFileLogger.SeparatorBytes.Length; i++) - { - arr.RemoveFirst(); - } + buffer.RemoveRange(buffer.Count - TextFileLogger.SeparatorBytes.Length, TextFileLogger.SeparatorBytes.Length); break; } } - if (readLength == maxRead) // 达到最大读取限制时,直接放弃 + if (buffer.Count > maxRead) // 超过最大字节数限制时丢弃数据 { - arr = new(); - readLength = arr.Count; + newPos = -1; + break; } newPos--; if (newPos <= -1) break; } - if (arr.Count >= 5) // 处理完整的行数据 + if (buffer.Count >= 10) { - var str = Encoding.UTF8.GetString(arr.ToArray()); // 转换为字符串 - strs.Add(str); // 存储有效行数据 + buffer.Reverse(); + value = Encoding.UTF8.GetString(buffer.ToArray()); // 转换为字符串 } return newPos; // 返回新的读取位置 @@ -259,24 +220,22 @@ public class TextFileReader } } - private static bool MatchSeparator(LinkedList arr) + + private static bool MatchSeparator(List arr) { if (arr.Count < TextFileLogger.SeparatorBytes.Length) { return false; } - - var currentNode = arr.First; // 从头节点开始 + var pos = arr.Count - 1; for (int i = 0; i < TextFileLogger.SeparatorBytes.Length; i++) { - if (currentNode.Value != TextFileLogger.SeparatorBytes[i]) + if (arr[pos] != TextFileLogger.SeparatorBytes[i]) { return false; } - - currentNode = currentNode.Next; // 移动到下一个节点 + pos--; } - return true; } diff --git a/src/Foundation/ThingsGateway.Foundation/OperResult/IOperResult.cs b/src/Foundation/ThingsGateway.Foundation/OperResult/IOperResult.cs index 39698a970..8464f4905 100644 --- a/src/Foundation/ThingsGateway.Foundation/OperResult/IOperResult.cs +++ b/src/Foundation/ThingsGateway.Foundation/OperResult/IOperResult.cs @@ -18,7 +18,7 @@ public interface IOperResult : IRequestInfo /// /// 执行错误返回类型 /// - ErrorCodeEnum? ErrorCode { get; } + ErrorTypeEnum? ErrorType { get; } /// /// 返回消息 diff --git a/src/Foundation/ThingsGateway.Foundation/OperResult/OperResult.cs b/src/Foundation/ThingsGateway.Foundation/OperResult/OperResult.cs index fea02c3c2..e0da41b0c 100644 --- a/src/Foundation/ThingsGateway.Foundation/OperResult/OperResult.cs +++ b/src/Foundation/ThingsGateway.Foundation/OperResult/OperResult.cs @@ -41,7 +41,7 @@ public struct OperResult : IOperResult OperCode = operResult.OperCode; ErrorMessage = operResult.ErrorMessage; Exception = operResult.Exception; - ErrorCode = operResult.ErrorCode; + ErrorType = operResult.ErrorType; } /// @@ -52,7 +52,7 @@ public struct OperResult : IOperResult { OperCode = 500; ErrorMessage = msg; - ErrorCode = ErrorCodeEnum.InvokeFail; + ErrorType = ErrorTypeEnum.InvokeFail; } /// @@ -66,15 +66,15 @@ public struct OperResult : IOperResult //指定Timeout或OperationCanceled为超时取消 if (ex is TimeoutException || ex is OperationCanceledException) { - ErrorCode = ErrorCodeEnum.Canceled; + ErrorType = ErrorTypeEnum.Canceled; } else if (ex is ReturnErrorException) { - ErrorCode = ErrorCodeEnum.RetuenError; + ErrorType = ErrorTypeEnum.DeviceError; } else { - ErrorCode = ErrorCodeEnum.InvokeFail; + ErrorType = ErrorTypeEnum.InvokeFail; } } @@ -101,7 +101,7 @@ public struct OperResult : IOperResult #endif [JsonConverter(typeof(StringEnumConverter))] - public ErrorCodeEnum? ErrorCode { get; private set; } + public ErrorTypeEnum? ErrorType { get; set; } /// public T Content { get; set; } @@ -156,7 +156,7 @@ public struct OperResult : IOperResult OperCode = operResult.OperCode; ErrorMessage = operResult.ErrorMessage; Exception = operResult.Exception; - ErrorCode = operResult.ErrorCode; + ErrorType = operResult.ErrorType; } /// @@ -167,7 +167,7 @@ public struct OperResult : IOperResult { OperCode = 500; ErrorMessage = msg; - ErrorCode = ErrorCodeEnum.InvokeFail; + ErrorType = ErrorTypeEnum.InvokeFail; } /// @@ -181,15 +181,15 @@ public struct OperResult : IOperResult //指定Timeout或OperationCanceled为超时取消 if (ex is TimeoutException || ex is OperationCanceledException) { - ErrorCode = ErrorCodeEnum.Canceled; + ErrorType = ErrorTypeEnum.Canceled; } else if (ex is ReturnErrorException) { - ErrorCode = ErrorCodeEnum.RetuenError; + ErrorType = ErrorTypeEnum.DeviceError; } else { - ErrorCode = ErrorCodeEnum.InvokeFail; + ErrorType = ErrorTypeEnum.InvokeFail; } } @@ -216,7 +216,7 @@ public struct OperResult : IOperResult #endif [JsonConverter(typeof(StringEnumConverter))] - public ErrorCodeEnum? ErrorCode { get; private set; } + public ErrorTypeEnum? ErrorType { get; set; } /// public T2 Content2 { get; set; } @@ -265,7 +265,7 @@ public struct OperResult : IOperResult OperCode = operResult.OperCode; ErrorMessage = operResult.ErrorMessage; Exception = operResult.Exception; - ErrorCode = operResult.ErrorCode; + ErrorType = operResult.ErrorType; } /// @@ -276,7 +276,7 @@ public struct OperResult : IOperResult { OperCode = 500; ErrorMessage = msg; - ErrorCode = ErrorCodeEnum.InvokeFail; + ErrorType = ErrorTypeEnum.InvokeFail; } /// @@ -290,15 +290,15 @@ public struct OperResult : IOperResult //指定Timeout或OperationCanceled为超时取消 if (ex is TimeoutException || ex is OperationCanceledException) { - ErrorCode = ErrorCodeEnum.Canceled; + ErrorType = ErrorTypeEnum.Canceled; } else if (ex is ReturnErrorException) { - ErrorCode = ErrorCodeEnum.RetuenError; + ErrorType = ErrorTypeEnum.DeviceError; } else { - ErrorCode = ErrorCodeEnum.InvokeFail; + ErrorType = ErrorTypeEnum.InvokeFail; } } @@ -325,7 +325,7 @@ public struct OperResult : IOperResult #endif [JsonConverter(typeof(StringEnumConverter))] - public ErrorCodeEnum? ErrorCode { get; private set; } + public ErrorTypeEnum? ErrorType { get; set; } /// public T Content { get; set; } @@ -375,7 +375,7 @@ public struct OperResult : IOperResult OperCode = operResult.OperCode; ErrorMessage = operResult.ErrorMessage; Exception = operResult.Exception; - ErrorCode = operResult.ErrorCode; + ErrorType = operResult.ErrorType; } /// @@ -386,7 +386,7 @@ public struct OperResult : IOperResult { OperCode = 500; ErrorMessage = msg; - ErrorCode = ErrorCodeEnum.InvokeFail; + ErrorType = ErrorTypeEnum.InvokeFail; } /// @@ -400,15 +400,15 @@ public struct OperResult : IOperResult //指定Timeout或OperationCanceled为超时取消 if (ex is TimeoutException || ex is OperationCanceledException) { - ErrorCode = ErrorCodeEnum.Canceled; + ErrorType = ErrorTypeEnum.Canceled; } else if (ex is ReturnErrorException) { - ErrorCode = ErrorCodeEnum.RetuenError; + ErrorType = ErrorTypeEnum.DeviceError; } else { - ErrorCode = ErrorCodeEnum.InvokeFail; + ErrorType = ErrorTypeEnum.InvokeFail; } } @@ -435,7 +435,7 @@ public struct OperResult : IOperResult #endif [JsonConverter(typeof(StringEnumConverter))] - public ErrorCodeEnum? ErrorCode { get; private set; } + public ErrorTypeEnum? ErrorType { get; set; } /// /// 返回一个成功结果,并带有结果值 @@ -458,145 +458,3 @@ public struct OperResult : IOperResult } } -/// -public class OperResultClass : IOperResult -{ - /// - /// 异常堆栈 - /// -#if NET6_0_OR_GREATER - [System.Text.Json.Serialization.JsonIgnore] -#endif - - [JsonIgnore] - public Exception? Exception { get; set; } - - /// - /// 从另一个操作对象中赋值信息 - /// - public OperResultClass(IOperResult operResult) - { - OperCode = operResult.OperCode; - ErrorMessage = operResult.ErrorMessage; - Exception = operResult.Exception; - ErrorCode = operResult.ErrorCode; - } - - /// - /// 传入错误信息 - /// - /// - public OperResultClass(string msg) - { - OperCode = 500; - ErrorMessage = msg; - ErrorCode = ErrorCodeEnum.InvokeFail; - } - - /// - /// 传入异常堆栈 - /// - public OperResultClass(Exception ex) - { - OperCode = 500; - Exception = ex; - ErrorMessage = ex.Message; - //指定Timeout或OperationCanceled为超时取消 - if (ex is TimeoutException || ex is OperationCanceledException) - { - ErrorCode = ErrorCodeEnum.Canceled; - } - else if (ex is ReturnErrorException) - { - ErrorCode = ErrorCodeEnum.RetuenError; - } - else - { - ErrorCode = ErrorCodeEnum.InvokeFail; - } - } - - /// - /// 传入错误信息与异常堆栈 - /// - public OperResultClass(string msg, Exception ex) : this(ex) - { - ErrorMessage = msg; - } - - /// - /// 默认构造,操作结果会是成功 - /// - public OperResultClass() - { - } - - /// - public int? OperCode { get; set; } - - /// - public bool IsSuccess => OperCode == null || OperCode == 0; - - /// - public string? ErrorMessage { get; set; } - - /// -#if NET6_0_OR_GREATER - [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] -#endif - - [JsonConverter(typeof(StringEnumConverter))] - public ErrorCodeEnum? ErrorCode { get; private set; } - - /// - /// 返回一个成功结果,并带有结果值 - /// - public static OperResult CreateSuccessResult(T content) - { - return new() { Content = content }; - } - - /// - /// 返回错误信息与异常堆栈等信息 - /// - /// - public override string ToString() - { - string messageString = ErrorMessage == null ? string.Empty : $"{DefaultResource.Localizer["ErrorMessage"]}:{ErrorMessage}"; - string exceptionString = Exception == null ? string.Empty : ErrorMessage == null ? $"{DefaultResource.Localizer["Exception"]}:{Exception}" : $"{Environment.NewLine}{DefaultResource.Localizer["Exception"]}:{Exception}"; - - return $"{messageString}{exceptionString}"; - } -} - -/// -public class OperResultClass : OperResultClass, IOperResult -{ - /// - public OperResultClass() : base() - { - } - - /// - public OperResultClass(IOperResult operResult) : base(operResult) - { - } - - /// - public OperResultClass(string msg) : base(msg) - { - } - - /// - public OperResultClass(Exception ex) : base(ex) - { - } - - /// - public OperResultClass(string msg, Exception ex) : base(msg, ex) - { - } - - /// - public T Content { get; set; } -} diff --git a/src/Foundation/ThingsGateway.Foundation/OperResult/OperResultClass.cs b/src/Foundation/ThingsGateway.Foundation/OperResult/OperResultClass.cs new file mode 100644 index 000000000..8e07b5426 --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation/OperResult/OperResultClass.cs @@ -0,0 +1,156 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace ThingsGateway.Foundation; + +/// +public class OperResultClass : IOperResult +{ + /// + /// 异常堆栈 + /// +#if NET6_0_OR_GREATER + [System.Text.Json.Serialization.JsonIgnore] +#endif + + [JsonIgnore] + public Exception? Exception { get; set; } + + /// + /// 从另一个操作对象中赋值信息 + /// + public OperResultClass(IOperResult operResult) + { + OperCode = operResult.OperCode; + ErrorMessage = operResult.ErrorMessage; + Exception = operResult.Exception; + ErrorType = operResult.ErrorType; + } + + /// + /// 传入错误信息 + /// + /// + public OperResultClass(string msg) + { + OperCode = 500; + ErrorMessage = msg; + ErrorType = ErrorTypeEnum.InvokeFail; + } + + /// + /// 传入异常堆栈 + /// + public OperResultClass(Exception ex) + { + OperCode = 500; + Exception = ex; + ErrorMessage = ex.Message; + //指定Timeout或OperationCanceled为超时取消 + if (ex is TimeoutException || ex is OperationCanceledException) + { + ErrorType = ErrorTypeEnum.Canceled; + } + else if (ex is ReturnErrorException) + { + ErrorType = ErrorTypeEnum.DeviceError; + } + else + { + ErrorType = ErrorTypeEnum.InvokeFail; + } + } + + /// + /// 传入错误信息与异常堆栈 + /// + public OperResultClass(string msg, Exception ex) : this(ex) + { + ErrorMessage = msg; + } + + /// + /// 默认构造,操作结果会是成功 + /// + public OperResultClass() + { + } + + /// + public int? OperCode { get; set; } + + /// + public bool IsSuccess => OperCode == null || OperCode == 0; + + /// + public string? ErrorMessage { get; set; } + + /// +#if NET6_0_OR_GREATER + [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] +#endif + + [JsonConverter(typeof(StringEnumConverter))] + public ErrorTypeEnum? ErrorType { get; set; } + + /// + /// 返回一个成功结果,并带有结果值 + /// + public static OperResult CreateSuccessResult(T content) + { + return new() { Content = content }; + } + + /// + /// 返回错误信息与异常堆栈等信息 + /// + /// + public override string ToString() + { + string messageString = ErrorMessage == null ? string.Empty : $"{DefaultResource.Localizer["ErrorMessage"]}:{ErrorMessage}"; + string exceptionString = Exception == null ? string.Empty : ErrorMessage == null ? $"{DefaultResource.Localizer["Exception"]}:{Exception}" : $"{Environment.NewLine}{DefaultResource.Localizer["Exception"]}:{Exception}"; + + return $"{messageString}{exceptionString}"; + } +} +/// +public class OperResultClass : OperResultClass, IOperResult +{ + /// + public OperResultClass() : base() + { + } + + /// + public OperResultClass(IOperResult operResult) : base(operResult) + { + } + + /// + public OperResultClass(string msg) : base(msg) + { + } + + /// + public OperResultClass(Exception ex) : base(ex) + { + } + + /// + public OperResultClass(string msg, Exception ex) : base(msg, ex) + { + } + + /// + public T Content { get; set; } +} diff --git a/src/Foundation/ThingsGateway.Foundation/Protocol/ProtocolBaseExtension.cs b/src/Foundation/ThingsGateway.Foundation/Protocol/ProtocolBaseExtension.cs deleted file mode 100644 index 823b82874..000000000 --- a/src/Foundation/ThingsGateway.Foundation/Protocol/ProtocolBaseExtension.cs +++ /dev/null @@ -1,91 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -namespace ThingsGateway.Foundation; - -/// -/// 协议基类 -/// -public static partial class ProtocolBaseExtension -{ - #region 读取 - - /// - public static async ValueTask> ReadBooleanAsync(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadBooleanAsync(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - /// - public static async ValueTask> ReadDoubleAsync(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadDoubleAsync(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - /// - public static async ValueTask> ReadInt16Async(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadInt16Async(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - /// - public static async ValueTask> ReadInt32Async(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadInt32Async(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - /// - public static async ValueTask> ReadInt64Async(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadInt64Async(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - /// - public static async ValueTask> ReadSingleAsync(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadSingleAsync(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - /// - public static async ValueTask> ReadStringAsync(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadStringAsync(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - /// - public static async ValueTask> ReadUInt16Async(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadUInt16Async(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - /// - public static async ValueTask> ReadUInt32Async(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadUInt32Async(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - /// - public static async ValueTask> ReadUInt64Async(this IProtocol protocol, string address, CancellationToken cancellationToken = default) - { - var result = await protocol.ReadUInt64Async(address, 1, null, cancellationToken).ConfigureAwait(false); - return result.OperResultFrom(() => result.Content[0]); - } - - #endregion 读取 -} diff --git a/src/Foundation/ThingsGateway.Foundation/Protocol/ProtocolWaitDataStatusExtension.cs b/src/Foundation/ThingsGateway.Foundation/Protocol/ProtocolWaitDataStatusExtension.cs deleted file mode 100644 index 321aa7b0d..000000000 --- a/src/Foundation/ThingsGateway.Foundation/Protocol/ProtocolWaitDataStatusExtension.cs +++ /dev/null @@ -1,39 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using TouchSocket.Resources; - -namespace ThingsGateway.Foundation; - -/// -public static partial class ProtocolWaitDataStatusExtension -{ - /// - /// 当状态不是时返回异常。 - /// - /// - public static OperResult Check(this WaitDataStatus status) - { - switch (status) - { - case WaitDataStatus.SetRunning: - return new(); - - case WaitDataStatus.Canceled: return new(new OperationCanceledException()); - case WaitDataStatus.Overtime: return new(new TimeoutException()); - case WaitDataStatus.Disposed: - case WaitDataStatus.Default: - default: - { - return new(new Exception(TouchSocketCoreResource.UnknownError)); - } - } - } -} diff --git a/src/Foundation/ThingsGateway.Foundation/Protocol/StructContentPraseExtensions.cs b/src/Foundation/ThingsGateway.Foundation/Protocol/StructContentPraseExtensions.cs deleted file mode 100644 index 4f5695ccb..000000000 --- a/src/Foundation/ThingsGateway.Foundation/Protocol/StructContentPraseExtensions.cs +++ /dev/null @@ -1,56 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -namespace ThingsGateway.Foundation; - -/// -/// StructContentPraseExtensions -/// -public static class StructContentPraseExtensions -{ - /// - /// 在返回的字节数组中解析每个变量的值 - /// 根据每个变量的 - /// 不支持变长字符串类型变量,不能存在于变量List中 - /// - /// 设备 - /// 设备变量List - /// 返回的字节数组 - /// 任意一个失败时抛出异常 - /// 解析结果 - public static OperResult PraseStructContent(this IEnumerable variables, IProtocol protocol, byte[] buffer, bool exWhenAny) where T : IVariable - { - var time = DateTime.Now; - var result = OperResult.Success; - foreach (var variable in variables) - { - IThingsGatewayBitConverter byteConverter = variable.ThingsGatewayBitConverter; - var dataType = variable.DataType; - int index = variable.Index; - try - { - var data = byteConverter.GetDataFormBytes(protocol, variable.RegisterAddress, buffer, index, dataType); - result = Set(variable, data); - if (exWhenAny) - if (!result.IsSuccess) - return result; - } - catch (Exception ex) - { - return new OperResult($"Error parsing byte array, address: {variable.RegisterAddress}, array length: {buffer.Length}, index: {index}, type: {dataType}", ex); - } - } - return result; - OperResult Set(IVariable organizedVariable, object num) - { - return organizedVariable.SetValue(num, time); - } - } -} diff --git a/src/Foundation/ThingsGateway.Foundation/ThingsGateway.Foundation.csproj b/src/Foundation/ThingsGateway.Foundation/ThingsGateway.Foundation.csproj index 0712e0abf..50f2c62e8 100644 --- a/src/Foundation/ThingsGateway.Foundation/ThingsGateway.Foundation.csproj +++ b/src/Foundation/ThingsGateway.Foundation/ThingsGateway.Foundation.csproj @@ -1,18 +1,29 @@ - - - + + + - - 工业设备通讯协议-基础类库 - true - + + 工业设备通讯协议-基础类库 + true + - - - - - - + + + + + + + + + + Never + + + + + + + diff --git a/src/Foundation/ThingsGateway.Foundation/Trans/ConverterConfig.cs b/src/Foundation/ThingsGateway.Foundation/Trans/ConverterConfig.cs new file mode 100644 index 000000000..8474297ce --- /dev/null +++ b/src/Foundation/ThingsGateway.Foundation/Trans/ConverterConfig.cs @@ -0,0 +1,138 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Text; + +using ThingsGateway.Foundation.Extension.String; +using ThingsGateway.NewLife.Collections; + +namespace ThingsGateway.Foundation; + +public class ConverterConfig +{ + public virtual DataFormatEnum? DataFormat { get; set; } + public virtual Encoding? Encoding { get; set; } + public virtual int? EncodingName + { + get + { + return Encoding?.CodePage; + } + set + { + try + { + if (value != null) + Encoding = Encoding.GetEncoding(value.Value); + else + Encoding = null; + } + catch { Encoding = null; } + } + } + public virtual bool? VariableStringLength { get; set; } + public virtual int? Stringlength { get; set; } + public virtual BcdFormatEnum? BcdFormat { get; set; } + + public ConverterConfig(string value) + { + if (value.IsNullOrEmpty()) return; + // 去除设备地址两端的空格 + value = value.Trim(); + + // 根据分号拆分附加信息 + var strs = value.SplitStringBySemicolon(); + DataFormatEnum? dataFormat = null; + Encoding? encoding = null; + bool? wstring = null; + int? stringlength = null; + BcdFormatEnum? bcdFormat = null; + foreach (var str in strs) + { + // 解析 dataFormat + if (str.StartsWith("data=", StringComparison.OrdinalIgnoreCase)) + { + var dataFormatName = str.Substring(5); + try { if (Enum.TryParse(dataFormatName, true, out var dataFormat1)) dataFormat = dataFormat1; } catch { } + } + else if (str.StartsWith("vsl=", StringComparison.OrdinalIgnoreCase)) + { + var wstringName = str.Substring(4); + try { if (bool.TryParse(wstringName, out var wstring1)) wstring = wstring1; } catch { } + } + // 解析 encoding + else if (str.StartsWith("encoding=", StringComparison.OrdinalIgnoreCase)) + { + var encodingName = str.Substring(9); + try { encoding = Encoding.GetEncoding(encodingName); } catch { } + } + // 解析 length + else if (str.StartsWith("len=", StringComparison.OrdinalIgnoreCase)) + { + var lenStr = str.Substring(4); + stringlength = lenStr.IsNullOrEmpty() ? null : Convert.ToUInt16(lenStr); + } + // 解析 bcdFormat + else if (str.StartsWith("bcd=", StringComparison.OrdinalIgnoreCase)) + { + var bcdName = str.Substring(4); + try { if (Enum.TryParse(bcdName, true, out var bcdFormat1)) bcdFormat = bcdFormat1; } catch { } + } + } + + DataFormat = dataFormat; + Encoding = encoding; + VariableStringLength = wstring; + Stringlength = stringlength; + BcdFormat = bcdFormat; + + + } + public override string ToString() + { + StringBuilder stringBuilder = Pool.StringBuilder.Get(); + if (DataFormat != null) + { + stringBuilder.Append("format="); + stringBuilder.Append(DataFormat.ToString()); + stringBuilder.Append(';'); + } + if (Encoding != null) + { + stringBuilder.Append("encoding="); + stringBuilder.Append(Encoding.WebName); + stringBuilder.Append(';'); + } + if (VariableStringLength != null) + { + stringBuilder.Append("vsl="); + stringBuilder.Append(VariableStringLength.ToString()); + stringBuilder.Append(';'); + } + if (Stringlength != null) + { + stringBuilder.Append("len="); + stringBuilder.Append(Stringlength.ToString()); + stringBuilder.Append(';'); + } + if (BcdFormat != null) + { + stringBuilder.Append("bcd="); + stringBuilder.Append(BcdFormat.ToString()); + stringBuilder.Append(';'); + } + var data = stringBuilder.ToString(); + + Pool.StringBuilder.Return(stringBuilder); + + return data; + } + +} diff --git a/src/Foundation/ThingsGateway.Foundation/Trans/IThingsGatewayBitConverter.cs b/src/Foundation/ThingsGateway.Foundation/Trans/IThingsGatewayBitConverter.cs index 523ed7dd2..add2f2984 100644 --- a/src/Foundation/ThingsGateway.Foundation/Trans/IThingsGatewayBitConverter.cs +++ b/src/Foundation/ThingsGateway.Foundation/Trans/IThingsGatewayBitConverter.cs @@ -51,15 +51,11 @@ public interface IThingsGatewayBitConverter /// int? StringLength { get; set; } - /// - /// 数组长度,只在连读时生效 - /// - int? ArrayLength { get; set; } - /// /// 获取或设置在解析字符串的时候是否将字节按照字单位反转 /// bool IsStringReverseByteWord { get; set; } + /// /// 获取或设置在解析字符串的时候是否变长字符串 /// @@ -330,12 +326,5 @@ public interface IThingsGatewayBitConverter /// decimal对象 decimal[] ToDecimal(byte[] buffer, int offset, int length); - /// - /// 获取指定的数据格式 - /// - /// - /// - IThingsGatewayBitConverter GetByDataFormat(DataFormatEnum dataFormat); - #endregion ToValue } diff --git a/src/Foundation/ThingsGateway.Foundation/Trans/ThingsGatewayBitConverter.cs b/src/Foundation/ThingsGateway.Foundation/Trans/ThingsGatewayBitConverter.cs index 5071a1d9c..f030f9f91 100644 --- a/src/Foundation/ThingsGateway.Foundation/Trans/ThingsGatewayBitConverter.cs +++ b/src/Foundation/ThingsGateway.Foundation/Trans/ThingsGatewayBitConverter.cs @@ -44,9 +44,6 @@ public partial class ThingsGatewayBitConverter : IThingsGatewayBitConverter /// public virtual int? StringLength { get; set; } - /// - public virtual int? ArrayLength { get; set; } - /// /// 构造函数 /// @@ -90,20 +87,6 @@ public partial class ThingsGatewayBitConverter : IThingsGatewayBitConverter /// public static readonly ThingsGatewayBitConverter LittleEndian; - /// - public virtual IThingsGatewayBitConverter GetByDataFormat(DataFormatEnum dataFormat) - { - var data = new ThingsGatewayBitConverter(EndianType); - data.Encoding = Encoding; - data.DataFormat = dataFormat; - data.BcdFormat = BcdFormat; - data.StringLength = StringLength; - data.ArrayLength = ArrayLength; - data.IsStringReverseByteWord = IsStringReverseByteWord; - - return data; - } - #region GetBytes /// @@ -712,7 +695,6 @@ public partial class ThingsGatewayBitConverter : IThingsGatewayBitConverter /// 实际字节信息 [MethodImpl(MethodImplOptions.AggressiveInlining)] private byte[] ByteTransDataFormat4(byte[] value, int offset) - { var numArray = new byte[4]; switch (DataFormat) diff --git a/src/Foundation/ThingsGateway.Foundation/Trans/ThingsGatewayBitConverterExtension.cs b/src/Foundation/ThingsGateway.Foundation/Trans/ThingsGatewayBitConverterExtension.cs index c07f03a12..35f430095 100644 --- a/src/Foundation/ThingsGateway.Foundation/Trans/ThingsGatewayBitConverterExtension.cs +++ b/src/Foundation/ThingsGateway.Foundation/Trans/ThingsGatewayBitConverterExtension.cs @@ -23,23 +23,30 @@ namespace ThingsGateway.Foundation; public static class ThingsGatewayBitConverterExtension { /// - /// 从设备地址中解析附加信息,包括 dataFormat=XX;4字节数据解析规则、encoding=XX;字符串解析规则、len=XX;读写长度、bcdFormat=XX; bcd解析规则等。 - /// 这个方法获取,并去掉地址中的所有额外信息。 + /// 从设备地址中解析附加信息 + /// 这个方法获取 /// 解析步骤将被缓存。 /// /// 设备地址 /// 默认的数据转换器 /// 实例 - public static IThingsGatewayBitConverter GetTransByAddress(this IThingsGatewayBitConverter defaultBitConverter, ref string? registerAddress) + public static IThingsGatewayBitConverter GetTransByAddress(this IThingsGatewayBitConverter defaultBitConverter, string? registerAddress) { if (registerAddress.IsNullOrEmpty()) return defaultBitConverter; var type = defaultBitConverter.GetType(); // 尝试从缓存中获取解析结果 - var cacheKey = $"{nameof(ThingsGatewayBitConverterExtension)}_{nameof(GetTransByAddress)}_{type.FullName}_{defaultBitConverter.ToJsonString()}_{registerAddress}"; + var cacheKey = $"{nameof(ThingsGatewayBitConverterExtension)}_{nameof(GetTransByAddress)}_{type.FullName}_{type.TypeHandle.Value}_{defaultBitConverter.ToJsonString()}_{registerAddress}"; if (MemoryCache.Instance.TryGetValue(cacheKey, out IThingsGatewayBitConverter cachedConverter)) { - return cachedConverter!; + if (cachedConverter.Equals(defaultBitConverter)) + { + return defaultBitConverter; + } + else + { + return (IThingsGatewayBitConverter)cachedConverter.Map(type); + } } // 去除设备地址两端的空格 @@ -50,7 +57,6 @@ public static class ThingsGatewayBitConverterExtension DataFormatEnum? dataFormat = null; Encoding? encoding = null; - int? length = null; bool? wstring = null; int? stringlength = null; BcdFormatEnum? bcdFormat = null; @@ -63,9 +69,9 @@ public static class ThingsGatewayBitConverterExtension var dataFormatName = str.Substring(5); try { if (Enum.TryParse(dataFormatName, true, out var dataFormat1)) dataFormat = dataFormat1; } catch { } } - else if (str.StartsWith("w=", StringComparison.OrdinalIgnoreCase)) + else if (str.StartsWith("vsl=", StringComparison.OrdinalIgnoreCase)) { - var wstringName = str.Substring(2); + var wstringName = str.Substring(4); try { if (bool.TryParse(wstringName, out var wstring1)) wstring = wstring1; } catch { } } // 解析 encoding @@ -80,12 +86,6 @@ public static class ThingsGatewayBitConverterExtension var lenStr = str.Substring(4); stringlength = lenStr.IsNullOrEmpty() ? null : Convert.ToUInt16(lenStr); } - // 解析 array length - else if (str.StartsWith("arraylen=", StringComparison.OrdinalIgnoreCase)) - { - var lenStr = str.Substring(9); - length = lenStr.IsNullOrEmpty() ? null : Convert.ToUInt16(lenStr); - } // 解析 bcdFormat else if (str.StartsWith("bcd=", StringComparison.OrdinalIgnoreCase)) { @@ -107,8 +107,9 @@ public static class ThingsGatewayBitConverterExtension registerAddress = sb.ToString(); // 如果没有解析出任何附加信息,则直接返回默认的数据转换器 - if (bcdFormat == null && length == null && stringlength == null && encoding == null && dataFormat == null && wstring == null) + if (bcdFormat == null && stringlength == null && encoding == null && dataFormat == null && wstring == null) { + MemoryCache.Instance.Set(cacheKey, defaultBitConverter!, 3600); return defaultBitConverter; } @@ -124,10 +125,6 @@ public static class ThingsGatewayBitConverterExtension { converter.BcdFormat = bcdFormat.Value; } - if (length != null) - { - converter.ArrayLength = length.Value; - } if (wstring != null) { converter.IsVariableStringLength = wstring.Value; @@ -151,9 +148,9 @@ public static class ThingsGatewayBitConverterExtension /// /// 根据数据类型获取字节数组 /// - public static byte[] GetBytesFormData(this IThingsGatewayBitConverter byteConverter, JToken value, DataTypeEnum dataType) + public static byte[] GetBytesFormData(this IThingsGatewayBitConverter byteConverter, JToken value, DataTypeEnum dataType, int arrayLength = 1) { - if (byteConverter.ArrayLength > 1) + if (arrayLength > 1) { switch (dataType) { @@ -191,7 +188,7 @@ public static class ThingsGatewayBitConverterExtension default: List bytes = new(); String[] strings = value.ToObject(); - for (int i = 0; i < byteConverter.ArrayLength; i++) + for (int i = 0; i < arrayLength; i++) { var data = byteConverter.GetBytes(strings[i]); bytes.AddRange(data); @@ -243,75 +240,75 @@ public static class ThingsGatewayBitConverterExtension /// /// 根据数据类型获取实际值 /// - public static object GetDataFormBytes(this IThingsGatewayBitConverter byteConverter, IProtocol protocol, string address, byte[] buffer, int index, DataTypeEnum dataType) + public static object GetDataFormBytes(this IThingsGatewayBitConverter byteConverter, IDevice device, string address, byte[] buffer, int index, DataTypeEnum dataType, int arrayLength) { switch (dataType) { case DataTypeEnum.Boolean: - return byteConverter.ArrayLength > 1 ? - byteConverter.ToBoolean(buffer, index, byteConverter.ArrayLength.Value, protocol.BitReverse(address)) : - byteConverter.ToBoolean(buffer, index, protocol.BitReverse(address)); + return arrayLength > 1 ? + byteConverter.ToBoolean(buffer, index, arrayLength, device.BitReverse(address)) : + byteConverter.ToBoolean(buffer, index, device.BitReverse(address)); case DataTypeEnum.Byte: return - byteConverter.ArrayLength > 1 ? - byteConverter.ToByte(buffer, index, byteConverter.ArrayLength.Value) : + arrayLength > 1 ? + byteConverter.ToByte(buffer, index, arrayLength) : byteConverter.ToByte(buffer, index); case DataTypeEnum.Int16: return - byteConverter.ArrayLength > 1 ? - byteConverter.ToInt16(buffer, index, byteConverter.ArrayLength.Value) : + arrayLength > 1 ? + byteConverter.ToInt16(buffer, index, arrayLength) : byteConverter.ToInt16(buffer, index); case DataTypeEnum.UInt16: return - byteConverter.ArrayLength > 1 ? - byteConverter.ToUInt16(buffer, index, byteConverter.ArrayLength.Value) : + arrayLength > 1 ? + byteConverter.ToUInt16(buffer, index, arrayLength) : byteConverter.ToUInt16(buffer, index); case DataTypeEnum.Int32: return - byteConverter.ArrayLength > 1 ? - byteConverter.ToInt32(buffer, index, byteConverter.ArrayLength.Value) : + arrayLength > 1 ? + byteConverter.ToInt32(buffer, index, arrayLength) : byteConverter.ToInt32(buffer, index); case DataTypeEnum.UInt32: return - byteConverter.ArrayLength > 1 ? - byteConverter.ToUInt32(buffer, index, byteConverter.ArrayLength.Value) : + arrayLength > 1 ? + byteConverter.ToUInt32(buffer, index, arrayLength) : byteConverter.ToUInt32(buffer, index); case DataTypeEnum.Int64: return - byteConverter.ArrayLength > 1 ? - byteConverter.ToInt64(buffer, index, byteConverter.ArrayLength.Value) : + arrayLength > 1 ? + byteConverter.ToInt64(buffer, index, arrayLength) : byteConverter.ToInt64(buffer, index); case DataTypeEnum.UInt64: return - byteConverter.ArrayLength > 1 ? - byteConverter.ToUInt64(buffer, index, byteConverter.ArrayLength.Value) : + arrayLength > 1 ? + byteConverter.ToUInt64(buffer, index, arrayLength) : byteConverter.ToUInt64(buffer, index); case DataTypeEnum.Single: return - byteConverter.ArrayLength > 1 ? - byteConverter.ToSingle(buffer, index, byteConverter.ArrayLength.Value) : + arrayLength > 1 ? + byteConverter.ToSingle(buffer, index, arrayLength) : byteConverter.ToSingle(buffer, index); case DataTypeEnum.Double: return - byteConverter.ArrayLength > 1 ? - byteConverter.ToDouble(buffer, index, byteConverter.ArrayLength.Value) : + arrayLength > 1 ? + byteConverter.ToDouble(buffer, index, arrayLength) : byteConverter.ToDouble(buffer, index); case DataTypeEnum.String: default: - if (byteConverter.ArrayLength > 1) + if (arrayLength > 1) { List strings = new(); - for (int i = 0; i < byteConverter.ArrayLength; i++) + for (int i = 0; i < arrayLength; i++) { var data = byteConverter.ToString(buffer, index + i * byteConverter.StringLength ?? 1, byteConverter.StringLength ?? 1); strings.Add(data); diff --git a/src/Foundation/ThingsGateway.Foundation/Utils/JTokenUtil.cs b/src/Foundation/ThingsGateway.Foundation/Utils/JTokenUtil.cs index f69d2ce6d..a54f53029 100644 --- a/src/Foundation/ThingsGateway.Foundation/Utils/JTokenUtil.cs +++ b/src/Foundation/ThingsGateway.Foundation/Utils/JTokenUtil.cs @@ -50,6 +50,8 @@ public static class JTokenUtil /// public static object? GetObjectFromJToken(this JToken jtoken) { + if (jtoken == null) + return null; switch (jtoken.Type) { case JTokenType.Object: diff --git a/src/Foundation/ThingsGateway.Foundation/Variable/IVariable.cs b/src/Foundation/ThingsGateway.Foundation/Variable/IVariable.cs index 98bb999c7..7bf558404 100644 --- a/src/Foundation/ThingsGateway.Foundation/Variable/IVariable.cs +++ b/src/Foundation/ThingsGateway.Foundation/Variable/IVariable.cs @@ -35,6 +35,11 @@ public interface IVariable /// string? RegisterAddress { get; set; } + /// + /// 数组长度 + /// + int? ArrayLength { get; set; } + /// /// 数据转换规则 /// diff --git a/src/Foundation/ThingsGateway.Foundation/Variable/VariableClass.cs b/src/Foundation/ThingsGateway.Foundation/Variable/VariableClass.cs index c464e3c12..a43fa7c2d 100644 --- a/src/Foundation/ThingsGateway.Foundation/Variable/VariableClass.cs +++ b/src/Foundation/ThingsGateway.Foundation/Variable/VariableClass.cs @@ -32,12 +32,15 @@ public class VariableClass : IVariable /// 执行间隔 /// public virtual string? IntervalTime { get; set; } - + /// + /// 数组长度 + /// + public virtual int? ArrayLength { get; set; } /// public bool IsOnline { get; set; } /// - public string LastErrorMessage => VariableSource?.LastErrorMessage; + public virtual string LastErrorMessage => VariableSource?.LastErrorMessage; /// /// 寄存器地址 diff --git a/src/Foundation/ThingsGateway.Foundation/Variable/VariableSourceClass.cs b/src/Foundation/ThingsGateway.Foundation/Variable/VariableSourceClass.cs index 8f7eec7c4..fd30d76ec 100644 --- a/src/Foundation/ThingsGateway.Foundation/Variable/VariableSourceClass.cs +++ b/src/Foundation/ThingsGateway.Foundation/Variable/VariableSourceClass.cs @@ -15,7 +15,7 @@ namespace ThingsGateway.Foundation; /// public class VariableSourceClass : IVariableSource { - private List _variableRunTimes = new(); + private List _variableRuntimes = new(); /// public string? LastErrorMessage { get; set; } @@ -32,13 +32,13 @@ public class VariableSourceClass : IVariableSource /// /// 已打包变量 /// - public IEnumerable VariableRunTimes => _variableRunTimes; + public IEnumerable VariableRuntimes => _variableRuntimes; /// public virtual void AddVariable(IVariable variable) { variable.VariableSource = this; - _variableRunTimes.Add(variable); + _variableRuntimes.Add(variable); } /// @@ -48,6 +48,6 @@ public class VariableSourceClass : IVariableSource { variable.VariableSource = this; } - _variableRunTimes.AddRange(variables); + _variableRuntimes.AddRange(variables); } } diff --git a/src/FoundationVersion.props b/src/FoundationVersion.props deleted file mode 100644 index e4a562b50..000000000 --- a/src/FoundationVersion.props +++ /dev/null @@ -1,9 +0,0 @@ - - - 9.0.3.6 - - - - - - diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Attributes/DynamicMethodAttribute.cs b/src/Gateway/ThingsGateway.Gateway.Application/Attributes/DynamicMethodAttribute.cs index 194f404fc..0981ac411 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Attributes/DynamicMethodAttribute.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Attributes/DynamicMethodAttribute.cs @@ -18,7 +18,7 @@ namespace ThingsGateway.Gateway.Application; public sealed class DynamicMethodAttribute : Attribute { /// - public DynamicMethodAttribute(string desc, string? remark = null) + public DynamicMethodAttribute(string? desc = null, string? remark = null) { Description = desc; Remark = remark; diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Attributes/DynamicPropertyAttribute.cs b/src/Gateway/ThingsGateway.Gateway.Application/Attributes/DynamicPropertyAttribute.cs index 1f4b1dc4a..1b34b89e8 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Attributes/DynamicPropertyAttribute.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Attributes/DynamicPropertyAttribute.cs @@ -18,11 +18,22 @@ namespace ThingsGateway.Gateway.Application; [AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)] public sealed class DynamicPropertyAttribute : Attribute { + /// + public DynamicPropertyAttribute(string? desc = null, string? remark = null, string? groupName = null) + { + Description = desc; + Remark = remark; + GroupName = groupName; + } + /// /// 名称 /// public string? Description { get; set; } - + /// + /// 分组名称 + /// + public string? GroupName { get; set; } /// /// 描述 /// diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Common/AsyncReadWriteLock.cs b/src/Gateway/ThingsGateway.Gateway.Application/Common/AsyncReadWriteLock.cs new file mode 100644 index 000000000..a61a675ae --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Common/AsyncReadWriteLock.cs @@ -0,0 +1,67 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using TouchSocket.Core; + +namespace ThingsGateway.Gateway.Application; + +public class AsyncReadWriteLock +{ + private AsyncAutoResetEvent _readerLock = new AsyncAutoResetEvent(false); // 控制读计数 + private long _writerCount = 0; // 当前活跃的写线程数 + + /// + /// 获取读锁,支持多个线程并发读取,但写入时会阻止所有读取。 + /// + public async Task ReaderLockAsync(CancellationToken cancellationToken = default) + { + if (Interlocked.Read(ref _writerCount) > 0) + { + // 第一个读者需要获取写入锁,防止写操作 + await _readerLock.WaitOneAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// 获取写锁,阻止所有读取。 + /// + public IDisposable WriterLock() + { + Interlocked.Increment(ref _writerCount); + return new Writer(this); + } + + private void ReleaseWriter() + { + Interlocked.Decrement(ref _writerCount); + + if (Interlocked.Read(ref _writerCount) == 0) + { + var resetEvent = _readerLock; + _readerLock = new(false); + resetEvent.SafeDispose(); + } + } + + private struct Writer : IDisposable + { + private readonly AsyncReadWriteLock _lock; + + public Writer(AsyncReadWriteLock lockObj) + { + _lock = lockObj; + } + + public void Dispose() + { + _lock.ReleaseWriter(); + } + } +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Common/DoTask.cs b/src/Gateway/ThingsGateway.Gateway.Application/Common/DoTask.cs index 96921dd3b..3eaffd9bf 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Common/DoTask.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Common/DoTask.cs @@ -8,30 +8,21 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; +using TouchSocket.Core; + namespace ThingsGateway.Gateway.Application; [ThingsGateway.DependencyInjection.SuppressSniffer] public class DoTask { /// - /// 取消令牌与调度取消令牌合集 + /// 取消令牌 /// private CancellationTokenSource? _cancelTokenSource; - /// - /// 调度取消令牌 - /// - private CancellationToken _schedulerCancelToken; - - /// - /// 取消令牌 - /// - private CancellationTokenSource? _triggerCancelTokenSource; - - public DoTask(Func doWork, ILogger logger, string taskName = null) + public DoTask(Func doWork, ILog logger, string taskName = null) { DoWork = doWork; Logger = logger; TaskName = taskName; } @@ -40,10 +31,7 @@ public class DoTask /// 执行任务方法 /// public Func DoWork { get; } - - public bool IsStoped => PrivateTask == null; - private IStringLocalizer Localizer { get; } = App.CreateLocalizerByType(typeof(DoTask))!; - private ILogger Logger { get; } + private ILog Logger { get; } private Task PrivateTask { get; set; } private string TaskName { get; } @@ -53,36 +41,52 @@ public class DoTask /// 调度取消令牌 public void Start(CancellationToken? cancellationToken = null) { - _schedulerCancelToken = cancellationToken ?? CancellationToken.None; - - _triggerCancelTokenSource = new CancellationTokenSource(); - _cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_triggerCancelTokenSource.Token, _schedulerCancelToken); - - // 异步执行 - PrivateTask = Task.Factory.StartNew(async () => + try { - while (!_cancelTokenSource.IsCancellationRequested) + WaitLock.Wait(); + + if (cancellationToken != null && cancellationToken.Value.CanBeCanceled) { - try - { - if (_cancelTokenSource.IsCancellationRequested) - return; - await DoWork(_cancelTokenSource.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - catch (ObjectDisposedException) - { - } - catch (Exception ex) - { - Logger?.LogWarning(ex, "DoWork"); - } + _cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken.Value); } - }, TaskCreationOptions.LongRunning); + else + { + _cancelTokenSource = new CancellationTokenSource(); + } + + // 异步执行 + PrivateTask = Task.Run(Do); + } + finally + { + WaitLock.Release(); + } } + private async Task Do() + { + while (!_cancelTokenSource.IsCancellationRequested) + { + try + { + if (_cancelTokenSource.IsCancellationRequested) + return; + await DoWork(_cancelTokenSource.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + Logger?.LogWarning(ex, "DoWork"); + } + } + } + + private WaitLock WaitLock = new(); /// /// 停止操作 /// @@ -90,26 +94,28 @@ public class DoTask { try { - _triggerCancelTokenSource?.Cancel(); - _cancelTokenSource?.Cancel(); - _triggerCancelTokenSource.Dispose(); - _cancelTokenSource?.Dispose(); - } - catch (Exception ex) - { - Console.WriteLine(ex); - } - try - { + await WaitLock.WaitAsync().ConfigureAwait(false); + + try + { + _cancelTokenSource?.Cancel(); + _cancelTokenSource?.Dispose(); + } + catch (Exception ex) + { + Logger?.LogWarning(ex, "Cancel error"); + } + if (PrivateTask != null) { try { if (TaskName != null) - Logger?.LogInformation(Localizer[$"Stoping", TaskName]); - await PrivateTask.WaitAsync(waitTime ?? TimeSpan.FromSeconds(10)).ConfigureAwait(false); + Logger?.LogInformation($"{TaskName} Stoping"); + if (waitTime != null) + await PrivateTask.WaitAsync(waitTime.Value).ConfigureAwait(false); if (TaskName != null) - Logger?.LogInformation(Localizer[$"Stoped", TaskName]); + Logger?.LogInformation($"{TaskName} Stoped"); } catch (ObjectDisposedException) { @@ -117,27 +123,20 @@ public class DoTask catch (TimeoutException) { if (TaskName != null) - Logger?.LogWarning(Localizer[$"Timeout", TaskName]); + Logger?.LogWarning($"{TaskName} Stop timeout, exiting wait block"); } catch (Exception ex) { if (TaskName != null) - Logger?.LogWarning(ex, Localizer[$"Error", TaskName]); - } - try - { - PrivateTask?.Dispose(); - PrivateTask = null; - } - catch (Exception ex) - { - Console.WriteLine(ex); + Logger?.LogWarning(ex, $"{TaskName} Stop error"); } + PrivateTask = null; + } } - catch (Exception ex) + finally { - Console.WriteLine(ex); + WaitLock.Release(); } } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Common/ExportFilter.cs b/src/Gateway/ThingsGateway.Gateway.Application/Common/ExportFilter.cs new file mode 100644 index 000000000..a060d53f4 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Common/ExportFilter.cs @@ -0,0 +1,23 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +namespace ThingsGateway.Gateway.Application; + +public class ExportFilter +{ + public FilterKeyValueAction FilterKeyValueAction { get; set; } + public string? PluginName { get; set; } + public PluginTypeEnum? PluginType { get; set; } + public long? ChannelId { get; set; } + public long? DeviceId { get; set; } + public QueryPageOptions QueryPageOptions { get; set; } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/Dto/InternalTableColumn.cs b/src/Gateway/ThingsGateway.Gateway.Application/Common/InternalTableColumn.cs similarity index 97% rename from src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/Dto/InternalTableColumn.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Common/InternalTableColumn.cs index ae39c4245..fa4829c15 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/Dto/InternalTableColumn.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Common/InternalTableColumn.cs @@ -20,7 +20,7 @@ namespace BootstrapBlazor.Components; /// 字段名称 /// 字段类型 /// 显示文字 -public class InternalTableColumn(string fieldName, Type fieldType, string? fieldText = null) : IEditorItem, ITableColumn +internal sealed class InternalTableColumn(string fieldName, Type fieldType, string? fieldText = null) : IEditorItem, ITableColumn { public IEnumerable>? ComponentParameters { get; set; } public Type? ComponentType { get; set; } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Common/StringOrdinalIgnoreCaseEqualityComparer.cs b/src/Gateway/ThingsGateway.Gateway.Application/Common/StringOrdinalIgnoreCaseEqualityComparer.cs new file mode 100644 index 000000000..ab2a8d73d --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Common/StringOrdinalIgnoreCaseEqualityComparer.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using System.Diagnostics.CodeAnalysis; + +namespace ThingsGateway.Gateway.Application; + +public sealed class StringOrdinalIgnoreCaseEqualityComparer : EqualityComparer +{ + public new static StringOrdinalIgnoreCaseEqualityComparer Default = new StringOrdinalIgnoreCaseEqualityComparer(); + public override bool Equals(string? x, string? y) + { + return x.Equals(y, StringComparison.OrdinalIgnoreCase); + } + + public override int GetHashCode([DisallowNull] string obj) + { + return obj.ToUpper().GetHashCode(); + } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Controller/ConfigInfoController.cs b/src/Gateway/ThingsGateway.Gateway.Application/Controller/ConfigInfoController.cs deleted file mode 100644 index ecc3eef1d..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/Controller/ConfigInfoController.cs +++ /dev/null @@ -1,74 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -using System.ComponentModel; - -namespace ThingsGateway.Gateway.Application; - -/// -/// 采集设备 -/// -[ApiDescriptionSettings("ThingsGateway.OpenApi", Order = 200)] -[DisplayName("获取配置信息")] -[Route("openApi/configInfo")] -[RolePermission] -[Authorize(AuthenticationSchemes = "Bearer")] -public class ConfigInfoController : ControllerBase -{ - public ConfigInfoController( - IChannelService channelService, - IVariableService variableService, - IDeviceService deviceService) - { - _variableService = variableService; - _collectDeviceService = deviceService; - _channelService = channelService; - } - - private IChannelService _channelService { get; set; } - private IDeviceService _collectDeviceService { get; set; } - private IVariableService _variableService { get; set; } - - /// - /// 获取通道信息 - /// - /// - [HttpGet("channelList")] - [DisplayName("获取通道信息")] - public Task> GetChannelList([FromQuery] ChannelPageInput input) - { - return _channelService.PageAsync(input); - } - - /// - /// 获取设备信息 - /// - /// - [HttpGet("deviceList")] - [DisplayName("获取设备信息")] - public Task> GetCollectDeviceList([FromQuery] DevicePageInput input) - { - return _collectDeviceService.PageAsync(input); - } - - /// - /// 获取变量信息 - /// - /// - [HttpGet("variableList")] - [DisplayName("获取变量信息")] - public Task> GetVariableList([FromQuery] VariablePageInput input) - { - return _variableService.PageAsync(input); - } -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Controller/ControlController.cs b/src/Gateway/ThingsGateway.Gateway.Application/Controller/ControlController.cs index 78dd5694c..039786eaf 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Controller/ControlController.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Controller/ControlController.cs @@ -13,6 +13,8 @@ using Microsoft.AspNetCore.Mvc; using System.ComponentModel; +using ThingsGateway.FriendlyException; + namespace ThingsGateway.Gateway.Application; /// @@ -25,15 +27,6 @@ namespace ThingsGateway.Gateway.Application; [Authorize(AuthenticationSchemes = "Bearer")] public class ControlController : ControllerBase { - private ISysUserService _sysUserService; - public ControlController(IRpcService rpcService, ISysUserService sysUserService) - { - _sysUserService = sysUserService; - _rpcService = rpcService; - } - - private IRpcService _rpcService { get; set; } - /// /// 清空全部缓存 @@ -58,75 +51,54 @@ public class ControlController : ControllerBase App.GetService().DeleteChannelFromCache(); } - /// - /// 控制业务线程启停 + /// 控制设备线程暂停 /// /// [HttpPost("pauseBusinessThread")] - [DisplayName("控制业务线程启停")] - public async Task PauseBusinessThread(long id, bool isStart) + [DisplayName("控制设备线程启停")] + public async Task PauseDeviceThreadAsync(long id, bool pause) { - var data = GlobalData.BusinessDevices.FirstOrDefault(a => a.Value.Id == id).Value; - if (data != null) - await _sysUserService.CheckApiDataScopeAsync(data.CreateOrgId, data.CreateUserId).ConfigureAwait(false); - GlobalData.BusinessDeviceHostedService.PauseThread(id, isStart); + if (GlobalData.Devices.TryGetValue(id, out var device)) + { + await GlobalData.SysUserService.CheckApiDataScopeAsync(device.CreateOrgId, device.CreateUserId).ConfigureAwait(false); + if (device.Driver != null) + { + device.Driver.PauseThread(pause); + return; + } + } + throw Oops.Bah("device not found"); + } + /// + /// 重启全部线程 + /// + /// + [HttpPost("restartAllThread")] + [DisplayName("重启全部线程")] + public async Task RestartAllThread() + { + var data = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + await GlobalData.ChannelThreadManage.RestartChannelAsync(data.Select(a => a.Value)).ConfigureAwait(false); } /// - /// 控制采集线程启停 + /// 重启设备线程 /// /// - [HttpPost("pauseCollectThread")] - [DisplayName("控制采集线程启停")] - public async Task PauseCollectThread(long id, bool isStart) + [HttpPost("restartThread")] + [DisplayName("重启设备线程")] + public async Task RestartDeviceThreadAsync(long deviceId) { - var data = GlobalData.CollectDevices.FirstOrDefault(a => a.Value.Id == id).Value; - if (data != null) - await _sysUserService.CheckApiDataScopeAsync(data.CreateOrgId, data.CreateUserId).ConfigureAwait(false); - GlobalData.CollectDeviceHostedService.PauseThread(id, isStart); - } - - /// - /// 重启业务线程 - /// - /// - [HttpPost("restartBusinessThread")] - [DisplayName("重启业务线程")] - public async Task RestartBusinessDeviceThread(long id) - { - if (id <= 0) + if (GlobalData.Devices.TryGetValue(deviceId, out var deviceRuntime)) { - await GlobalData.BusinessDeviceHostedService.RestartAsync().ConfigureAwait(false); - } - else - { - var data = GlobalData.BusinessDevices.FirstOrDefault(a => a.Value.Id == id).Value; - if (data != null) - await _sysUserService.CheckApiDataScopeAsync(data.CreateOrgId, data.CreateUserId).ConfigureAwait(false); - await GlobalData.BusinessDeviceHostedService.RestartChannelThreadAsync(id, true).ConfigureAwait(false); - } - } - - /// - /// 重启采集线程 - /// - /// - [HttpPost("restartCollectThread")] - [DisplayName("重启采集线程")] - public async Task RestartCollectDeviceThread(long id) - { - if (id <= 0) - { - await GlobalData.CollectDeviceHostedService.RestartAsync().ConfigureAwait(false); - } - else - { - var data = GlobalData.CollectDevices.FirstOrDefault(a => a.Value.Id == id).Value; - if (data != null) - await _sysUserService.CheckApiDataScopeAsync(data.CreateOrgId, data.CreateUserId).ConfigureAwait(false); - await GlobalData.CollectDeviceHostedService.RestartChannelThreadAsync(id, true).ConfigureAwait(false); + await GlobalData.SysUserService.CheckApiDataScopeAsync(deviceRuntime.CreateOrgId, deviceRuntime.CreateUserId).ConfigureAwait(false); + if (GlobalData.TryGetDeviceThreadManage(deviceRuntime, out var deviceThreadManage)) + { + await deviceThreadManage.RestartDeviceAsync(deviceRuntime, false).ConfigureAwait(false); + } } + throw Oops.Bah("device not found"); } /// @@ -134,14 +106,13 @@ public class ControlController : ControllerBase /// [HttpPost("writeVariables")] [DisplayName("写入变量")] - public async Task> WriteDeviceMethods(Dictionary objs) + public async Task> WriteVariablesAsync(Dictionary objs) { - var data = GlobalData.ReadOnlyVariables.Where(a => objs.ContainsKey(a.Key)); if (data != null) - await _sysUserService.CheckApiDataScopeAsync(data.Select(a => a.Value.CreateOrgId), data.Select(a => a.Value.CreateUserId)).ConfigureAwait(false); + await GlobalData.SysUserService.CheckApiDataScopeAsync(data.Select(a => a.Value.CreateOrgId), data.Select(a => a.Value.CreateUserId)).ConfigureAwait(false); - return await _rpcService.InvokeDeviceMethodAsync($"WebApi-{UserManager.UserAccount}-{App.HttpContext.Connection.RemoteIpAddress.MapToIPv4()}", objs).ConfigureAwait(false); + return await GlobalData.RpcService.InvokeDeviceMethodAsync($"WebApi-{UserManager.UserAccount}-{App.HttpContext.Connection.RemoteIpAddress.MapToIPv4()}", objs).ConfigureAwait(false); } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Controller/GatewayExportController.cs b/src/Gateway/ThingsGateway.Gateway.Application/Controller/GatewayExportController.cs index 38d47e78f..17aff6777 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Controller/GatewayExportController.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Controller/GatewayExportController.cs @@ -8,83 +8,11 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using BootstrapBlazor.Components; - -using Mapster; - using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace ThingsGateway.Gateway.Application; -/// -/// 查询条件实体类 -/// -public class QueryPageOptionsDto -{ - /// - /// 获得/设置 模糊查询关键字 - /// - public string? SearchText { get; set; } - /// - /// 获得 排序字段名称 由 设置 - /// - public string? SortName { get; set; } - - /// - /// 获得 排序方式 由 设置 - /// - public SortOrder SortOrder { get; set; } - - /// - /// 获得/设置 多列排序集合 默认为 Empty 内部为 "Name" "Age desc" 由 设置 - /// - public List SortList { get; } = new(10); - - /// - /// 获得/设置 自定义多列排序集合 默认为 Empty 内部为 "Name" "Age desc" 由 设置 - /// - public List AdvancedSortList { get; } = new(10); - - /// - /// 获得 搜索条件绑定模型 未设置 时为 泛型模型 - /// - public object? SearchModel { get; set; } - - /// - /// 获得 当前页码 首页为 第一页 - /// - public int PageIndex { get; set; } = 1; - - /// - /// 获得 请求读取数据开始行 默认 0 - /// - /// 开启虚拟滚动 时使用 - public int StartIndex { get; set; } - - public int PageItems { get; set; } = 20; - - /// - /// 获得 是否分页查询模式 默认为 false 由 设置 - /// - public bool IsPage { get; set; } - - /// - /// 获得 是否为虚拟滚动查询模式 默认为 false 由 设置 - /// - public bool IsVirtualScroll { get; set; } - - /// - /// 获得 是否为首次查询 默认 false - /// - /// 组件首次查询数据时为 true - public bool IsFirstQuery { get; set; } -} -public class ExportDto -{ - public QueryPageOptionsDto QueryPageOptions { get; set; } = new(); - public FilterKeyValueAction FilterKeyValueAction { get; set; } = new(); -} /// /// 导出文件 /// @@ -94,15 +22,15 @@ public class ExportDto [Authorize] public class GatewayExportController : ControllerBase { - private readonly IChannelService _channelService; - private readonly IDeviceService _deviceService; - private readonly IVariableService _variableService; + private readonly IChannelRuntimeService _channelService; + private readonly IDeviceRuntimeService _deviceService; + private readonly IVariableRuntimeService _variableService; private readonly IImportExportService _importExportService; public GatewayExportController( - IChannelService channelService, - IDeviceService deviceService, - IVariableService variableService, + IChannelRuntimeService channelService, + IDeviceRuntimeService deviceService, + IVariableRuntimeService variableService, IImportExportService importExportService ) { @@ -110,20 +38,19 @@ public class GatewayExportController : ControllerBase _deviceService = deviceService; _variableService = variableService; _importExportService = importExportService; - } /// /// 下载设备 /// /// - [HttpPost("businessdevice")] - public async Task DownloadBusinessDeviceAsync([FromBody] ExportDto input) + [HttpPost("device")] + public async Task DownloadDeviceAsync([FromBody] ExportFilter input) { input.QueryPageOptions.IsPage = false; input.QueryPageOptions.IsVirtualScroll = false; - var sheets = await _deviceService.ExportDeviceAsync(input.QueryPageOptions.Adapt(), PluginTypeEnum.Business, input.FilterKeyValueAction).ConfigureAwait(false); - return await _importExportService.ExportAsync(sheets, "BusinessDevice", false).ConfigureAwait(false); + var sheets = await _deviceService.ExportDeviceAsync(input).ConfigureAwait(false); + return await _importExportService.ExportAsync(sheets, "Device", false).ConfigureAwait(false); } @@ -132,40 +59,27 @@ public class GatewayExportController : ControllerBase /// /// [HttpPost("channel")] - public async Task DownloadChannelAsync([FromBody] ExportDto input) + public async Task DownloadChannelAsync([FromBody] ExportFilter input) { input.QueryPageOptions.IsPage = false; input.QueryPageOptions.IsVirtualScroll = false; - var sheets = await _channelService.ExportChannelAsync(input.QueryPageOptions.Adapt(), input.FilterKeyValueAction).ConfigureAwait(false); + var sheets = await _channelService.ExportChannelAsync(input).ConfigureAwait(false); return await _importExportService.ExportAsync(sheets, "Channel", false).ConfigureAwait(false); } - /// - /// 下载设备 - /// - /// - [HttpPost("collectdevice")] - public async Task DownloadCollectDeviceAsync([FromBody] ExportDto input) - { - input.QueryPageOptions.IsPage = false; - input.QueryPageOptions.IsVirtualScroll = false; - - var sheets = await _deviceService.ExportDeviceAsync(input.QueryPageOptions.Adapt(), PluginTypeEnum.Collect, input.FilterKeyValueAction).ConfigureAwait(false); - return await _importExportService.ExportAsync(sheets, "CollectDevice", false).ConfigureAwait(false); - } /// /// 下载变量 /// /// [HttpPost("variable")] - public async Task DownloadVariableAsync([FromBody] ExportDto input) + public async Task DownloadVariableAsync([FromBody] ExportFilter input) { input.QueryPageOptions.IsPage = false; input.QueryPageOptions.IsVirtualScroll = false; - var sheets = await _variableService.ExportVariableAsync(input.QueryPageOptions.Adapt(), input.FilterKeyValueAction).ConfigureAwait(false); + var sheets = await _variableService.ExportVariableAsync(input).ConfigureAwait(false); return await _importExportService.ExportAsync(sheets, "Variable", false).ConfigureAwait(false); } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Controller/RuntimeInfoController.cs b/src/Gateway/ThingsGateway.Gateway.Application/Controller/RuntimeInfoController.cs index f476063d1..8d6103536 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Controller/RuntimeInfoController.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Controller/RuntimeInfoController.cs @@ -17,7 +17,6 @@ using SqlSugar; using System.ComponentModel; -using ThingsGateway.Extension.Generic; using ThingsGateway.NewLife.Extension; namespace ThingsGateway.Gateway.Application; @@ -31,30 +30,44 @@ namespace ThingsGateway.Gateway.Application; [Authorize(AuthenticationSchemes = "Bearer")] public class RuntimeInfoController : ControllerBase { - private ISysUserService _sysUserService; - public RuntimeInfoController(ISysUserService sysUserService) + /// + /// 获取通道信息 + /// + /// + [HttpGet("channelList")] + [DisplayName("获取通道信息")] + public async Task> GetChannelListAsync(ChannelPageInput input) { - _sysUserService = sysUserService; + + var channelRuntimes = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + + var data = channelRuntimes + .Select(a => a.Value) + .WhereIF(!string.IsNullOrEmpty(input.Name), u => u.Name.Contains(input.Name)) + .WhereIF(!string.IsNullOrEmpty(input.PluginName), u => u.PluginName == input.PluginName) + .WhereIF(input.PluginType != null, u => u.PluginType == input.PluginType) + .ToPagedList(input); + return data; } + + /// /// 获取设备信息 /// /// [HttpGet("deviceList")] [DisplayName("获取设备信息")] - public async Task> GetCollectDeviceListAsync([FromQuery] DevicePageInput input) + public async Task> GetDeviceListAsync(DevicePageInput input) { - var dataScope = await _sysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - var data = GlobalData.ReadOnlyCollectDevices - .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.Value.CreateOrgId))//在指定机构列表查询 - .WhereIf(dataScope?.Count == 0, u => u.Value.CreateUserId == UserManager.UserId) + var deviceRuntimes = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + var data = deviceRuntimes .Select(a => a.Value) .WhereIF(!string.IsNullOrEmpty(input.Name), u => u.Name.Contains(input.Name)) - .WhereIF(input.ChannelId != null, u => u.ChannelId == input.ChannelId) + .WhereIF(!input.ChannelName.IsNullOrEmpty(), u => u.ChannelName == input.ChannelName) .WhereIF(!string.IsNullOrEmpty(input.PluginName), u => u.PluginName == input.PluginName) - .Where(u => u.PluginType == input.PluginType) + .WhereIF(input.PluginType != null, u => u.PluginType == input.PluginType) .ToPagedList(input); - return data.Adapt>(); + return data; } /// @@ -62,20 +75,21 @@ public class RuntimeInfoController : ControllerBase /// /// [HttpGet("realAlarmList")] - [DisplayName("获取实时报警信息")] - public async Task> GetRealAlarmList([FromQuery] VariablePageInput input) + [DisplayName("获取实时报警变量信息")] + public async Task> GetRealAlarmList(VariablePageInput input) { - var dataScope = await _sysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - var data = GlobalData.ReadOnlyRealAlarmVariables - .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.Value.CreateOrgId))//在指定机构列表查询 - .WhereIf(dataScope?.Count == 0, u => u.Value.CreateUserId == UserManager.UserId) + var realAlarmVariables = await GlobalData.GetCurrentUserRealAlarmVariables().ConfigureAwait(false); + + var data = realAlarmVariables .Select(a => a.Value) .WhereIF(!input.RegisterAddress.IsNullOrEmpty(), a => a.RegisterAddress == input.RegisterAddress) .WhereIF(!input.Name.IsNullOrEmpty(), a => a.Name == input.Name) - .WhereIF(input.DeviceId != null, a => a.DeviceId == input.DeviceId) + .WhereIF(!input.DeviceName.IsNullOrEmpty(), a => a.DeviceName == input.DeviceName) + .WhereIF(input.BusinessDeviceId > 0, a => a.VariablePropertys.ContainsKey(input.BusinessDeviceId)) .ToPagedList(input); return data.Adapt>(); } + /// /// 确认实时报警 /// @@ -86,7 +100,7 @@ public class RuntimeInfoController : ControllerBase { if (GlobalData.ReadOnlyRealAlarmVariables.TryGetValue(variableName, out var variable)) { - await _sysUserService.CheckApiDataScopeAsync(variable.CreateOrgId, variable.CreateUserId).ConfigureAwait(false); + await GlobalData.SysUserService.CheckApiDataScopeAsync(variable.CreateOrgId, variable.CreateUserId).ConfigureAwait(false); GlobalData.AlarmHostedService.ConfirmAlarm(variable); } } @@ -97,17 +111,62 @@ public class RuntimeInfoController : ControllerBase /// [HttpGet("variableList")] [DisplayName("获取变量信息")] - public async Task> GetVariableList([FromQuery] VariablePageInput input) + public async Task> GetVariableList(VariablePageInput input) { - var dataScope = await _sysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - var data = GlobalData.ReadOnlyVariables - .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.Value.CreateOrgId))//在指定机构列表查询 - .WhereIf(dataScope?.Count == 0, u => u.Value.CreateUserId == UserManager.UserId) + var variables = await GlobalData.GetCurrentUserIdVariables().ConfigureAwait(false); + var data = variables .Select(a => a.Value) .WhereIF(!input.Name.IsNullOrWhiteSpace(), a => a.Name == input.Name) - .WhereIF(input.DeviceId != null, a => a.DeviceId == input.DeviceId) + .WhereIF(!input.DeviceName.IsNullOrEmpty(), a => a.DeviceName == input.DeviceName) .WhereIF(!input.RegisterAddress.IsNullOrWhiteSpace(), a => a.RegisterAddress == input.RegisterAddress) + .WhereIF(input.BusinessDeviceId > 0, a => a.VariablePropertys.ContainsKey(input.BusinessDeviceId)) + .ToPagedList(input); - return data.Adapt>(); + return data; } } + +public class ChannelPageInput : BasePageInput +{ + /// + public string? Name { get; set; } + + /// + public string? PluginName { get; set; } + + /// + public PluginTypeEnum? PluginType { get; set; } +} + + +public class DevicePageInput : BasePageInput +{ + /// + public string? ChannelName { get; set; } + + /// + public string? Name { get; set; } + + /// + public string? PluginName { get; set; } + + /// + public PluginTypeEnum? PluginType { get; set; } +} + + + +public class VariablePageInput : BasePageInput +{ + /// + public long BusinessDeviceId { get; set; } + + /// + public string? DeviceName { get; set; } + + /// + public string Name { get; set; } + + /// + public string RegisterAddress { get; set; } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/BusinessBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessBase.cs similarity index 50% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/BusinessBase.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessBase.cs index cb20db4af..98352b446 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/BusinessBase.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessBase.cs @@ -12,7 +12,7 @@ using BootstrapBlazor.Components; using Microsoft.Extensions.Localization; -using TouchSocket.Core; +using ThingsGateway.NewLife.Extension; namespace ThingsGateway.Gateway.Application; @@ -24,82 +24,78 @@ public abstract class BusinessBase : DriverBase /// /// 当前关联的采集设备 /// - public IReadOnlyDictionary CollectDevices { get; protected set; } + public IReadOnlyDictionary CollectDevices { get; protected set; } /// /// 变量属性UI Type,如果不存在,返回null /// public virtual Type DriverVariablePropertyUIType { get; } - public override DriverPropertyBase DriverProperties => _businessPropertyBase; + public sealed override DriverPropertyBase DriverProperties => _businessPropertyBase; + private List pluginVariablePropertyEditorItems; public List PluginVariablePropertyEditorItems { get { - if (CurrentDevice?.PluginName?.IsNullOrWhiteSpace() == true) + if (pluginVariablePropertyEditorItems == null) { - var result = PluginService.GetVariablePropertyTypes(CurrentDevice.PluginName, this); - return result.EditorItems.ToList(); - } - else - { - var editorItems = PluginServiceUtil.GetEditorItems(VariablePropertys?.GetType()); - return editorItems.ToList(); + pluginVariablePropertyEditorItems = PluginServiceUtil.GetEditorItems(VariablePropertys?.GetType()).ToList(); } + return pluginVariablePropertyEditorItems; } } - /// /// 插件配置项 ,继承实现后,返回继承类,如果不存在,返回null /// public abstract VariablePropertyBase VariablePropertys { get; } protected abstract BusinessPropertyBase _businessPropertyBase { get; } + protected IStringLocalizer BusinessBaseLocalizer { get; private set; } /// /// 初始化方法,用于初始化设备运行时。 /// /// 设备运行时实例。 - internal protected override void Init(DeviceRunTime device) + protected override void ProtectedInitDevice(DeviceRuntime device) { BusinessBaseLocalizer = App.CreateLocalizerByType(typeof(BusinessBase))!; - base.Init(device); // 调用基类的初始化方法 + base.ProtectedInitDevice(device); // 调用基类的初始化方法 + } - // 获取与当前设备相关的变量 - var variables = GlobalData.Variables.Where(a => a.Value.VariablePropertys?.ContainsKey(device.Id) == true); + public override void AfterVariablesChanged() + { + // 获取与当前设备相关的变量,CurrentDevice.VariableRuntimes并不适用于业务插件 + var variableRuntimes = GlobalData.GetEnableVariables().Where(a => + { + if (!a.Value.Enable) return false; + if (a.Value.VariablePropertys?.TryGetValue(DeviceId, out var values) == true) + { + if (values.TryGetValue("Enable", out var Enable)) + { + return Enable.ToBoolean(true); + } + else if (values.TryGetValue("enable", out var enable)) + { + return enable.ToBoolean(true); + } + else + { + return true; + } + } + else + { + return false; + } + } + ); - // 将变量与设备关联,并保存到设备运行时的变量字典中 - device.VariableRunTimes = variables.ToDictionary(a => a.Key, a => a.Value); // 获取当前设备需要采集的设备 - CollectDevices = GlobalData.CollectDevices - .Where(a => device.VariableRunTimes.Select(b => b.Value.DeviceId).Contains(a.Value.Id)) - .ToDictionary(a => a.Key, a => a.Value); + CollectDevices = GlobalData.GetEnableDevices().Where(a => CurrentDevice.VariableRuntimes.Select(b => b.Value.DeviceId).ToHashSet().Contains(a.Key)).ToDictionary(a => a.Key, a => a.Value); - CurrentDevice.RefreshBusinessDeviceRuntime(device.Id); - - // 如果设备的采集间隔小于等于50毫秒,则将其设置为50毫秒 - if (int.TryParse(device.IntervalTime, out int delay)) - { - if (delay <= 50) - device.IntervalTime = "50"; - } + VariableRuntimes = variableRuntimes.ToDictionary(); } - - /// - /// 默认延时 - /// - protected async Task Delay(CancellationToken cancellationToken) - { - await Task.Delay(ChannelThread.CycleInterval, cancellationToken).ConfigureAwait(false); - } - - protected override void Dispose(bool disposing) - { - this.RemoveBusinessDeviceRuntime(); - base.Dispose(disposing); - } - } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/BusinessPropertyBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessPropertyBase.cs similarity index 89% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/BusinessPropertyBase.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessPropertyBase.cs index 0ef19b967..392dfb314 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/BusinessPropertyBase.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/BusinessPropertyBase.cs @@ -12,9 +12,6 @@ namespace ThingsGateway.Gateway.Application; /// /// 插件配置项 -/// -/// 约定: -/// 如果需要密码输入,属性名称中需包含Password字符串 ///

/// 使用 标识所需的配置属性 ,取消特性后不会在页面上显示 ///
diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/BusinessBaseWithCacheAlarmModel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/BusinessBaseWithCacheAlarmModel.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/BusinessBaseWithCacheAlarmModel.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/BusinessBaseWithCacheAlarmModel.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/BusinessBaseWithCacheDeviceModel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/BusinessBaseWithCacheDeviceModel.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/BusinessBaseWithCacheDeviceModel.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/BusinessBaseWithCacheDeviceModel.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/BusinessBaseWithCacheVariableModel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/BusinessBaseWithCacheVariableModel.cs similarity index 98% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/BusinessBaseWithCacheVariableModel.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/BusinessBaseWithCacheVariableModel.cs index baac219bd..a35484b58 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/BusinessBaseWithCacheVariableModel.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/BusinessBaseWithCacheVariableModel.cs @@ -23,8 +23,7 @@ public abstract class BusinessBaseWithCacheVariableModel : BusinessBas protected ConcurrentQueue> _memoryVarModelQueue = new(); protected volatile bool success = true; private volatile bool LocalDBCacheVarModelInited; - public override IProtocol? Protocol => null; - protected override BusinessPropertyBase _businessPropertyBase => _businessPropertyWithCache; + protected sealed override BusinessPropertyBase _businessPropertyBase => _businessPropertyWithCache; protected abstract BusinessPropertyWithCache _businessPropertyWithCache { get; } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/BusinessPropertyWithCache.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/BusinessPropertyWithCache.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/BusinessPropertyWithCache.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/BusinessPropertyWithCache.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs similarity index 73% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs index 7cf992d6c..c70c7e182 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalAlarmModel.cs @@ -25,38 +25,21 @@ public abstract class BusinessBaseWithCacheIntervalAlarmModel /// 业务属性 ///
- protected override BusinessPropertyWithCache _businessPropertyWithCache => _businessPropertyWithCacheInterval; + protected sealed override BusinessPropertyWithCache _businessPropertyWithCache => _businessPropertyWithCacheInterval; /// /// 业务属性 /// protected abstract BusinessPropertyWithCacheInterval _businessPropertyWithCacheInterval { get; } - /// - /// 初始化方法,可传入通道参数 - /// - internal protected override void Init(IChannel? channel = null) + protected internal override void InitChannel(IChannel? channel = null) { - // 如果业务属性的缓存要求上传所有变量,则设置当前设备的变量运行时间和采集设备列表 - if (_businessPropertyWithCacheInterval.IsAllVariable) - { - CurrentDevice.VariableRunTimes = GlobalData.Variables; - CollectDevices = GlobalData.CollectDevices; - } - - // 如果业务属性的缓存要求的业务间隔小于等于100,则设置为100 - if (int.TryParse(_businessPropertyWithCacheInterval.BusinessInterval, out int delay)) - { - if (delay <= 100) - _businessPropertyWithCacheInterval.BusinessInterval = "100"; - } - // 初始化 _exTTimerTick = new(_businessPropertyWithCacheInterval.BusinessInterval); _exT2TimerTick = new(_businessPropertyWithCacheInterval.BusinessInterval); - GlobalData.AlarmHostedService.OnAlarmChanged -= AlarmValueChange; - GlobalData.AlarmHostedService.OnAlarmChanged += AlarmValueChange; + GlobalData.AlarmChangedEvent -= AlarmValueChange; + GlobalData.AlarmChangedEvent += AlarmValueChange; // 解绑全局数据的事件 GlobalData.VariableValueChangeEvent -= VariableValueChange; GlobalData.DeviceStatusChangeEvent -= DeviceStatusChange; @@ -70,13 +53,30 @@ public abstract class BusinessBaseWithCacheIntervalAlarmModel { if (a.Value.DeviceStatus == DeviceStatusEnum.OnLine) DeviceStatusChange(a.Value, a.Value.Adapt()); }); - CurrentDevice.VariableRunTimes.ForEach(a => + VariableRuntimes.ForEach(a => { if (a.Value.IsOnline) VariableValueChange(a.Value, a.Value.Adapt()); @@ -95,18 +95,18 @@ public abstract class BusinessBaseWithCacheIntervalAlarmModel /// 当设备状态变化时触发此方法。如果不需要进行设备上传,则可以忽略此方法。通常情况下,需要在此方法中执行 方法。 ///
- /// 设备运行时信息 + /// 设备运行时信息 /// 设备数据 - protected virtual void DeviceChange(DeviceRunTime deviceRunTime, DeviceBasicData deviceData) + protected virtual void DeviceChange(DeviceRuntime deviceRuntime, DeviceBasicData deviceData) { // 在设备状态变化时执行的自定义逻辑 } /// /// 当设备状态定时变化时触发此方法。如果不需要进行设备上传,则可以忽略此方法。通常情况下,需要在此方法中执行 方法。 /// - /// 设备运行时信息 + /// 设备运行时信息 /// 设备数据 - protected virtual void DeviceTimeInterval(DeviceRunTime deviceRunTime, DeviceBasicData deviceData) + protected virtual void DeviceTimeInterval(DeviceRuntime deviceRuntime, DeviceBasicData deviceData) { // 在设备状态变化时执行的自定义逻辑 } @@ -116,7 +116,7 @@ public abstract class BusinessBaseWithCacheIntervalAlarmModel /// 间隔上传数据的方法 ///
- protected virtual async Task IntervalInsert() + protected virtual async Task IntervalInsert(CancellationToken cancellationToken) { - var vardatas = CurrentDevice.VariableRunTimes.Values.ToList(); - var devdatas = CollectDevices.Values.ToList(); while (!DisposedValue) { - if (CurrentDevice?.KeepRun == false) + if (CurrentDevice.Pause == true) { - await Delay(default).ConfigureAwait(false); + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); continue; } @@ -150,7 +148,7 @@ public abstract class BusinessBaseWithCacheIntervalAlarmModel a.Value)) { VariableTimeInterval(variableRuntime, variableRuntime.Adapt()); } @@ -165,7 +163,7 @@ public abstract class BusinessBaseWithCacheIntervalAlarmModel a.Value)) { DeviceTimeInterval(deviceRuntime, deviceRuntime.Adapt()); } @@ -177,35 +175,35 @@ public abstract class BusinessBaseWithCacheIntervalAlarmModel /// 启动前异步方法 ///
- protected override Task ProtectedBeforStartAsync(CancellationToken cancellationToken) + protected override Task ProtectedStartAsync(CancellationToken cancellationToken) { // 启动间隔上传的数据获取线程 - _ = IntervalInsert(); - return base.ProtectedBeforStartAsync(cancellationToken); + _ = IntervalInsert(cancellationToken); + return base.ProtectedStartAsync(cancellationToken); } /// /// 当变量状态变化时触发此方法。如果不需要进行变量上传,则可以忽略此方法。通常情况下,需要在此方法中执行 方法。 /// - /// 变量运行时信息 + /// 变量运行时信息 /// 变量数据 - protected virtual void VariableChange(VariableRunTime variableRunTime, VariableBasicData variable) + protected virtual void VariableChange(VariableRuntime variableRuntime, VariableBasicData variable) { // 在变量状态变化时执行的自定义逻辑 } /// /// 当变量定时变化时触发此方法。如果不需要进行变量上传,则可以忽略此方法。通常情况下,需要在此方法中执行 方法。 /// - /// 变量运行时信息 + /// 变量运行时信息 /// 变量数据 - protected virtual void VariableTimeInterval(VariableRunTime variableRunTime, VariableBasicData variable) + protected virtual void VariableTimeInterval(VariableRuntime variableRuntime, VariableBasicData variable) { // 在变量状态变化时执行的自定义逻辑 } @@ -215,50 +213,50 @@ public abstract class BusinessBaseWithCacheIntervalAlarmModel报警变量 private void AlarmValueChange(AlarmVariable alarmVariable) { - if (!CurrentDevice.KeepRun) + if (CurrentDevice.Pause) return; // 如果业务属性的缓存为间隔上传,则不执行后续操作 //if (_businessPropertyWithCacheInterval?.IsInterval != true) { // 检查当前设备的变量是否包含此报警变量,如果包含,则触发报警变量的变化处理方法 - if (CurrentDevice.VariableRunTimes.ContainsKey(alarmVariable.Name)) + if (VariableRuntimes.ContainsKey(alarmVariable.Name)) AlarmChange(alarmVariable); } } /// - /// 当设备状态发生变化时触发此事件处理方法。该方法内部会检查是否需要进行设备上传,如果需要,则调用 方法。 + /// 当设备状态发生变化时触发此事件处理方法。该方法内部会检查是否需要进行设备上传,如果需要,则调用 方法。 /// - /// 设备运行时信息 + /// 设备运行时信息 /// 设备数据 - private void DeviceStatusChange(DeviceRunTime deviceRunTime, DeviceBasicData deviceData) + private void DeviceStatusChange(DeviceRuntime deviceRuntime, DeviceBasicData deviceData) { - if (!CurrentDevice.KeepRun) + if (CurrentDevice.Pause == true) return; // 如果业务属性的缓存为间隔上传,则不执行后续操作 //if (_businessPropertyWithCacheInterval?.IsInterval != true) { // 检查当前设备的设备列表是否包含此设备,如果包含,则触发设备的状态变化处理方法 - if (CollectDevices.ContainsKey(deviceData.Name)) - DeviceChange(deviceRunTime, deviceData); + if (CollectDevices.ContainsKey(deviceData.Id)) + DeviceChange(deviceRuntime, deviceData); } } /// - /// 当变量值发生变化时触发此事件处理方法。该方法内部会检查是否需要进行变量上传,如果需要,则调用 方法。 + /// 当变量值发生变化时触发此事件处理方法。该方法内部会检查是否需要进行变量上传,如果需要,则调用 方法。 /// - /// 变量运行时信息 + /// 变量运行时信息 /// 变量数据 - private void VariableValueChange(VariableRunTime variableRunTime, VariableBasicData variable) + private void VariableValueChange(VariableRuntime variableRuntime, VariableBasicData variable) { - if (!CurrentDevice.KeepRun) + if (CurrentDevice.Pause == true) return; // 如果业务属性的缓存为间隔上传,则不执行后续操作 //if (_businessPropertyWithCacheInterval?.IsInterval != true) { // 检查当前设备的变量是否包含此变量,如果包含,则触发变量的变化处理方法 - if (CurrentDevice.VariableRunTimes.ContainsKey(variable.Name)) - VariableChange(variableRunTime, variable); + if (VariableRuntimes.ContainsKey(variable.Name)) + VariableChange(variableRuntime, variable); } } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs similarity index 73% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs index 72df09fe7..b769f5069 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalDeviceModel.cs @@ -30,37 +30,23 @@ public abstract class BusinessBaseWithCacheIntervalDeviceModel /// 获取具体业务属性的缓存设置。 ///
- protected override BusinessPropertyWithCache _businessPropertyWithCache => _businessPropertyWithCacheInterval; + protected sealed override BusinessPropertyWithCache _businessPropertyWithCache => _businessPropertyWithCacheInterval; /// /// 获取业务属性与缓存间隔的抽象属性。 /// protected abstract BusinessPropertyWithCacheInterval _businessPropertyWithCacheInterval { get; } - /// - /// 初始化方法,初始化插件。 - /// - /// 通道对象 - internal protected override void Init(IChannel? channel = null) + + + protected internal override void InitChannel(IChannel? channel = null) { - // 如果业务属性要求上传所有变量,则更新当前设备的变量运行次数和采集设备信息 - if (_businessPropertyWithCacheInterval.IsAllVariable) - { - CurrentDevice.VariableRunTimes = GlobalData.Variables; - CollectDevices = GlobalData.CollectDevices; - } - - // 设置业务间隔时间的最小值为100毫秒 - if (int.TryParse(_businessPropertyWithCacheInterval.BusinessInterval, out int delay)) - { - if (delay <= 100) - _businessPropertyWithCacheInterval.BusinessInterval = "100"; - } - // 初始化设备和变量上传的定时器 _exTTimerTick = new(_businessPropertyWithCacheInterval.BusinessInterval); _exT2TimerTick = new(_businessPropertyWithCacheInterval.BusinessInterval); + + // 注销全局变量值改变事件和设备状态改变事件的订阅,以防止重复订阅 GlobalData.VariableValueChangeEvent -= VariableValueChange; GlobalData.DeviceStatusChangeEvent -= DeviceStatusChange; @@ -72,32 +58,50 @@ public abstract class BusinessBaseWithCacheIntervalDeviceModel { if (a.Value.DeviceStatus == DeviceStatusEnum.OnLine) DeviceStatusChange(a.Value, a.Value.Adapt()); }); - CurrentDevice.VariableRunTimes.ForEach(a => + VariableRuntimes.ForEach(a => { if (a.Value.IsOnline) VariableValueChange(a.Value, a.Value.Adapt()); }); } + /// /// 设备状态变化时发生的虚拟方法,用于处理设备状态变化事件。 /// - /// 设备运行时对象 + /// 设备运行时对象 /// 设备数据对象 - protected virtual void DeviceChange(DeviceRunTime deviceRunTime, DeviceBasicData deviceData) + protected virtual void DeviceChange(DeviceRuntime deviceRuntime, DeviceBasicData deviceData) { } /// /// 当设备状态定时变化时触发此方法。如果不需要进行设备上传,则可以忽略此方法。通常情况下,需要在此方法中执行 方法。 /// - /// 设备运行时信息 + /// 设备运行时信息 /// 设备数据 - protected virtual void DeviceTimeInterval(DeviceRunTime deviceRunTime, DeviceBasicData deviceData) + protected virtual void DeviceTimeInterval(DeviceRuntime deviceRuntime, DeviceBasicData deviceData) { // 在设备状态变化时执行的自定义逻辑 } @@ -121,15 +125,14 @@ public abstract class BusinessBaseWithCacheIntervalDeviceModel /// 异步任务 - protected virtual async Task IntervalInsert() + protected virtual async Task IntervalInsert(CancellationToken cancellationToken) { - var vardatas = CurrentDevice.VariableRunTimes.Values.ToList(); - var devdatas = CollectDevices.Values.ToList(); - while (!DisposedValue) + + while (!cancellationToken.IsCancellationRequested) { - if (CurrentDevice?.KeepRun == false) + if (CurrentDevice.Pause == true) { - await Delay(default).ConfigureAwait(false); + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); continue; } @@ -141,7 +144,7 @@ public abstract class BusinessBaseWithCacheIntervalDeviceModel a.Value)) { VariableTimeInterval(variableRuntime, variableRuntime.Adapt()); } @@ -157,7 +160,7 @@ public abstract class BusinessBaseWithCacheIntervalDeviceModel a.Value)) { DeviceTimeInterval(deviceRuntime, deviceRuntime.Adapt()); } @@ -169,7 +172,7 @@ public abstract class BusinessBaseWithCacheIntervalDeviceModel /// 取消令牌 /// 异步任务 - protected override Task ProtectedBeforStartAsync(CancellationToken cancellationToken) + protected override Task ProtectedStartAsync(CancellationToken cancellationToken) { - _ = IntervalInsert(); - return base.ProtectedBeforStartAsync(cancellationToken); + _ = IntervalInsert(cancellationToken); + return base.ProtectedStartAsync(cancellationToken); } /// /// 变量状态变化时发生的虚拟方法,用于处理变量状态变化事件。 /// - /// 变量运行时对象 + /// 变量运行时对象 /// 变量数据对象 - protected virtual void VariableChange(VariableRunTime variableRunTime, VariableData variable) + protected virtual void VariableChange(VariableRuntime variableRuntime, VariableData variable) { } /// /// 当变量定时变化时触发此方法。如果不需要进行变量上传,则可以忽略此方法。通常情况下,需要在此方法中执行 方法。 /// - /// 变量运行时信息 + /// 变量运行时信息 /// 变量数据 - protected virtual void VariableTimeInterval(VariableRunTime variableRunTime, VariableBasicData variable) + protected virtual void VariableTimeInterval(VariableRuntime variableRuntime, VariableBasicData variable) { // 在变量状态变化时执行的自定义逻辑 } /// /// 设备状态改变时的事件处理方法。 /// - /// 设备运行时对象 + /// 设备运行时对象 /// 设备数据对象 - private void DeviceStatusChange(DeviceRunTime deviceRunTime, DeviceBasicData deviceData) + private void DeviceStatusChange(DeviceRuntime deviceRuntime, DeviceBasicData deviceData) { // 如果当前设备已停止运行,则直接返回,不进行处理 - if (!CurrentDevice.KeepRun) + if (CurrentDevice.Pause == true) return; // 如果业务属性不是间隔上传,则执行设备状态改变的处理逻辑 //if (_businessPropertyWithCacheInterval?.IsInterval != true) { // 检查当前设备集合中是否包含该设备,并进行相应处理 - if (CollectDevices.ContainsKey(deviceRunTime.Name)) - DeviceChange(deviceRunTime, deviceData); + if (CollectDevices.ContainsKey(deviceRuntime.Id)) + DeviceChange(deviceRuntime, deviceData); } } /// /// 变量值改变时的事件处理方法。 /// - /// 变量运行时对象 + /// 变量运行时对象 /// 变量数据对象 - private void VariableValueChange(VariableRunTime variableRunTime, VariableBasicData variable) + private void VariableValueChange(VariableRuntime variableRuntime, VariableBasicData variable) { // 如果当前设备已停止运行,则直接返回,不进行处理 - if (!CurrentDevice.KeepRun) + if (CurrentDevice.Pause == true) return; // 如果业务属性不是间隔上传,则执行变量状态改变的处理逻辑 //if (_businessPropertyWithCacheInterval?.IsInterval != true) { // 检查当前设备是否包含该变量,并进行相应处理 - if (CurrentDevice.VariableRunTimes.ContainsKey(variableRunTime.Name)) - VariableChange(variableRunTime, variable); + if (VariableRuntimes.ContainsKey(variableRuntime.Name)) + VariableChange(variableRuntime, variable); } } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalScript.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalScript.cs similarity index 98% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalScript.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalScript.cs index 862a0d1da..479a825c4 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalScript.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalScript.cs @@ -8,12 +8,10 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using Newtonsoft.Json; - using System.Globalization; using System.Text.RegularExpressions; -using ThingsGateway.Core.Json.Extension; +using ThingsGateway.NewLife.Json.Extension; using TouchSocket.Core; @@ -24,7 +22,7 @@ namespace ThingsGateway.Gateway.Application; ///
public abstract partial class BusinessBaseWithCacheIntervalScript : BusinessBaseWithCacheIntervalAlarmModel where DevModel : class where VarModel : class where AlarmModel : class { - protected override BusinessPropertyWithCacheInterval _businessPropertyWithCacheInterval => _businessPropertyWithCacheIntervalScript; + protected sealed override BusinessPropertyWithCacheInterval _businessPropertyWithCacheInterval => _businessPropertyWithCacheIntervalScript; protected abstract BusinessPropertyWithCacheIntervalScript _businessPropertyWithCacheIntervalScript { get; } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs similarity index 70% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs index a829d6fa4..f2923388b 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessBaseWithCacheIntervalVariableModel.cs @@ -28,33 +28,15 @@ public abstract class BusinessBaseWithCacheIntervalVariableModel : BusinessBa /// /// 获取具体业务属性的缓存设置。 /// - protected override BusinessPropertyWithCache _businessPropertyWithCache => _businessPropertyWithCacheInterval; + protected sealed override BusinessPropertyWithCache _businessPropertyWithCache => _businessPropertyWithCacheInterval; /// /// 获取具体业务属性的缓存间隔设置。 /// protected abstract BusinessPropertyWithCacheInterval _businessPropertyWithCacheInterval { get; } - /// - /// 初始化方法,用于初始化业务对象。 - /// - /// 通道对象 - internal protected override void Init(IChannel? channel = null) + protected internal override void InitChannel(IChannel? channel = null) { - // 如果业务属性指定了全部变量,则设置当前设备的变量运行时列表和采集设备列表 - if (_businessPropertyWithCacheInterval.IsAllVariable) - { - CurrentDevice.VariableRunTimes = GlobalData.Variables; - CollectDevices = GlobalData.CollectDevices; - } - - // 如果业务间隔小于等于100毫秒,则将业务间隔设置为100毫秒 - if (int.TryParse(_businessPropertyWithCacheInterval.BusinessInterval, out int delay)) - { - if (delay <= 100) - _businessPropertyWithCacheInterval.BusinessInterval = "100"; - } - // 初始化定时器 _exTTimerTick = new TimeTick(_businessPropertyWithCacheInterval.BusinessInterval); @@ -63,10 +45,25 @@ public abstract class BusinessBaseWithCacheIntervalVariableModel : BusinessBa if (_businessPropertyWithCacheInterval.BusinessUpdateEnum != BusinessUpdateEnum.Interval) { GlobalData.VariableValueChangeEvent += VariableValueChange; - } + + base.InitChannel(channel); + } + public override void AfterVariablesChanged() + { + // 如果业务属性指定了全部变量,则设置当前设备的变量运行时列表和采集设备列表 + if (_businessPropertyWithCacheInterval.IsAllVariable) + { + VariableRuntimes = new(GlobalData.GetEnableVariables()); + CollectDevices = GlobalData.GetEnableDevices().ToDictionary(); + } + else + { + base.AfterVariablesChanged(); + } + // 触发一次变量值变化事件 - CurrentDevice.VariableRunTimes.ForEach(a => + VariableRuntimes.ForEach(a => { if (a.Value.IsOnline) VariableValueChange(a.Value, a.Value.Adapt()); @@ -88,14 +85,13 @@ public abstract class BusinessBaseWithCacheIntervalVariableModel : BusinessBa /// 间隔插入操作,用于周期性地插入变量。 ///
/// 表示异步操作的任务 - protected virtual async Task IntervalInsert() + protected virtual async Task IntervalInsert(CancellationToken cancellationToken) { - var vardatas = CurrentDevice.VariableRunTimes.Values.ToList(); - while (!DisposedValue) + while (!cancellationToken.IsCancellationRequested) { - if (CurrentDevice?.KeepRun == false) + if (CurrentDevice.Pause == true) { - await Delay(default).ConfigureAwait(false); + await Task.Delay(1000, cancellationToken).ConfigureAwait(false); continue; } //间隔上传 @@ -106,7 +102,7 @@ public abstract class BusinessBaseWithCacheIntervalVariableModel : BusinessBa if (_exTTimerTick.IsTickHappen()) { //间隔推送全部变量 - foreach (var variableRuntime in vardatas) + foreach (var variableRuntime in VariableRuntimes.Select(a => a.Value)) { VariableTimeInterval(variableRuntime, variableRuntime.Adapt()); } @@ -118,8 +114,7 @@ public abstract class BusinessBaseWithCacheIntervalVariableModel : BusinessBa } } - - await Delay(default).ConfigureAwait(false); + await Task.Delay(100, cancellationToken).ConfigureAwait(false); } } @@ -130,44 +125,45 @@ public abstract class BusinessBaseWithCacheIntervalVariableModel : BusinessBa ///
/// 取消令牌 /// 表示异步操作的任务 - protected override Task ProtectedBeforStartAsync(CancellationToken cancellationToken) + protected override Task ProtectedStartAsync(CancellationToken cancellationToken) { // 启动间隔插入操作 - _ = IntervalInsert(); - return base.ProtectedBeforStartAsync(cancellationToken); + _ = IntervalInsert(cancellationToken); + return base.ProtectedStartAsync(cancellationToken); } /// /// 当变量状态变化时发生,通常需要执行。 /// - /// 变量运行时对象 + /// 变量运行时对象 /// 变量运行时对象 - protected virtual void VariableChange(VariableRunTime variableRunTime, VariableBasicData variable) + protected virtual void VariableChange(VariableRuntime variableRuntime, VariableBasicData variable) { } + /// /// 当变量定时变化时触发此方法。如果不需要进行变量上传,则可以忽略此方法。通常情况下,需要在此方法中执行 方法。 /// - /// 变量运行时信息 + /// 变量运行时信息 /// 变量数据 - protected virtual void VariableTimeInterval(VariableRunTime variableRunTime, VariableBasicData variable) + protected virtual void VariableTimeInterval(VariableRuntime variableRuntime, VariableBasicData variable) { // 在变量状态变化时执行的自定义逻辑 } /// /// 当变量值发生变化时调用的方法。 /// - /// 变量运行时对象 + /// 变量运行时对象 /// 变量数据 - private void VariableValueChange(VariableRunTime variableRunTime, VariableBasicData variable) + private void VariableValueChange(VariableRuntime variableRuntime, VariableBasicData variable) { - if (!CurrentDevice.KeepRun) + if (CurrentDevice.Pause == true) return; //if (_businessPropertyWithCacheInterval?.IsInterval != true) { //筛选 - if (CurrentDevice.VariableRunTimes.ContainsKey(variableRunTime.Name)) - VariableChange(variableRunTime, variable); + if (VariableRuntimes.ContainsKey(variableRuntime.Name)) + VariableChange(variableRuntime, variable); } } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessPropertyWithCacheInterval.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessPropertyWithCacheInterval.cs similarity index 93% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessPropertyWithCacheInterval.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessPropertyWithCacheInterval.cs index 337af8919..fea739630 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessPropertyWithCacheInterval.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessPropertyWithCacheInterval.cs @@ -9,13 +9,6 @@ //------------------------------------------------------------------------------ namespace ThingsGateway.Gateway.Application; - -public enum BusinessUpdateEnum -{ - Change, - Interval, - IntervalOrChange, -} /// /// /// diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessPropertyWithCacheIntervalDBScript.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessPropertyWithCacheIntervalDBScript.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessPropertyWithCacheIntervalDBScript.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessPropertyWithCacheIntervalDBScript.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessPropertyWithCacheIntervalScript.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessPropertyWithCacheIntervalScript.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/Interval/BusinessPropertyWithCacheIntervalScript.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/Interval/BusinessPropertyWithCacheIntervalScript.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/TopicArray.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/TopicArray.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/TopicArray.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/TopicArray.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/TopicJson.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/TopicJson.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/Cache/TopicJson.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/Cache/TopicJson.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/BusinessDatabaseUtil.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/BusinessDatabaseUtil.cs similarity index 66% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/BusinessDatabaseUtil.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/BusinessDatabaseUtil.cs index 8dacecd40..0dc66e4f3 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/BusinessDatabaseUtil.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/BusinessDatabaseUtil.cs @@ -52,20 +52,20 @@ public static class BusinessDatabaseUtil /// /// 按条件获取DB插件中的全部历史报警(不分页) /// - public static async Task>> GetDBHistoryAlarmPagesAsync(string businessDeviceName, DBHistoryAlarmPageInput input) + public static async Task>> GetDBHistoryAlarmPagesAsync(long deviceId, DBHistoryAlarmPageInput input) { try { - var businessDevice = GlobalData.BusinessDeviceHostedService.DriverBases.Where(a => a is IDBHistoryValueService b).Where(a => a.DeviceName == businessDeviceName).FirstOrDefault(); - if (businessDevice == null) + var driver = GlobalData.Devices.TryGetValue(deviceId, out var businessDevice) ? businessDevice.Driver : null; + if (driver is not IDBHistoryAlarmService alarmService) { - return new(new ArgumentNullException(nameof(businessDevice))); + return new(new ArgumentNullException(nameof(driver))); } - if (!businessDevice.IsConnected()) + if (!driver.IsConnected()) { return new(new Exception("Connect Fail")); } - var data = await ((IDBHistoryAlarmService)businessDevice).GetDBHistoryAlarmPagesAsync(input).ConfigureAwait(false); + var data = await alarmService.GetDBHistoryAlarmPagesAsync(input).ConfigureAwait(false); return OperResult.CreateSuccessResult(data); } catch (Exception ex) @@ -77,20 +77,20 @@ public static class BusinessDatabaseUtil /// /// 按条件获取DB插件中的全部历史报警(不分页) /// - public static async Task>> GetDBHistoryAlarmsAsync(string businessDeviceName, DBHistoryAlarmPageInput input) + public static async Task>> GetDBHistoryAlarmsAsync(long deviceId, DBHistoryAlarmPageInput input) { try { - var businessDevice = GlobalData.BusinessDeviceHostedService.DriverBases.Where(a => a is IDBHistoryValueService b).Where(a => a.DeviceName == businessDeviceName).FirstOrDefault(); - if (businessDevice == null) + var driver = GlobalData.Devices.TryGetValue(deviceId, out var businessDevice) ? businessDevice.Driver : null; + if (driver is not IDBHistoryAlarmService alarmService) { - return new(new ArgumentNullException(nameof(businessDevice))); + return new(new ArgumentNullException(nameof(alarmService))); } - if (!businessDevice.IsConnected()) + if (!driver.IsConnected()) { return new(new Exception("Connect Fail")); } - var data = await ((IDBHistoryAlarmService)businessDevice).GetDBHistoryAlarmsAsync(input).ConfigureAwait(false); + var data = await alarmService.GetDBHistoryAlarmsAsync(input).ConfigureAwait(false); return OperResult.CreateSuccessResult(data); } catch (Exception ex) @@ -102,20 +102,20 @@ public static class BusinessDatabaseUtil /// /// 按条件获取DB插件中的全部历史数据(不分页) /// - public static async Task>> GetDBHistoryValuePagesAsync(string businessDeviceName, DBHistoryValuePageInput input) + public static async Task>> GetDBHistoryValuePagesAsync(long deviceId, DBHistoryValuePageInput input) { try { - var businessDevice = GlobalData.BusinessDeviceHostedService.DriverBases.Where(a => a is IDBHistoryValueService b).Where(a => a.DeviceName == businessDeviceName).FirstOrDefault(); - if (businessDevice == null) + var driver = GlobalData.Devices.TryGetValue(deviceId, out var businessDevice) ? businessDevice.Driver : null; + if (driver is not IDBHistoryValueService historyValueService) { return new(new ArgumentNullException(nameof(businessDevice))); } - if (!businessDevice.IsConnected()) + if (!driver.IsConnected()) { return new(new Exception("Connect Fail")); } - var data = await ((IDBHistoryValueService)businessDevice).GetDBHistoryValuePagesAsync(input).ConfigureAwait(false); + var data = await (historyValueService).GetDBHistoryValuePagesAsync(input).ConfigureAwait(false); return OperResult.CreateSuccessResult(data); } catch (Exception ex) @@ -127,20 +127,20 @@ public static class BusinessDatabaseUtil /// /// 按条件获取DB插件中的全部历史数据(不分页) /// - public static async Task>> GetDBHistoryValuesAsync(string businessDeviceName, DBHistoryValuePageInput input) + public static async Task>> GetDBHistoryValuesAsync(long deviceId, DBHistoryValuePageInput input) { try { - var businessDevice = GlobalData.BusinessDeviceHostedService.DriverBases.Where(a => a is IDBHistoryValueService b).Where(a => a.DeviceName == businessDeviceName).FirstOrDefault(); - if (businessDevice == null) + var driver = GlobalData.Devices.TryGetValue(deviceId, out var businessDevice) ? businessDevice.Driver : null; + if (driver is not IDBHistoryValueService historyValueService) { return new(new ArgumentNullException(nameof(businessDevice))); } - if (!businessDevice.IsConnected()) + if (!driver.IsConnected()) { return new(new Exception("Connect Fail")); } - var data = await ((IDBHistoryValueService)businessDevice).GetDBHistoryValuesAsync(input).ConfigureAwait(false); + var data = await historyValueService.GetDBHistoryValuesAsync(input).ConfigureAwait(false); return OperResult.CreateSuccessResult(data); } catch (Exception ex) diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/DBHistoryAlarmPageInput.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/DBHistoryAlarmPageInput.cs similarity index 96% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/DBHistoryAlarmPageInput.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/DBHistoryAlarmPageInput.cs index 804988d48..f4030af45 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/DBHistoryAlarmPageInput.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/DBHistoryAlarmPageInput.cs @@ -31,5 +31,4 @@ public class DBHistoryAlarmPageInput : BasePageInput public string VariableName { get; set; } - public List? OrgId { get; set; } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/DBHistoryValuePageInput.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/DBHistoryValuePageInput.cs similarity index 96% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/DBHistoryValuePageInput.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/DBHistoryValuePageInput.cs index 5f1899e39..cc7314006 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/DBHistoryValuePageInput.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/DBHistoryValuePageInput.cs @@ -32,6 +32,4 @@ public class DBHistoryValuePageInput : BasePageInput /// public virtual string[]? VariableNames { get; set; } - public List? OrgId { get; set; } - } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/IDBHistoryAlarm.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/IDBHistoryAlarm.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/IDBHistoryAlarm.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/IDBHistoryAlarm.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/IDBHistoryAlarmService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/IDBHistoryAlarmService.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/IDBHistoryAlarmService.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/IDBHistoryAlarmService.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/IDBHistoryValue.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/IDBHistoryValue.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/IDBHistoryValue.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/IDBHistoryValue.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/IDBHistoryValueService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/IDBHistoryValueService.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/DB/IDBHistoryValueService.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/DB/IDBHistoryValueService.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/VariablePropertyBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/VariablePropertyBase.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/VariablePropertyBase.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Business/VariablePropertyBase.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDB.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDB.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDB.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDB.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDBItem.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDBItem.cs similarity index 97% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDBItem.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDBItem.cs index 5a89f6cc0..bc2258686 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDBItem.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDBItem.cs @@ -10,8 +10,6 @@ using SqlSugar; -using Yitter.IdGenerator; - namespace ThingsGateway.Gateway.Application; [SugarTable("CacheDBItem")] diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDBOption.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDBOption.cs similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDBOption.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDBOption.cs diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDBUtil.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDBUtil.cs similarity index 89% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDBUtil.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDBUtil.cs index a1f2bad2e..7145ee9f8 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/CacheDB/CacheDBUtil.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/CacheDB/CacheDBUtil.cs @@ -10,6 +10,8 @@ using System.Text.RegularExpressions; +using ThingsGateway.NewLife; + namespace ThingsGateway.Gateway.Application; public class CacheDBUtil @@ -27,7 +29,7 @@ public class CacheDBUtil { var fileLength = GetFileLength(fullName); if (fileLength > maxFileLength) - DeleteFile(fullName); + FileUtil.DeleteFile(fullName); return true; } @@ -38,14 +40,6 @@ public class CacheDBUtil } } - public static void DeleteFile(string file) - { - if (File.Exists(file)) - { - File.SetAttributes(file, FileAttributes.Normal); - File.Delete(file); - } - } /// /// 获取缓存链接 @@ -63,9 +57,10 @@ public class CacheDBUtil } } + public static string GetFileBasePath() { - var dir = Path.Combine(App.HostEnvironment?.ContentRootPath ?? AppContext.BaseDirectory, "businessCache"); + var dir = Path.Combine(App.HostEnvironment?.ContentRootPath ?? AppContext.BaseDirectory, "PluginCache"); //创建文件夹 Directory.CreateDirectory(dir); return dir; diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Collect/CollectBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectBase.cs similarity index 80% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Collect/CollectBase.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectBase.cs index 753be6d0e..86707a111 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Collect/CollectBase.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectBase.cs @@ -17,8 +17,8 @@ using Newtonsoft.Json.Linq; using System.Collections.Concurrent; -using ThingsGateway.Core.Json.Extension; using ThingsGateway.Gateway.Application.Extensions; +using ThingsGateway.NewLife.Json.Extension; using ThingsGateway.NewLife.Threading; using TouchSocket.Core; @@ -37,47 +37,57 @@ public abstract class CollectBase : DriverBase /// public abstract CollectPropertyBase CollectProperties { get; } - public new CollectDeviceRunTime CurrentDevice => (CollectDeviceRunTime)base.CurrentDevice; - /// /// 特殊方法 /// - public List? DeviceMethods { get; private set; } + public List? DriverMethodInfos { get; private set; } + + public sealed override object DriverProperties => CollectProperties; - public override object DriverProperties => CollectProperties; - public virtual bool IsSingleThread => true; private IStringLocalizer Localizer { get; set; } /// /// 获取设备变量打包列表/特殊方法列表 /// - /// - internal protected override void LoadSourceRead(IEnumerable collectVariableRunTimes) + public override void AfterVariablesChanged() { var currentDevice = CurrentDevice; + VariableRuntimes = currentDevice.VariableRuntimes.Where(a => a.Value.Enable).ToDictionary(); + + //预热脚本,加速编译 + VariableRuntimes.Where(a => !string.IsNullOrWhiteSpace(a.Value.ReadExpressions)) + .Select(b => b.Value.ReadExpressions).ToHashSet().ParallelForEach(script => + { + try + { + _ = ExpressionEvaluatorExtension.GetOrAddScript(script); + } + catch + { + } + }); + try { // 连读打包 // 从收集的变量运行时信息中筛选需要读取的变量 - var tags = collectVariableRunTimes + var tags = VariableRuntimes.Select(a => a.Value) .Where(it => it.ProtectType != ProtectTypeEnum.WriteOnly && string.IsNullOrEmpty(it.OtherMethod) && !string.IsNullOrEmpty(it.RegisterAddress)); - //筛选特殊变量地址 //1、DeviceStatus - Func source = (a => + Func source = (a => { - return a.RegisterAddress != nameof(DeviceRunTime.DeviceStatus) && + return a.RegisterAddress != nameof(DeviceRuntime.DeviceStatus) && a.RegisterAddress != "Script" && a.RegisterAddress != "ScriptRead" ; }); - - currentDevice.OtherVariableRunTimes = tags.Where(a => !source(a)).ToList(); + currentDevice.OtherVariableRuntimes = tags.Where(a => !source(a)).ToList(); // 将打包后的结果存储在当前设备的 VariableSourceReads 属性中 currentDevice.VariableSourceReads = ProtectedLoadSourceRead(tags.Where(source).ToList()); @@ -91,7 +101,7 @@ public abstract class CollectBase : DriverBase try { // 初始化动态方法 - var variablesMethod = collectVariableRunTimes.Where(it => !string.IsNullOrEmpty(it.OtherMethod)); + var variablesMethod = VariableRuntimes.Select(a => a.Value).Where(it => !string.IsNullOrEmpty(it.OtherMethod)); // 处理可读的动态方法 { @@ -103,30 +113,28 @@ public abstract class CollectBase : DriverBase // 处理可写的动态方法 { var tag = variablesMethod.Where(it => it.ProtectType != ProtectTypeEnum.ReadOnly); - List variablesMethodResult = GetMethod(tag); - currentDevice.VariableMethods = variablesMethodResult; + currentDevice.MethodVariableCount = tag.Count(); } } catch (Exception ex) { // 如果出现异常,记录日志并初始化 ReadVariableMethods 和 VariableMethods 属性为新实例 currentDevice.ReadVariableMethods ??= new(); - currentDevice.VariableMethods ??= new(); LogMessage.LogWarning(ex, Localizer["GetMethodError", ex.Message]); } // 根据标签获取方法信息的局部函数 - List GetMethod(IEnumerable tag) + List GetMethod(IEnumerable tag) { var variablesMethodResult = new List(); foreach (var item in tag) { // 根据标签查找对应的方法信息 - var method = DeviceMethods.FirstOrDefault(it => it.Name == item.OtherMethod); + var method = DriverMethodInfos.FirstOrDefault(it => it.Name == item.OtherMethod); if (method != null) { // 构建 VariableMethod 对象 - var methodResult = new VariableMethod(new Method(method.MethodInfo), item, string.IsNullOrWhiteSpace(item.IntervalTime) ? item.CollectDeviceRunTime.IntervalTime : item.IntervalTime); + var methodResult = new VariableMethod(new Method(method.MethodInfo), item, string.IsNullOrWhiteSpace(item.IntervalTime) ? item.DeviceRuntime.IntervalTime : item.IntervalTime); variablesMethodResult.Add(methodResult); } else @@ -139,35 +147,19 @@ public abstract class CollectBase : DriverBase } } - internal protected override void Init(DeviceRunTime device) + protected override void ProtectedInitDevice(DeviceRuntime device) { // 调用基类的初始化方法 - base.Init(device); + base.ProtectedInitDevice(device); Localizer = App.CreateLocalizerByType(typeof(CollectBase))!; - // 从插件服务中获取当前设备关联的驱动方法信息列表,并转换为列表形式 - var data = PluginService.GetDriverMethodInfos(device.PluginName, this); - - // 将获取到的驱动方法信息列表赋值给 DeviceMethods - DeviceMethods = data; - - CurrentDevice.RefreshCollectDeviceRuntime(device.Id); + // 从插件服务中获取当前设备关联的驱动方法信息列表 + DriverMethodInfos = GlobalData.PluginService.GetDriverMethodInfos(device.PluginName, this); } - protected override void Dispose(bool disposing) - { - //去掉全局变量 - this.RemoveCollectDeviceRuntime(); - base.Dispose(disposing); - } - - /// - /// 注意非通用设备需重写 - /// - /// internal protected virtual string GetAddressDescription() { - return Protocol?.GetAddressDescription(); + return FoundationDevice?.GetAddressDescription(); } /// @@ -179,24 +171,21 @@ public abstract class CollectBase : DriverBase { try { - ReadResultCount readResultCount = new(); if (cancellationToken.IsCancellationRequested) return; - if (await TestOnline(cancellationToken).ConfigureAwait(false)) - return; - if (CollectProperties.ConcurrentCount > 1) + if (CollectProperties.MaxConcurrentCount > 1) { // 并行处理每个变量读取 await CurrentDevice.VariableSourceReads.ParallelForEachAsync(async (variableSourceRead, cancellationToken) => { if (cancellationToken.IsCancellationRequested) return; - if (await ReadVariableSource(readResultCount, variableSourceRead, cancellationToken, false).ConfigureAwait(false)) + if (await ReadVariableSource(readResultCount, variableSourceRead, cancellationToken).ConfigureAwait(false)) return; } - , CollectProperties.ConcurrentCount, cancellationToken).ConfigureAwait(false); + , CollectProperties.MaxConcurrentCount, cancellationToken).ConfigureAwait(false); } else { @@ -204,23 +193,22 @@ public abstract class CollectBase : DriverBase { if (cancellationToken.IsCancellationRequested) return; - // 每10包延迟一次 - if (await ReadVariableSource(readResultCount, CurrentDevice.VariableSourceReads[i], cancellationToken, i % 10 == 9).ConfigureAwait(false)) + if (await ReadVariableSource(readResultCount, CurrentDevice.VariableSourceReads[i], cancellationToken).ConfigureAwait(false)) return; } } - if (CollectProperties.ConcurrentCount > 1) + if (CollectProperties.MaxConcurrentCount > 1) { // 并行处理每个方法调用 await CurrentDevice.ReadVariableMethods.ParallelForEachAsync(async (readVariableMethods, cancellationToken) => { if (cancellationToken.IsCancellationRequested) return; - if (await ReadVariableMed(readResultCount, readVariableMethods, cancellationToken, false).ConfigureAwait(false)) + if (await ReadVariableMed(readResultCount, readVariableMethods, cancellationToken).ConfigureAwait(false)) return; } - , CollectProperties.ConcurrentCount, cancellationToken).ConfigureAwait(false); + , CollectProperties.MaxConcurrentCount, cancellationToken).ConfigureAwait(false); } else { @@ -228,8 +216,7 @@ public abstract class CollectBase : DriverBase { if (cancellationToken.IsCancellationRequested) return; - // 每10包延迟一次 - if (await ReadVariableMed(readResultCount, CurrentDevice.ReadVariableMethods[i], cancellationToken, i % 10 == 9).ConfigureAwait(false)) + if (await ReadVariableMed(readResultCount, CurrentDevice.ReadVariableMethods[i], cancellationToken).ConfigureAwait(false)) return; } } @@ -238,10 +225,8 @@ public abstract class CollectBase : DriverBase if (readResultCount.deviceMethodsVariableFailedNum == 0 && readResultCount.deviceSourceVariableFailedNum == 0 && (readResultCount.deviceMethodsVariableSuccessNum != 0 || readResultCount.deviceSourceVariableSuccessNum != 0)) { //只有成功读取一次,失败次数都会清零 - CurrentDevice.SetDeviceStatus(TimerX.Now, 0); + CurrentDevice.SetDeviceStatus(TimerX.Now, false); } - - } finally { @@ -250,16 +235,15 @@ public abstract class CollectBase : DriverBase #region 执行方法 - async ValueTask ReadVariableMed(ReadResultCount readResultCount, VariableMethod readVariableMethods, CancellationToken cancellationToken, bool delay = true) + async ValueTask ReadVariableMed(ReadResultCount readResultCount, VariableMethod readVariableMethods, CancellationToken cancellationToken) { - if (KeepRun != true) + if (Pause) return true; if (cancellationToken.IsCancellationRequested) return true; - if (await TestOnline(cancellationToken).ConfigureAwait(false)) - return true; + // 如果请求更新时间已到,则执行方法调用 - if (readVariableMethods.CheckIfRequestAndUpdateTime(DateTime.Now)) + if (readVariableMethods.CheckIfRequestAndUpdateTime()) { if (cancellationToken.IsCancellationRequested) return true; @@ -274,7 +258,7 @@ public abstract class CollectBase : DriverBase // 方法调用失败时重试一定次数 while (!readResult.IsSuccess && readErrorCount < CollectProperties.RetryCount) { - if (KeepRun != true) + if (Pause) return true; if (cancellationToken.IsCancellationRequested) return true; @@ -294,7 +278,7 @@ public abstract class CollectBase : DriverBase if (LogMessage.LogLevel <= TouchSocket.Core.LogLevel.Trace) LogMessage?.Trace(string.Format("{0} - Execute method[{1}] - Succeeded {2}", DeviceName, readVariableMethods.MethodInfo.Name, readResult.Content?.ToJsonNetString())); readResultCount.deviceMethodsVariableSuccessNum++; - CurrentDevice.SetDeviceStatus(TimerX.Now, 0); + CurrentDevice.SetDeviceStatus(TimerX.Now, false); } else { @@ -316,10 +300,8 @@ public abstract class CollectBase : DriverBase readResultCount.deviceMethodsVariableFailedNum++; readVariableMethods.LastErrorMessage = readResult.ErrorMessage; - CurrentDevice.SetDeviceStatus(TimerX.Now, 0); + CurrentDevice.SetDeviceStatus(TimerX.Now, false); } - if (delay) - await Task.Delay(ChannelThread.MinCycleInterval, cancellationToken).ConfigureAwait(false); } return false; @@ -329,20 +311,18 @@ public abstract class CollectBase : DriverBase #region 执行默认读取 - async ValueTask ReadVariableSource(ReadResultCount readResultCount, VariableSourceRead? variableSourceRead, CancellationToken cancellationToken, bool delay = true) + async ValueTask ReadVariableSource(ReadResultCount readResultCount, VariableSourceRead? variableSourceRead, CancellationToken cancellationToken) { - if (KeepRun != true) + if (Pause) return true; if (cancellationToken.IsCancellationRequested) return true; - if (await TestOnline(cancellationToken).ConfigureAwait(false)) - return true; // 如果请求更新时间已到,则执行变量读取 - if (variableSourceRead.CheckIfRequestAndUpdateTime(DateTime.Now)) + if (variableSourceRead.CheckIfRequestAndUpdateTime()) { if (cancellationToken.IsCancellationRequested) return true; - if (KeepRun != true) + if (Pause) return true; if (await TestOnline(cancellationToken).ConfigureAwait(false)) return true; @@ -355,7 +335,7 @@ public abstract class CollectBase : DriverBase // 读取失败时重试一定次数 while (!readResult.IsSuccess && readErrorCount < CollectProperties.RetryCount) { - if (KeepRun != true) + if (Pause) return true; if (cancellationToken.IsCancellationRequested) return true; @@ -376,7 +356,7 @@ public abstract class CollectBase : DriverBase if (LogMessage.LogLevel <= TouchSocket.Core.LogLevel.Trace) LogMessage?.Trace(string.Format("{0} - Collection[{1} - {2}] data succeeded {3}", DeviceName, variableSourceRead?.RegisterAddress, variableSourceRead?.Length, readResult.Content?.ToHexString(' '))); readResultCount.deviceSourceVariableSuccessNum++; - CurrentDevice.SetDeviceStatus(TimerX.Now, 0); + CurrentDevice.SetDeviceStatus(TimerX.Now, false); } else { @@ -399,13 +379,11 @@ public abstract class CollectBase : DriverBase readResultCount.deviceSourceVariableFailedNum++; variableSourceRead.LastErrorMessage = readResult.ErrorMessage; - CurrentDevice.SetDeviceStatus(TimerX.Now, CurrentDevice.ErrorCount + 1, readResult.ErrorMessage); + CurrentDevice.SetDeviceStatus(TimerX.Now, true, readResult.ErrorMessage); var time = DateTime.Now; - variableSourceRead.VariableRunTimes.ForEach(a => a.SetValue(null, time, isOnline: false)); + variableSourceRead.VariableRuntimes.ForEach(a => a.SetValue(null, time, isOnline: false)); } } - if (delay) - await Task.Delay(ChannelThread.MinCycleInterval, cancellationToken).ConfigureAwait(false); } return false; @@ -417,20 +395,20 @@ public abstract class CollectBase : DriverBase { //设备无法连接时 // 检查协议是否为空,如果为空则抛出异常 - if (Protocol != null) + if (FoundationDevice != null) { - if (Protocol.OnLine == false) + if (FoundationDevice.OnLine == false) { Exception exception = null; try { - await Protocol.Channel.ConnectAsync(Protocol.ConnectTimeout, cancellationToken).ConfigureAwait(false); + await FoundationDevice.Channel.ConnectAsync(FoundationDevice.Channel.ChannelOptions.ConnectTimeout, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { exception = ex; } - if (Protocol.OnLine == false && exception != null) + if (FoundationDevice.OnLine == false && exception != null) { foreach (var item in CurrentDevice.VariableSourceReads) { @@ -440,9 +418,9 @@ public abstract class CollectBase : DriverBase LogMessage?.LogWarning(exception, Localizer["CollectFail", DeviceName, item?.RegisterAddress, item?.Length, exception.Message]); } item.LastErrorMessage = exception.Message; - CurrentDevice.SetDeviceStatus(TimerX.Now, CurrentDevice.ErrorCount + 1, exception.Message); + CurrentDevice.SetDeviceStatus(TimerX.Now, true, exception.Message); var time = DateTime.Now; - item.VariableRunTimes.ForEach(a => a.SetValue(null, time, isOnline: false)); + item.VariableRuntimes.ForEach(a => a.SetValue(null, time, isOnline: false)); } foreach (var item in CurrentDevice.ReadVariableMethods) { @@ -452,12 +430,12 @@ public abstract class CollectBase : DriverBase LogMessage?.LogWarning(exception, Localizer["MethodFail", DeviceName, item.MethodInfo.Name, exception.Message]); } item.LastErrorMessage = exception.Message; - CurrentDevice.SetDeviceStatus(TimerX.Now, CurrentDevice.ErrorCount + 1, exception.Message); + CurrentDevice.SetDeviceStatus(TimerX.Now, true, exception.Message); var time = DateTime.Now; item.Variable.SetValue(null, time, isOnline: false); } - await Task.Delay(10000, cancellationToken).ConfigureAwait(false); + await Task.Delay(3000, cancellationToken).ConfigureAwait(false); return true; } } @@ -471,19 +449,19 @@ public abstract class CollectBase : DriverBase { DateTime dateTime = TimerX.Now; //特殊地址变量 - for (int i = 0; i < CurrentDevice.OtherVariableRunTimes.Count; i++) + for (int i = 0; i < CurrentDevice.OtherVariableRuntimes.Count; i++) { if (cancellationToken.IsCancellationRequested) return; - var variableRunTime = CurrentDevice.OtherVariableRunTimes[i]; - if (variableRunTime.RegisterAddress == nameof(DeviceRunTime.DeviceStatus)) + var variableRuntime = CurrentDevice.OtherVariableRuntimes[i]; + if (variableRuntime.RegisterAddress == nameof(DeviceRuntime.DeviceStatus)) { - variableRunTime.SetValue(variableRunTime.CollectDeviceRunTime.DeviceStatus, dateTime); + variableRuntime.SetValue(variableRuntime.DeviceRuntime.DeviceStatus, dateTime); } - else if (variableRunTime.RegisterAddress == "ScriptRead") + else if (variableRuntime.RegisterAddress == "ScriptRead") { - variableRunTime.SetValue(default, dateTime); + variableRuntime.SetValue(default, dateTime); } } @@ -495,8 +473,10 @@ public abstract class CollectBase : DriverBase /// /// 设备下的全部通讯点位 /// - protected abstract List ProtectedLoadSourceRead(List deviceVariables); + protected abstract List ProtectedLoadSourceRead(List deviceVariables); + + protected AsyncReadWriteLock ReadWriteLock = new(); /// /// 采集驱动读取,读取成功后直接赋值变量,失败不做处理,注意非通用设备需重写 /// @@ -504,18 +484,12 @@ public abstract class CollectBase : DriverBase { try { - if (IsSingleThread) - { - while (WriteLock.IsWaitting) - { - await Task.Delay(100, cancellationToken).ConfigureAwait(false);//写优先,直接等待一段时间 - } - } + await ReadWriteLock.ReaderLockAsync(cancellationToken).ConfigureAwait(false); if (cancellationToken.IsCancellationRequested) return new(new OperationCanceledException()); // 从协议读取数据 - var read = await Protocol.ReadAsync(variableSourceRead.RegisterAddress, variableSourceRead.Length, cancellationToken).ConfigureAwait(false); + var read = await FoundationDevice.ReadAsync(variableSourceRead.RegisterAddress, variableSourceRead.Length, cancellationToken).ConfigureAwait(false); // 增加变量源的读取次数 Interlocked.Increment(ref variableSourceRead.ReadCount); @@ -523,7 +497,7 @@ public abstract class CollectBase : DriverBase // 如果读取成功且有有效内容,则解析结构化内容 if (read.IsSuccess) { - var prase = variableSourceRead.VariableRunTimes.PraseStructContent(Protocol, read.Content, false); + var prase = variableSourceRead.VariableRuntimes.PraseStructContent(FoundationDevice, read.Content, false); return new OperResult(prase); } @@ -539,16 +513,13 @@ public abstract class CollectBase : DriverBase /// 批量写入变量值,需返回变量名称/结果,注意非通用设备需重写 /// /// - protected virtual async ValueTask> WriteValuesAsync(Dictionary writeInfoLists, CancellationToken cancellationToken) + protected virtual async ValueTask> WriteValuesAsync(Dictionary writeInfoLists, CancellationToken cancellationToken) { + using var writeLock = ReadWriteLock.WriterLock(); try { - // 如果是单线程模式,则等待写入锁 - if (IsSingleThread) - await WriteLock.WaitAsync(cancellationToken).ConfigureAwait(false); - // 检查协议是否为空,如果为空则抛出异常 - if (Protocol == null) + if (FoundationDevice == null) throw new NotSupportedException(); // 创建用于存储操作结果的并发字典 @@ -560,7 +531,7 @@ public abstract class CollectBase : DriverBase try { // 调用协议的写入方法,将写入信息中的数据写入到对应的寄存器地址,并获取操作结果 - var result = await Protocol.WriteAsync(writeInfo.Key.RegisterAddress, writeInfo.Value, writeInfo.Key.DataType, cancellationToken).ConfigureAwait(false); + var result = await FoundationDevice.WriteAsync(writeInfo.Key.RegisterAddress, writeInfo.Value, writeInfo.Key.DataType, cancellationToken).ConfigureAwait(false); // 将操作结果添加到结果字典中,使用变量名称作为键 operResults.TryAdd(writeInfo.Key.Name, result); @@ -569,16 +540,13 @@ public abstract class CollectBase : DriverBase { operResults.TryAdd(writeInfo.Key.Name, new(ex)); } - }, CollectProperties.ConcurrentCount, cancellationToken).ConfigureAwait(false); + }, CollectProperties.MaxConcurrentCount, cancellationToken).ConfigureAwait(false); // 返回包含操作结果的字典 return new Dictionary(operResults); } finally { - // 如果是单线程模式,则释放写入锁 - if (IsSingleThread) - WriteLock.Release(); } } @@ -598,7 +566,7 @@ public abstract class CollectBase : DriverBase /// 要写入的变量及其对应的数据 /// 取消操作的通知 /// 写入操作的结果字典 - internal async ValueTask>> InvokeMethodAsync(Dictionary writeInfoLists, CancellationToken cancellationToken) + internal async ValueTask>> InvokeMethodAsync(Dictionary writeInfoLists, CancellationToken cancellationToken) { // 初始化结果字典 Dictionary> results = new Dictionary>(); @@ -614,7 +582,7 @@ public abstract class CollectBase : DriverBase try { // 根据写入表达式转换数据 - object data = deviceVariable.WriteExpressions.GetExpressionsResult(rawdata); + object data = deviceVariable.WriteExpressions.GetExpressionsResult(rawdata, LogMessage); // 将转换后的数据重新赋值给写入信息列表 writeInfoLists[deviceVariable] = JToken.FromObject(data); } @@ -628,11 +596,11 @@ public abstract class CollectBase : DriverBase ConcurrentDictionary> operResults = new(); + + using var writeLock = ReadWriteLock.WriterLock(); + try { - // 如果是单线程模式,则等待写入锁 - if (IsSingleThread) - await WriteLock.WaitAsync(cancellationToken).ConfigureAwait(false); // 使用并发方式遍历写入信息列表,并进行异步写入操作 await writeInfoLists @@ -651,13 +619,10 @@ public abstract class CollectBase : DriverBase { operResults.TryAdd(writeInfo.Key.Name, new(ex)); } - }, CollectProperties.ConcurrentCount, cancellationToken).ConfigureAwait(false); + }, CollectProperties.MaxConcurrentCount, cancellationToken).ConfigureAwait(false); } finally { - // 如果是单线程模式,则释放写入锁 - if (IsSingleThread) - WriteLock.Release(); } // 将转换失败的变量和写入成功的变量的操作结果合并到结果字典中 @@ -670,7 +635,7 @@ public abstract class CollectBase : DriverBase /// 要写入的变量及其对应的数据 /// 取消操作的通知 /// 写入操作的结果字典 - internal async ValueTask> InVokeWriteAsync(Dictionary writeInfoLists, CancellationToken cancellationToken) + internal async ValueTask> InVokeWriteAsync(Dictionary writeInfoLists, CancellationToken cancellationToken) { // 初始化结果字典 Dictionary results = new Dictionary(); @@ -687,7 +652,7 @@ public abstract class CollectBase : DriverBase try { // 根据写入表达式转换数据 - object data = deviceVariable.WriteExpressions.GetExpressionsResult(rawdata); + object data = deviceVariable.WriteExpressions.GetExpressionsResult(rawdata, LogMessage); // 将转换后的数据重新赋值给写入信息列表 writeInfoLists[deviceVariable] = JToken.FromObject(data); } @@ -700,8 +665,8 @@ public abstract class CollectBase : DriverBase } - var writePList = writeInfoLists.Where(a => !CurrentDevice.OtherVariableRunTimes.Contains(a.Key)); - var writeSList = writeInfoLists.Where(a => CurrentDevice.OtherVariableRunTimes.Contains(a.Key)); + var writePList = writeInfoLists.Where(a => !CurrentDevice.OtherVariableRuntimes.Contains(a.Key)); + var writeSList = writeInfoLists.Where(a => CurrentDevice.OtherVariableRuntimes.Contains(a.Key)); DateTime now = DateTime.Now; foreach (var item in writeSList) diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Collect/CollectPropertyBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectPropertyBase.cs similarity index 80% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Collect/CollectPropertyBase.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectPropertyBase.cs index 5a3017a4f..dbafae30a 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Collect/CollectPropertyBase.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/Collect/CollectPropertyBase.cs @@ -12,9 +12,6 @@ namespace ThingsGateway.Gateway.Application; /// /// 插件配置项 -/// -/// 约定: -/// 如果需要密码输入,属性名称中需包含Password字符串 ///

/// 使用 标识所需的配置属性 ///
@@ -23,13 +20,13 @@ public abstract class CollectPropertyBase : DriverPropertyBase /// /// 最大并发数量 /// - public virtual int ConcurrentCount { get; set; } = 1; + public virtual int MaxConcurrentCount { get; set; } = 1; /// - /// 离线后恢复运行的间隔时间 /s,默认30s + /// 离线后恢复运行的间隔时间 /// [DynamicProperty] - public virtual int ReIntervalTime { get; set; } = 30; + public virtual int ReIntervalTime { get; set; } = 30000; /// /// 失败重试次数,默认3 diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Driver/DriverBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/DriverBase.cs new file mode 100644 index 000000000..99987eba4 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/DriverBase.cs @@ -0,0 +1,521 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.Extensions.Localization; + +using ThingsGateway.NewLife.Threading; +using ThingsGateway.Razor; + +using TouchSocket.Core; + +namespace ThingsGateway.Gateway.Application; + +/// +/// 插件基类 +/// +public abstract class DriverBase : DisposableObject, IDriver +{ + /// + public DriverBase() + { + Localizer = App.CreateLocalizerByType(typeof(DriverBase))!; + } + + #region 属性 + + /// + /// 当前设备 + /// + public DeviceRuntime? CurrentDevice { get; private set; } + + /// + /// 当前设备Id + /// + public long DeviceId => CurrentDevice?.Id ?? 0; + + /// + /// 当前设备名称 + /// + public string? DeviceName => CurrentDevice?.Name; + + /// + /// 调试UI Type,如果不存在,返回null + /// + public virtual Type DriverDebugUIType { get; } + + /// + /// 插件UI Type,继承如果不存在,返回null + /// + public virtual Type DriverUIType { get; } + + /// + /// 插件属性UI Type,继承如果不存在,返回null + /// + public virtual Type DriverPropertyUIType { get; } + + /// + /// 插件变量寄存器UI Type,继承如果不存在,返回null + /// + public virtual Type DriverVariableAddressUIType { get; } + + /// + /// 插件配置项 + /// + public abstract object DriverProperties { get; } + + /// + /// 是否执行了Start方法 + /// + public bool IsStarted { get; protected set; } = false; + + /// + /// 是否初始化成功,失败时不再执行,等待检测重启 + /// + public bool IsInitSuccess { get; internal set; } = true; + + /// + /// 是否采集插件 + /// + public virtual bool? IsCollectDevice => CurrentDevice?.IsCollect; + + /// + /// 暂停 + /// + public bool Pause => CurrentDevice?.Pause == true; + + private List pluginPropertyEditorItems; + public List PluginPropertyEditorItems + { + get + { + if (pluginPropertyEditorItems == null) + { + pluginPropertyEditorItems = PluginServiceUtil.GetEditorItems(DriverProperties?.GetType()).ToList(); + } + return pluginPropertyEditorItems; + } + } + + /// + /// 底层驱动,有可能为null + /// + public virtual IDevice? FoundationDevice { get; } + + private IStringLocalizer Localizer { get; } + + #endregion 属性 + + #region 变量管理 + + private WaitLock NewVariableLock = new(); + + /// + /// 动态刷新变量 + /// + public async Task RefreshVariableAsync() + { + try + { + await NewVariableLock.WaitAsync().ConfigureAwait(false); + AfterVariablesChanged(); + } + finally + { + NewVariableLock.Release(); + } + } + + #endregion + + /// + /// 暂停 + /// + /// 暂停 + public void PauseThread(bool pause) + { + lock (this) + { + if (CurrentDevice == null) return; + var str = pause == true ? "DeviceTaskPause" : "DeviceTaskContinue"; + LogMessage?.LogInformation(Localizer[str, DeviceName]); + CurrentDevice.Pause = pause; + } + } + + public override string ToString() + { + return FoundationDevice?.ToString() ?? base.ToString(); + } + + #region 任务管理器传入 + + public IDeviceThreadManage DeviceThreadManage { get; internal set; } + + public string PluginDirectory => CurrentChannel?.PluginInfo?.Directory; + + public ChannelRuntime CurrentChannel => DeviceThreadManage?.CurrentChannel; + + #endregion 任务管理器传入 + + #region 日志 + + private WaitLock SetLogLock = new(); + public async Task SetLogAsync(bool enable, LogLevel? logLevel = null, bool upDataBase = true) + { + try + { + await SetLogLock.WaitAsync().ConfigureAwait(false); + bool up = false; + + if (upDataBase && (CurrentDevice.LogEnable != enable || (logLevel != null && CurrentDevice.LogLevel != logLevel))) + { + up = true; + } + + CurrentDevice.LogEnable = enable; + if (logLevel != null) + CurrentDevice.LogLevel = logLevel.Value; + if (up) + { + //更新数据库 + await GlobalData.DeviceService.UpdateLogAsync(CurrentDevice.Id, CurrentDevice.LogEnable, CurrentDevice.LogLevel).ConfigureAwait(false); + } + + SetLog(CurrentDevice.LogEnable, CurrentDevice.LogLevel); + + } + catch (Exception ex) + { + LogMessage?.LogWarning(ex); + } + finally + { + SetLogLock.Release(); + } + } + private void SetLog(bool enable, LogLevel? logLevel = null) + { + // 如果日志使能状态为 true + if (enable) + { + + LogMessage.LogLevel = logLevel ?? TouchSocket.Core.LogLevel.Trace; + // 移除旧的文件日志记录器并释放资源 + if (TextLogger != null) + { + LogMessage.RemoveLogger(TextLogger); + TextLogger?.Dispose(); + } + + // 创建新的文件日志记录器,并设置日志级别为 Trace + TextLogger = TextFileLogger.GetMultipleFileLogger(LogPath); + TextLogger.LogLevel = logLevel ?? TouchSocket.Core.LogLevel.Trace; + // 将文件日志记录器添加到日志消息组中 + LogMessage.AddLogger(TextLogger); + } + else + { + if (logLevel != null) + LogMessage.LogLevel = logLevel.Value; + //LogMessage.LogLevel = TouchSocket.Core.LogLevel.Warning; + // 如果日志使能状态为 false,移除文件日志记录器并释放资源 + if (TextLogger != null) + { + LogMessage.RemoveLogger(TextLogger); + TextLogger?.Dispose(); + } + } + } + + private TextFileLogger? TextLogger; + + public LoggerGroup LogMessage { get; private set; } + + public string LogPath => CurrentDevice?.LogPath; + + #endregion + + #region 插件生命周期 + Microsoft.Extensions.Logging.ILogger? _logger; + /// + /// 内部初始化 + /// + internal void InitDevice(DeviceRuntime device) + { + CurrentDevice = device; + + _logger = App.RootServices.GetService().CreateLogger($"Driver[{CurrentDevice.Name}]"); + + LogMessage = new LoggerGroup() { LogLevel = TouchSocket.Core.LogLevel.Warning };//不显示调试日志 + + // 添加默认日志记录器 + LogMessage.AddLogger(new EasyLogger(Log_Out) { LogLevel = TouchSocket.Core.LogLevel.Trace }); + + SetLog(CurrentDevice.LogEnable, CurrentDevice.LogLevel); + + device.Driver = this; + + ProtectedInitDevice(device); + } + + private void Log_Out(TouchSocket.Core.LogLevel level, object arg2, string arg3, Exception exception) + { + if (level >= TouchSocket.Core.LogLevel.Warning) + { + CurrentDevice.SetDeviceStatus(lastErrorMessage: arg3); + } + _logger?.Log_Out(level, arg2, arg3, exception); + } + + /// + /// 在循环任务开始之前 + /// + /// 取消操作的令牌。 + /// 表示异步操作的任务。 + internal async ValueTask StartAsync(CancellationToken cancellationToken) + { + // 如果已经执行过初始化,则直接返回 + if (IsStarted) + { + return; + } + // 如果已经取消了操作,则直接返回 + if (cancellationToken.IsCancellationRequested) + { + return; + } + + try + { + + // 记录设备任务开始信息 + LogMessage?.LogInformation(Localizer["DeviceTaskStart", DeviceName]); + + var timeout = 60; // 设置超时时间为 60 秒 + + try + { + // 异步执行初始化操作,并设置超时时间 + await ProtectedStartAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(timeout), cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + } + catch (TimeoutException) + { + // 如果初始化操作超时,则记录警告信息 + LogMessage?.LogWarning(Localizer["DeviceTaskStartTimeout", DeviceName, timeout]); + } + + // 设置设备状态为当前时间 + CurrentDevice.SetDeviceStatus(TimerX.Now); + } + catch (Exception ex) + { + // 记录执行过程中的异常信息,并设置设备状态为异常 + LogMessage?.LogWarning(ex, "Before Start error"); + CurrentDevice.SetDeviceStatus(TimerX.Now, true, ex.Message); + } + finally + { + // 标记已执行初始化 + IsStarted = true; + } + } + + /// + /// 循环任务 + /// + /// 取消操作的令牌。 + /// 表示异步操作结果的枚举。 + internal async ValueTask ExecuteAsync(CancellationToken cancellationToken) + { + try + { + // 如果取消操作被请求,则返回中断状态 + if (cancellationToken.IsCancellationRequested) + { + return ThreadRunReturnTypeEnum.Break; + } + + // 如果标志为停止,则暂停执行 + if (Pause) + { + // 暂停 + return ThreadRunReturnTypeEnum.Continue; + } + + // 再次检查取消操作是否被请求 + if (cancellationToken.IsCancellationRequested) + { + return ThreadRunReturnTypeEnum.Break; + } + + // 获取设备连接状态并更新设备活动时间 + if (IsConnected()) + { + // 如果不是采集设备,则直接更新设备状态为当前时间 + if (IsCollectDevice == false) + { + CurrentDevice.SetDeviceStatus(TimerX.Now, false); + } + else + { + // 否则,更新设备活动时间 + CurrentDevice.SetDeviceStatus(TimerX.Now); + } + } + else + { + // 如果设备未连接,则更新设备状态为断开 + if (!IsConnected()) + { + // 如果不是采集设备,则直接更新设备状态为当前时间 + if (IsCollectDevice == false) + { + CurrentDevice.SetDeviceStatus(TimerX.Now, true); + } + } + } + + // 再次检查取消操作是否被请求 + if (cancellationToken.IsCancellationRequested) + { + return ThreadRunReturnTypeEnum.Break; + } + + // 执行任务操作 + await ProtectedExecuteAsync(cancellationToken).ConfigureAwait(false); + + // 再次检查取消操作是否被请求 + if (cancellationToken.IsCancellationRequested) + { + return ThreadRunReturnTypeEnum.Break; + } + + // 正常返回None状态 + return ThreadRunReturnTypeEnum.None; + } + catch (OperationCanceledException) + { + return ThreadRunReturnTypeEnum.Break; + } + catch (ObjectDisposedException) + { + return ThreadRunReturnTypeEnum.Break; + } + catch (Exception ex) + { + // 记录异常信息,并更新设备状态为异常 + LogMessage?.LogError(ex, "Execute"); + CurrentDevice.SetDeviceStatus(TimerX.Now, true, ex.Message); + return ThreadRunReturnTypeEnum.None; + } + } + + /// + /// 已停止循环任务,释放插件 + /// + internal void Stop() + { + + if (!DisposedValue) + { + lock (this) + { + if (!DisposedValue) + { + try + { + // 执行资源释放操作 + Dispose(); + } + catch (Exception ex) + { + // 记录 Dispose 方法执行失败的错误信息 + LogMessage?.LogError(ex, "Dispose"); + } + + // 记录设备线程已停止的信息 + LogMessage?.LogInformation(Localizer["DeviceTaskStop", DeviceName]); + } + } + } + } + + #endregion 插件生命周期 + + #region 插件重写 + /// + /// 内部初始化 + /// + protected virtual void ProtectedInitDevice(DeviceRuntime device) + { + + } + + /// + /// 当前关联的变量 + /// + public Dictionary VariableRuntimes { get; protected set; } = new(); + + /// + /// 是否连接成功 + /// + public virtual bool IsConnected() + { + return FoundationDevice?.OnLine == true; + } + + /// + /// 初始化,在开始前执行,异常时会标识重启 + /// + /// 通道,当通道类型为时,传入null + internal protected virtual void InitChannel(IChannel? channel = null) + { + if (channel != null) + channel.SetupAsync(channel.Config.Clone()); + AfterVariablesChanged(); + } + + /// + /// 变量更改后, 重新初始化变量列表,获取设备变量打包列表/特殊方法列表等 + /// + public abstract void AfterVariablesChanged(); + + /// + protected override void Dispose(bool disposing) + { + FoundationDevice?.Dispose(); + base.Dispose(disposing); + } + + /// + /// 开始通讯执行的方法 + /// + /// + /// + protected virtual async Task ProtectedStartAsync(CancellationToken cancellationToken) + { + if (FoundationDevice?.Channel != null) + await FoundationDevice.Channel.ConnectAsync(FoundationDevice.Channel.ChannelOptions.ConnectTimeout, cancellationToken).ConfigureAwait(false); + } + + /// + /// 间隔执行 + /// + protected abstract ValueTask ProtectedExecuteAsync(CancellationToken cancellationToken); + + + + #endregion 插件重写 +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DriverPropertyBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/DriverPropertyBase.cs similarity index 90% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/DriverPropertyBase.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/DriverPropertyBase.cs index 863c0f463..e48e1ce55 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DriverPropertyBase.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/DriverPropertyBase.cs @@ -13,8 +13,6 @@ namespace ThingsGateway.Gateway.Application; /// /// 插件配置项 /// -/// 约定: -/// 如果需要密码输入,属性名称中需包含Password字符串 ///

/// 使用 标识所需的配置属性 ///
diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DynamicModelExtension.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/DynamicModelExtension.cs similarity index 89% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/DynamicModelExtension.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Driver/DynamicModelExtension.cs index e5329c3ee..631e5ab6e 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DynamicModelExtension.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/DynamicModelExtension.cs @@ -35,24 +35,22 @@ public static class DynamicModelExtension } } - - /// /// 获取变量的业务属性值 /// - /// 当前变量 + /// 当前变量 /// 对应业务设备Id /// 属性名称 /// 属性值,如果不存在则返回null - public static string? GetPropertyValue(this VariableRunTime variableRunTime, long businessId, string propertyName) + public static string? GetPropertyValue(this VariableRuntime variableRuntime, long businessId, string propertyName) { - if (variableRunTime == null || propertyName.IsNullOrWhiteSpace()) + if (variableRuntime == null || propertyName.IsNullOrWhiteSpace()) return null; // 检查是否存在对应的业务设备Id - if (variableRunTime.VariablePropertys?.ContainsKey(businessId) == true) + if (variableRuntime.VariablePropertys?.ContainsKey(businessId) == true) { - variableRunTime.VariablePropertys[businessId].TryGetValue(propertyName, out var value); + variableRuntime.VariablePropertys[businessId].TryGetValue(propertyName, out var value); return value; // 返回属性值 } @@ -99,3 +97,8 @@ public static class DynamicModelExtension } } + +public interface IDynamicModel +{ + IEnumerable GetList(IEnumerable datas); +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Driver/IDriver.cs b/src/Gateway/ThingsGateway.Gateway.Application/Driver/IDriver.cs new file mode 100644 index 000000000..4624cf1bf --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Driver/IDriver.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using TouchSocket.Core; + +namespace ThingsGateway.Gateway.Application +{ + public interface IDriver : IDisposable + { + bool DisposedValue { get; } + ChannelRuntime CurrentChannel { get; } + DeviceRuntime? CurrentDevice { get; } + long DeviceId { get; } + string? DeviceName { get; } + Type DriverDebugUIType { get; } + object DriverProperties { get; } + + Type DriverPropertyUIType { get; } + Type DriverUIType { get; } + Type DriverVariableAddressUIType { get; } + IDevice? FoundationDevice { get; } + bool? IsCollectDevice { get; } + bool IsInitSuccess { get; } + bool IsStarted { get; } + LoggerGroup LogMessage { get; } + string LogPath { get; } + bool Pause { get; } + string PluginDirectory { get; } + List PluginPropertyEditorItems { get; } + Dictionary VariableRuntimes { get; } + IDeviceThreadManage DeviceThreadManage { get; } + + bool IsConnected(); + void PauseThread(bool pause); + Task RefreshVariableAsync(); + Task SetLogAsync(bool enable, LogLevel? logLevel = null, bool upDataBase = true); + void AfterVariablesChanged(); + } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Entity/Channel.cs b/src/Gateway/ThingsGateway.Gateway.Application/Entity/Channel.cs index 424928c9f..6b2ab599a 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Entity/Channel.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Entity/Channel.cs @@ -13,8 +13,10 @@ using BootstrapBlazor.Components; using SqlSugar; using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; using System.IO.Ports; +using TouchSocket.Core; using TouchSocket.Sockets; namespace ThingsGateway.Gateway.Application; @@ -25,8 +27,16 @@ namespace ThingsGateway.Gateway.Application; [SugarTable("channel", TableDescription = "通道表")] [Tenant(SqlSugarConst.DB_Custom)] [SugarIndex("unique_channel_name", nameof(Channel.Name), OrderByType.Asc, true)] -public class Channel : BaseDataEntity +public class Channel : ChannelOptionsBase, IPrimaryIdEntity, IBaseDataEntity, IBaseEntity { + /// + /// 主键Id + /// + [SugarColumn(ColumnDescription = "Id", IsPrimaryKey = true)] + [IgnoreExcel] + [AutoGenerateColumn(Visible = false, IsVisibleWhenEdit = false, IsVisibleWhenAdd = false, Sortable = true, DefaultSort = true, DefaultSortOrder = SortOrder.Asc)] + public virtual long Id { get; set; } + /// /// 通道名称 /// @@ -38,7 +48,15 @@ public class Channel : BaseDataEntity /// [SugarColumn(ColumnDescription = "通道类型", IsNullable = false)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] - public virtual ChannelTypeEnum ChannelType { get; set; } + public override ChannelTypeEnum ChannelType { get; set; } + + /// + /// 插件名称 + /// + [SugarColumn(ColumnDescription = "插件名称")] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + [Required] + public virtual string PluginName { get; set; } /// /// 使能 @@ -52,70 +70,177 @@ public class Channel : BaseDataEntity /// [SugarColumn(ColumnDescription = "调试日志")] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] - public bool LogEnable { get; set; } + public bool LogEnable { get; set; } = true; + + /// + /// LogLevel + /// + [SugarColumn(ColumnDescription = "日志等级")] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + public LogLevel LogLevel { get; set; } = LogLevel.Info; /// /// 远程地址,可由 相互转化 /// [SugarColumn(ColumnDescription = "远程地址", Length = 200, IsNullable = true)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] - public string? RemoteUrl { get; set; } + [UriValidation] + public override string RemoteUrl { get; set; } = "127.0.0.1:502"; /// /// 本地地址,可由相互转化 /// [SugarColumn(ColumnDescription = "本地地址", Length = 200, IsNullable = true)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] - public string? BindUrl { get; set; } + [UriValidation] + public override string BindUrl { get; set; } /// /// COM /// [SugarColumn(ColumnDescription = "COM", IsNullable = true)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] - public string? PortName { get; set; } + public override string PortName { get; set; } = "COM1"; /// /// 波特率 /// [SugarColumn(ColumnDescription = "波特率", IsNullable = true)] [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] - public int? BaudRate { get; set; } + public override int BaudRate { get; set; } = 9600; /// /// 数据位 /// [SugarColumn(ColumnDescription = "数据位", IsNullable = true)] [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] - public int? DataBits { get; set; } + public override int DataBits { get; set; } = 8; /// /// 校验位 /// [SugarColumn(ColumnDescription = "校验位", IsNullable = true)] [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] - public Parity? Parity { get; set; } + public override Parity Parity { get; set; } /// /// 停止位 /// [SugarColumn(ColumnDescription = "停止位", IsNullable = true)] [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] - public StopBits? StopBits { get; set; } + public override StopBits StopBits { get; set; } = StopBits.One; /// /// DtrEnable /// [SugarColumn(ColumnDescription = "DtrEnable", IsNullable = true)] [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] - public bool? DtrEnable { get; set; } + public override bool DtrEnable { get; set; } /// /// RtsEnable /// [SugarColumn(ColumnDescription = "RtsEnable", IsNullable = true)] [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] - public bool? RtsEnable { get; set; } + public override bool RtsEnable { get; set; } + + /// + /// 缓存超时 + /// + [SugarColumn(ColumnDescription = "缓存超时")] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + [MinValue(100)] + public override int CacheTimeout { get; set; } = 500; + + /// + /// 连接超时 + /// + [SugarColumn(ColumnDescription = "连接超时")] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + [MinValue(100)] + public override ushort ConnectTimeout { get; set; } = 3000; + + /// + /// 最大并发数 + /// + [SugarColumn(ColumnDescription = "最大并发数")] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + [MinValue(1)] + public override int MaxConcurrentCount { get; set; } = 1; + + /// + /// 创建者部门Id + /// + [SugarColumn(ColumnDescription = "创建者部门Id", IsOnlyIgnoreUpdate = true, IsNullable = true)] + [AutoGenerateColumn(Ignore = true)] + [IgnoreExcel] + public virtual long CreateOrgId { get; set; } + + /// + /// 创建时间 + /// + [SugarColumn(ColumnDescription = "创建时间", IsOnlyIgnoreUpdate = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Visible = false, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public virtual DateTime? CreateTime { get; set; } + + /// + /// 创建人 + /// + [SugarColumn(ColumnDescription = "创建人", IsOnlyIgnoreUpdate = true, IsNullable = true)] + [IgnoreExcel] + [NotNull] + [AutoGenerateColumn(Ignore = true)] + public virtual string? CreateUser { get; set; } + + /// + /// 创建者Id + /// + [SugarColumn(ColumnDescription = "创建者Id", IsOnlyIgnoreUpdate = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Ignore = true)] + public virtual long CreateUserId { get; set; } + + /// + /// 软删除 + /// + [SugarColumn(ColumnDescription = "软删除", IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Ignore = true)] + public virtual bool IsDelete { get; set; } = false; + + + /// + /// 更新时间 + /// + [SugarColumn(ColumnDescription = "更新时间", IsOnlyIgnoreInsert = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Visible = false, IsVisibleWhenAdd = false, IsVisibleWhenEdit = false)] + public virtual DateTime? UpdateTime { get; set; } + + /// + /// 更新人 + /// + [SugarColumn(ColumnDescription = "更新人", IsOnlyIgnoreInsert = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Ignore = true)] + public virtual string? UpdateUser { get; set; } + + /// + /// 修改者Id + /// + [SugarColumn(ColumnDescription = "修改者Id", IsOnlyIgnoreInsert = true, IsNullable = true)] + [IgnoreExcel] + [AutoGenerateColumn(Ignore = true)] + public virtual long? UpdateUserId { get; set; } + + /// + /// 排序码 + /// + [SugarColumn(ColumnDescription = "排序码", IsNullable = true)] + [AutoGenerateColumn(Visible = false, DefaultSort = true, Sortable = true, DefaultSortOrder = SortOrder.Asc)] + [IgnoreExcel] + public int? SortCode { get; set; } /// /// 导入验证专用 diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Entity/Device.cs b/src/Gateway/ThingsGateway.Gateway.Application/Entity/Device.cs index 3ca655cd8..a5f9416ca 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Entity/Device.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Entity/Device.cs @@ -22,8 +22,12 @@ namespace ThingsGateway.Gateway.Application; [SugarTable("device", TableDescription = "设备表")] [Tenant(SqlSugarConst.DB_Custom)] [SugarIndex("unique_device_name", nameof(Device.Name), OrderByType.Asc, true)] -public class Device : BaseDataEntity +public class Device : BaseDataEntity, IValidatableObject { + public override string ToString() + { + return Name ?? base.ToString(); + } /// /// 名称 /// @@ -50,27 +54,12 @@ public class Device : BaseDataEntity public virtual long ChannelId { get; set; } /// - /// 插件类型 - /// - [SugarColumn(ColumnDescription = "插件类型")] - [AutoGenerateColumn(Ignore = true)] - public virtual PluginTypeEnum PluginType { get; set; } - - /// - /// 默认执行间隔 + /// 默认执行间隔,支持corn表达式 /// [SugarColumn(ColumnDescription = "默认执行间隔")] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] public virtual string IntervalTime { get; set; } = "1000"; - /// - /// 插件名称 - /// - [SugarColumn(ColumnDescription = "插件名称")] - [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] - [Required] - public virtual string PluginName { get; set; } - /// /// 设备使能 /// @@ -78,6 +67,20 @@ public class Device : BaseDataEntity [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] public virtual bool Enable { get; set; } = true; + /// + /// LogEnable + /// + [SugarColumn(ColumnDescription = "调试日志")] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + public virtual bool LogEnable { get; set; } = true; + + /// + /// LogLevel + /// + [SugarColumn(ColumnDescription = "日志等级")] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + public virtual TouchSocket.Core.LogLevel LogLevel { get; set; } = TouchSocket.Core.LogLevel.Info; + /// /// 设备属性Json /// @@ -103,6 +106,27 @@ public class Device : BaseDataEntity [IgnoreExcel] public long? RedundantDeviceId { get; set; } + /// + /// 冗余模式 + /// + [SugarColumn(ColumnDescription = "冗余模式")] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + public virtual RedundantSwitchTypeEnum RedundantSwitchType { get; set; } + + /// + /// 冗余扫描间隔 + /// + [SugarColumn(ColumnDescription = "冗余扫描间隔")] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + public virtual int RedundantScanIntervalTime { get; set; } = 30000; + + /// + /// 冗余切换判断脚本,返回true则切换冗余设备 + /// + [SugarColumn(ColumnDescription = "冗余切换判断脚本", IsNullable = true)] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + public virtual string RedundantScript { get; set; } + #endregion 冗余配置 #region 备用字段 @@ -154,10 +178,12 @@ public class Device : BaseDataEntity [AutoGenerateColumn(Ignore = true)] internal bool IsUp { get; set; } - /// - /// 插件属性 - /// - [System.Text.Json.Serialization.JsonIgnore] - [Newtonsoft.Json.JsonIgnore] - public ModelValueValidateForm PluginPropertyModel; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (RedundantEnable && RedundantDeviceId == null) + { + yield return new ValidationResult("When enable redundancy, you must select a redundant device.", new[] { nameof(RedundantEnable), nameof(RedundantDeviceId) }); + } + } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Entity/IEqualityOperators.cs b/src/Gateway/ThingsGateway.Gateway.Application/Entity/IEqualityOperators.cs new file mode 100644 index 000000000..5c656e254 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Entity/IEqualityOperators.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +namespace ThingsGateway.Gateway.Application +{ + internal interface IEqualityOperators + { + } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Entity/Variable.cs b/src/Gateway/ThingsGateway.Gateway.Application/Entity/Variable.cs index 67da99a2b..7e53f7445 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Entity/Variable.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Entity/Variable.cs @@ -10,7 +10,7 @@ using BootstrapBlazor.Components; -using Newtonsoft.Json.Linq; +using Mapster; using SqlSugar; @@ -18,6 +18,8 @@ using System.Collections.Concurrent; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using ThingsGateway.Razor; + namespace ThingsGateway.Gateway.Application; /// @@ -37,7 +39,7 @@ public class Variable : BaseDataEntity, IValidatableObject [IgnoreExcel] [Required] [NotNull] - public virtual long? DeviceId { get; set; } + public virtual long DeviceId { get; set; } /// /// 变量名称 @@ -75,6 +77,13 @@ public class Variable : BaseDataEntity, IValidatableObject [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] public string? RegisterAddress { get; set; } + /// + /// 数组长度 + /// + [SugarColumn(ColumnDescription = "数组长度", IsNullable = true)] + [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] + public int? ArrayLength { get; set; } + /// /// 其他方法,若不为空,此时RegisterAddress为方法参数 /// @@ -138,11 +147,10 @@ public class Variable : BaseDataEntity, IValidatableObject set { if (value != null) - _value = value?.ToString().GetJTokenFromString(); + _value = value?.ToString()?.GetJTokenFromString(); else _value = null; } - } private object? _value; @@ -158,7 +166,7 @@ public class Variable : BaseDataEntity, IValidatableObject /// [SugarColumn(IsJson = true, ColumnDataType = StaticConfig.CodeFirst_BigString, ColumnDescription = "变量属性Json", IsNullable = true)] [IgnoreExcel] - [AutoGenerateColumn(Visible = false)] + [AutoGenerateColumn(Ignore = true)] public ConcurrentDictionary>? VariablePropertys { get; set; } #region 报警 @@ -417,11 +425,15 @@ public class Variable : BaseDataEntity, IValidatableObject /// [System.Text.Json.Serialization.JsonIgnore] [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] public ConcurrentDictionary? VariablePropertyModels; public IEnumerable Validate(ValidationContext validationContext) { - + if (string.IsNullOrEmpty(RegisterAddress) && string.IsNullOrEmpty(OtherMethod)) + { + yield return new ValidationResult("Both RegisterAddress and OtherMethod cannot be empty or null.", new[] { nameof(RegisterAddress), nameof(OtherMethod) }); + } if (HHAlarmEnable && HHAlarmCode == null) { yield return new ValidationResult("HHAlarmCode cannot be null when HHAlarmEnable is true", new[] { nameof(HHAlarmCode) }); @@ -465,10 +477,6 @@ public class Variable : BaseDataEntity, IValidatableObject yield return new ValidationResult("HAlarmCode should be greater than or less than LLAlarmCode", new[] { nameof(HAlarmCode), nameof(LLAlarmCode) }); } } -} -public class ModelValueValidateForm -{ - public object Value { get; set; } - public ValidateForm ValidateForm { get; set; } + } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/IDriverUIBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Enums/BusinessUpdateEnum.cs similarity index 89% rename from src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/IDriverUIBase.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Enums/BusinessUpdateEnum.cs index c9e490986..ded615571 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/Business/IDriverUIBase.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Enums/BusinessUpdateEnum.cs @@ -10,7 +10,9 @@ namespace ThingsGateway.Gateway.Application; -public interface IDriverUIBase +public enum BusinessUpdateEnum { - public object Driver { get; set; } + Change, + Interval, + IntervalOrChange, } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Enums/RedundantSwitchTypeEnum.cs b/src/Gateway/ThingsGateway.Gateway.Application/Enums/RedundantSwitchTypeEnum.cs new file mode 100644 index 000000000..d0a5240c0 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Enums/RedundantSwitchTypeEnum.cs @@ -0,0 +1,27 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Gateway.Application; + +/// +/// 冗余切换类型 +/// +public enum RedundantSwitchTypeEnum +{ + /// + /// 故障切换 + /// + OffLine, + + /// + /// 脚本触发切换 + /// + Script, +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Extensions/ResourceExtensions.cs b/src/Gateway/ThingsGateway.Gateway.Application/Extensions/ResourceExtensions.cs deleted file mode 100644 index a5726375b..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/Extensions/ResourceExtensions.cs +++ /dev/null @@ -1,88 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using BootstrapBlazor.Components; - -namespace ThingsGateway.Gateway.Application; - -[ThingsGateway.DependencyInjection.SuppressSniffer] -public static class ResourceExtensions -{ - /// - /// 构造选择项,ID/Name - /// - /// - /// - public static IEnumerable BuildChannelSelectList(this IEnumerable items) - { - var data = items - .Select((item, index) => - new SelectedItem(item.Id.ToString(), item.Name) - { - } - ).ToList(); - return data; - } - - /// - /// 构造选择项,ID/Name - /// - /// - /// - public static IEnumerable BuildDeviceSelectList(this IEnumerable items) - { - var data = items - .Select((item, index) => - new SelectedItem(item.Id.ToString(), item.Name) - { - } - ).ToList(); - return data; - } - - /// - /// 构造选择项,ID/Name - /// - /// - /// - public static IEnumerable BuildPluginSelectList(this IEnumerable items) - { - var data = items - .Select((item, index) => - new SelectedItem(item.FullName, item.Name) - { - //GroupName = item.FileName, - } - ).ToList(); - return data; - } - - /// - /// 构建树节点,传入的列表已经是树结构 - /// - public static List> BuildTreeItemList(this IEnumerable pluginOutputs, Microsoft.AspNetCore.Components.RenderFragment render = null, TreeViewItem? parent = null) - { - if (pluginOutputs == null) return null; - var trees = new List>(); - foreach (var node in pluginOutputs) - { - var item = new TreeViewItem(node) - { - Text = node.Name, - Parent = parent, - IsExpand = false, - Template = render, - }; - item.Items = BuildTreeItemList(node.Children, render, item) ?? new(); - trees.Add(item); - } - return trees; - } -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Extensions/VariableMethodExtensions.cs b/src/Gateway/ThingsGateway.Gateway.Application/Extensions/VariableMethodExtensions.cs new file mode 100644 index 000000000..260ed77fa --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Extensions/VariableMethodExtensions.cs @@ -0,0 +1,45 @@ +using System.Text.RegularExpressions; + +using TouchSocket.Core; +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +namespace ThingsGateway.Gateway.Application; + +public static class VariableMethodExtensions +{ + public static List SplitOS(this string input) + { + var results = new List(); + if (input.IsNullOrEmpty()) + { + return results; + } + input = input?.Trim()?.TrimEnd(','); + // 正则表达式解析 + var matches = Regex.Matches(input, "\"([^\"]*)\"|([^,]+)"); + + foreach (Match match in matches) + { + if (match.Groups[1].Success) + { + // 如果匹配的是引号内的内容 + results.Add(match.Groups[1].Value); + } + else + { + // 如果匹配的是普通内容 + results.Add(match.Groups[2].Value); + } + } + + return results; + } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/GlobalData/GlobalData.cs b/src/Gateway/ThingsGateway.Gateway.Application/GlobalData/GlobalData.cs index 7b9e9a5f6..5269b5e98 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/GlobalData/GlobalData.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/GlobalData/GlobalData.cs @@ -11,36 +11,35 @@ using Mapster; using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; + +using ThingsGateway.Extension.Generic; namespace ThingsGateway.Gateway.Application; /// /// 设备状态变化委托,用于通知设备状态发生变化时的事件 /// -/// 设备运行时对象 +/// 设备运行时对象 /// 设备数据对象 -public delegate void DelegateOnDeviceChanged(DeviceRunTime deviceRunTime, DeviceBasicData deviceData); +public delegate void DelegateOnDeviceChanged(DeviceRuntime deviceRuntime, DeviceBasicData deviceData); /// /// 变量改变事件委托,用于通知变量值发生变化时的事件 /// -/// 变量运行时对象 +/// 变量运行时对象 /// 变量数据对象 -public delegate void VariableChangeEventHandler(VariableRunTime variableRunTime, VariableBasicData variableData); - +public delegate void VariableChangeEventHandler(VariableRuntime variableRuntime, VariableBasicData variableData); /// /// 变量采集事件委托,用于通知变量进行采集时的事件 /// -/// 变量运行时对象 -public delegate void VariableCollectEventHandler(VariableRunTime variableRunTime); +/// 变量运行时对象 +public delegate void VariableCollectEventHandler(VariableRuntime variableRuntime); /// /// 采集设备值与状态全局提供类,用于提供全局的设备状态和变量数据的管理 /// public static class GlobalData { - /// /// 设备状态变化事件,当设备状态发生变化时触发该事件 /// @@ -49,41 +48,216 @@ public static class GlobalData /// /// 变量值改变事件,当变量值发生改变时触发该事件 /// - public static event VariableChangeEventHandler? VariableValueChangeEvent; - + public static event VariableChangeEventHandler VariableValueChangeEvent; /// /// 变量采集事件,当变量进行采集时触发该事件 /// internal static event VariableCollectEventHandler? VariableCollectChangeEvent; /// - /// 只读的业务设备字典,提供对业务设备的只读访问 + /// 报警变化事件 /// - public static IReadOnlyDictionary ReadOnlyBusinessDevices => BusinessDevices; + public static event VariableAlarmEventHandler AlarmChangedEvent; /// - /// 只读的采集设备字典,提供对采集设备的只读访问 + /// 只读的通道字典,提供对通道的只读访问 /// - public static IReadOnlyDictionary ReadOnlyCollectDevices => CollectDevices; + public static IReadOnlyDictionary ReadOnlyChannels => Channels; + public static async Task>> GetCurrentUserChannels() + { + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + return ReadOnlyChannels.WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.Value.CreateOrgId))//在指定机构列表查询 + .WhereIf(dataScope?.Count == 0, u => u.Value.CreateUserId == UserManager.UserId); + } + public static async Task>> GetCurrentUserDevices() + { + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + return ReadOnlyDevices.WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.Value.CreateOrgId))//在指定机构列表查询 + .WhereIf(dataScope?.Count == 0, u => u.Value.CreateUserId == UserManager.UserId); + } + public static async Task>> GetCurrentUserIdVariables() + { + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + return IdVariables.WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.Value.CreateOrgId))//在指定机构列表查询 + .WhereIf(dataScope?.Count == 0, u => u.Value.CreateUserId == UserManager.UserId); + } + public static async Task>> GetCurrentUserVariables() + { + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + return Variables.WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.Value.CreateOrgId))//在指定机构列表查询 + .WhereIf(dataScope?.Count == 0, u => u.Value.CreateUserId == UserManager.UserId); + } + public static async Task>> GetCurrentUserRealAlarmVariables() + { + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + return RealAlarmVariables.WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.Value.CreateOrgId))//在指定机构列表查询 + .WhereIf(dataScope?.Count == 0, u => u.Value.CreateUserId == UserManager.UserId); + } + + /// + /// 只读的通道字典,提供对通道的只读访问 + /// + public static IEnumerable> GetEnableChannels() + { + return Channels.Where(a => a.Value.Enable); + } + + /// + /// 只读的设备字典,提供对设备的只读访问 + /// + public static IReadOnlyDictionary ReadOnlyDevices => Devices; + /// + /// 只读的通道字典,提供对通道的只读访问 + /// + public static IEnumerable> GetEnableDevices() + { + var idSet = GetRedundantDeviceIds(); + return Devices.Where(a => a.Value.Enable && !idSet.Contains(a.Value.Id)); + } + public static HashSet GetRedundantDeviceIds() + { + return Devices.Select(a => a.Value).Where(a => a.RedundantEnable && a.RedundantDeviceId != null).Select(a => a.RedundantDeviceId ?? 0).ToHashSet(); + } /// /// 实时报警列表 /// - public static IReadOnlyDictionary ReadOnlyRealAlarmVariables => AlarmHostedService.ReadOnlyRealAlarmVariables; - + public static IReadOnlyDictionary ReadOnlyRealAlarmVariables => RealAlarmVariables; /// /// 只读的变量字典 /// - public static IReadOnlyDictionary ReadOnlyVariables => Variables; + public static IReadOnlyDictionary ReadOnlyVariables => Variables; + /// + /// 只读的变量字典 + /// + public static IReadOnlyDictionary ReadOnlyIdVariables => IdVariables; + public static IEnumerable> GetEnableVariables() + { + var idSet = Devices.Select(a => a.Value).Where(a => a.RedundantEnable && a.RedundantDeviceId != null).Select(a => a.RedundantDeviceId ?? 0).ToHashSet(); - public static bool TryGetVariable(string key, [MaybeNullWhen(false)] out VariableRunTime value) => Variables.TryGetValue(key, out value); - public static bool TryGetCollectDevice(string key, [MaybeNullWhen(false)] out CollectDeviceRunTime value) => CollectDevices.TryGetValue(key, out value); - public static bool TryGetBusinessDevice(string key, [MaybeNullWhen(false)] out DeviceRunTime value) => BusinessDevices.TryGetValue(key, out value); + return Variables.Where(a => a.Value.Enable && !idSet.Contains(a.Value.DeviceId)); + } + public static IEnumerable> GetEnableIdVariables() + { + var idSet = Devices.Select(a => a.Value).Where(a => a.RedundantEnable && a.RedundantDeviceId != null).Select(a => a.RedundantDeviceId ?? 0).ToHashSet(); + + return IdVariables.Where(a => a.Value.Enable && !idSet.Contains(a.Value.DeviceId)); + } + + public static bool TryGetDeviceThreadManage(DeviceRuntime deviceRuntime, out IDeviceThreadManage deviceThreadManage) + { + if (deviceRuntime.Driver?.DeviceThreadManage != null) + { + deviceThreadManage = deviceRuntime.Driver.DeviceThreadManage; + return true; + } + return GlobalData.ChannelThreadManage.DeviceThreadManages.TryGetValue(deviceRuntime.ChannelId, out deviceThreadManage); + } + public static Dictionary> GetDeviceThreadManages(IEnumerable deviceRuntimes) + { + Dictionary> deviceThreadManages = new(); + + foreach (var item in deviceRuntimes) + { + if (TryGetDeviceThreadManage(item, out var deviceThreadManage)) + { + if (deviceThreadManages.TryGetValue(deviceThreadManage, out List? value)) + { + value.Add(item); + } + else + { + deviceThreadManages.Add(deviceThreadManage, new List { item }); + } + } + } + return deviceThreadManages; + } #region 单例服务 + private static ISysUserService sysUserService; + public static ISysUserService SysUserService + { + get + { + if (sysUserService == null) + { + sysUserService = App.RootServices.GetRequiredService(); + } + return sysUserService; + } + } + + + private static IVariableRuntimeService variableRuntimeService; + public static IVariableRuntimeService VariableRuntimeService + { + get + { + if (variableRuntimeService == null) + { + variableRuntimeService = App.RootServices.GetRequiredService(); + } + return variableRuntimeService; + } + } + + + private static IDeviceRuntimeService deviceRuntimeService; + public static IDeviceRuntimeService DeviceRuntimeService + { + get + { + if (deviceRuntimeService == null) + { + deviceRuntimeService = App.RootServices.GetRequiredService(); + } + return deviceRuntimeService; + } + } + + private static IChannelRuntimeService channelRuntimeService; + public static IChannelRuntimeService ChannelRuntimeService + { + get + { + if (channelRuntimeService == null) + { + channelRuntimeService = App.RootServices.GetRequiredService(); + } + return channelRuntimeService; + } + } + private static IChannelThreadManage channelThreadManage; + public static IChannelThreadManage ChannelThreadManage + { + get + { + if (channelThreadManage == null) + { + channelThreadManage = App.RootServices.GetRequiredService(); + } + return channelThreadManage; + } + } + + + private static IGatewayMonitorHostedService gatewayMonitorHostedService; + public static IGatewayMonitorHostedService GatewayMonitorHostedService + { + get + { + if (gatewayMonitorHostedService == null) + { + gatewayMonitorHostedService = App.RootServices.GetRequiredService(); + } + return gatewayMonitorHostedService; + } + } + private static IRpcService rpcService; public static IRpcService RpcService { @@ -110,30 +284,8 @@ public static class GlobalData } } - private static IBusinessDeviceHostedService? businessDeviceHostedService; - - private static ICollectDeviceHostedService? collectDeviceHostedService; - private static IHardwareJob? hardwareJob; - public static IBusinessDeviceHostedService BusinessDeviceHostedService - { - get - { - businessDeviceHostedService ??= App.RootServices.GetRequiredService(); - return businessDeviceHostedService; - } - } - - public static ICollectDeviceHostedService CollectDeviceHostedService - { - get - { - collectDeviceHostedService ??= App.RootServices.GetRequiredService(); - return collectDeviceHostedService; - } - } - public static IHardwareJob HardwareJob { get @@ -144,59 +296,147 @@ public static class GlobalData } - #endregion - - /// - /// 内部使用的业务设备字典,用于存储业务设备对象 - /// - internal static ConcurrentDictionary BusinessDevices { get; } = new(); - - /// - /// 内部使用的采集设备字典,用于存储采集设备对象 - /// - internal static ConcurrentDictionary CollectDevices { get; } = new(); - - /// - /// 内部使用的变量字典,用于存储变量对象 - /// - internal static ConcurrentDictionary Variables { get; } = new(); - - /// - /// 设备状态变化处理方法,用于处理设备状态变化时的逻辑 - /// - /// 设备运行时对象 - internal static void DeviceStatusChange(DeviceRunTime deviceRunTime) + private static IPluginService? pluginService; + public static IPluginService PluginService { - if (DeviceStatusChangeEvent != null) + get { - // 触发设备状态变化事件,并将设备运行时对象转换为设备数据对象进行传递 - DeviceStatusChangeEvent.Invoke(deviceRunTime, deviceRunTime.Adapt()); + pluginService ??= App.RootServices.GetRequiredService(); + return pluginService; + } + } + + + private static IChannelService? channelService; + internal static IChannelService ChannelService + { + get + { + channelService ??= App.RootServices.GetRequiredService(); + return channelService; + } + } + + + private static IDeviceService? deviceService; + internal static IDeviceService DeviceService + { + get + { + deviceService ??= App.RootServices.GetRequiredService(); + return deviceService; + } + } + + + private static IVariableService? variableService; + internal static IVariableService VariableService + { + get + { + variableService ??= App.RootServices.GetRequiredService(); + return variableService; + } + } + + private static IGatewayRedundantSerivce? gatewayRedundantSerivce; + private static IGatewayRedundantSerivce? GatewayRedundantSerivce + { + get + { + gatewayRedundantSerivce ??= App.RootServices.GetService(); + return gatewayRedundantSerivce; } } /// - /// 变量采集处理方法,用于处理变量进行采集时的逻辑 + /// 采集通道是否可用 /// - /// 变量运行时对象 - internal static void VariableCollectChange(VariableRunTime variableRunTime) + public static bool StartCollectChannelEnable => GatewayRedundantSerivce?.StartCollectChannelEnable ?? true; + + /// + /// 业务通道是否可用 + /// + public static bool StartBusinessChannelEnable => GatewayRedundantSerivce?.StartBusinessChannelEnable ?? true; + #endregion + + + + /// + /// 内部使用的通道字典,用于存储通道对象 + /// + internal static ConcurrentDictionary Channels { get; } = new(); + + /// + /// 内部使用的设备字典,用于存储设备对象 + /// + internal static ConcurrentDictionary Devices { get; } = new(); + /// + /// 内部使用的变量字典,用于存储变量对象 + /// + internal static ConcurrentDictionary IdVariables { get; } = new(); + /// + /// 内部使用的变量字典,用于存储变量对象 + /// + internal static ConcurrentDictionary Variables { get; } = new(); + /// + /// 内部使用的报警配置变量字典 + /// + internal static ConcurrentDictionary AlarmEnableVariables { get; } = new(); + + /// + /// 内部使用的报警配置变量字典 + /// + internal static ConcurrentDictionary RealAlarmVariables { get; } = new(); + + /// + /// 报警状态变化处理方法,用于处理报警状态变化时的逻辑 + /// + /// 报警变量 + internal static void AlarmChange(AlarmVariable alarmVariable) { - if (VariableCollectChangeEvent != null) + if (AlarmChangedEvent != null) { - // 触发变量采集事件,并将变量运行时对象转换为变量数据对象进行传递 - VariableCollectChangeEvent.Invoke(variableRunTime); + // 触发设备状态变化事件,并将设备运行时对象转换为设备数据对象进行传递 + AlarmChangedEvent.Invoke(alarmVariable); + } + } + + /// + /// 设备状态变化处理方法,用于处理设备状态变化时的逻辑 + /// + /// 设备运行时对象 + internal static void DeviceStatusChange(DeviceRuntime deviceRuntime) + { + if (DeviceStatusChangeEvent != null) + { + // 触发设备状态变化事件,并将设备运行时对象转换为设备数据对象进行传递 + DeviceStatusChangeEvent.Invoke(deviceRuntime, deviceRuntime.Adapt()); } } /// /// 变量值变化处理方法,用于处理变量值发生变化时的逻辑 /// - /// 变量运行时对象 - internal static void VariableValueChange(VariableRunTime variableRunTime) + /// 变量运行时对象 + internal static void VariableValueChange(VariableRuntime variableRuntime) { if (VariableValueChangeEvent != null) { // 触发变量值变化事件,并将变量运行时对象转换为变量数据对象进行传递 - VariableValueChangeEvent.Invoke(variableRunTime, variableRunTime.Adapt()); + VariableValueChangeEvent.Invoke(variableRuntime, variableRuntime.Adapt()); + } + } + /// + /// 变量采集处理方法,用于处理变量进行采集时的逻辑 + /// + /// 变量运行时对象 + internal static void VariableCollectChange(VariableRuntime variableRuntime) + { + if (VariableCollectChangeEvent != null) + { + // 触发变量采集事件,并将变量运行时对象转换为变量数据对象进行传递 + VariableCollectChangeEvent.Invoke(variableRuntime); } } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/BusinessDeviceHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/BusinessDeviceHostedService.cs deleted file mode 100644 index 8e7df2467..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/BusinessDeviceHostedService.cs +++ /dev/null @@ -1,375 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using BootstrapBlazor.Components; - -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging; - -namespace ThingsGateway.Gateway.Application; - -/// -/// 业务设备服务 -/// -internal sealed class BusinessDeviceHostedService : DeviceHostedService, IBusinessDeviceHostedService -{ - /// - /// 线程检查时间,10分钟 - /// - public const int CheckIntervalTime = 600; - - private WaitLock _easyLock = new(false); - - /// - /// 已执行CreatThreads - /// - private volatile bool started = false; - - private IStringLocalizer BusinessDeviceHostedServiceLocalizer { get; } - - /// - public bool StartBusinessDeviceEnable { get; set; } = true; - - private WaitLock publicRestartLock = new(); - - public BusinessDeviceHostedService(ILogger logger, IStringLocalizer localizer) - { - _logger = logger; - BusinessDeviceHostedServiceLocalizer = localizer; - } - - #region 服务 - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - try - { - //每5分钟检测一次 - await Task.Delay(300000, stoppingToken).ConfigureAwait(false); - - //检测设备线程假死 - var data = DriverBases.ToList(); - var num = data.Count; - for (int i = 0; i < num; i++) - { - DriverBase driverBase = data[i]; - try - { - if (driverBase.CurrentDevice != null) - { - //线程卡死/初始化失败检测 - if (((driverBase.CurrentDevice.ActiveTime != null && driverBase.CurrentDevice.ActiveTime != DateTime.UnixEpoch.ToLocalTime() && driverBase.CurrentDevice.ActiveTime.Value.AddMinutes(CheckIntervalTime) <= DateTime.Now) - || (driverBase.IsInitSuccess == false)) && !driverBase.DisposedValue) - { - //如果线程处于暂停状态,跳过 - if (driverBase.CurrentDevice.DeviceStatus == DeviceStatusEnum.Pause) - continue; - //如果初始化失败 - if (!driverBase.IsInitSuccess) - _logger?.LogWarning(Localizer["DeviceInitFail", driverBase.CurrentDevice.Name]); - else - _logger?.LogWarning(Localizer["DeviceTaskDeath", driverBase.CurrentDevice.Name]); - //重启线程 - await RestartChannelThreadAsync(driverBase.CurrentDevice.Id, false).ConfigureAwait(false); - break; - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "WhileExecute"); - } - } - } - catch (OperationCanceledException) - { - } - catch (ObjectDisposedException) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "BusinessDeviceHostedService WhileExecute"); - } - } - - } - - /// - public override async Task StopAsync(CancellationToken cancellationToken) - { - using var stoppingToken = new CancellationTokenSource(); - _stoppingToken = stoppingToken.Token; - stoppingToken.Cancel(); - //取消全部采集线程 - await BeforeRemoveAllChannelThreadAsync().ConfigureAwait(false); - //停止全部采集线程 - await RemoveAllChannelThreadAsync(true).ConfigureAwait(false); - DriverBases.RemoveBusinessDeviceRuntime(); - - await base.StopAsync(cancellationToken).ConfigureAwait(false); - } - - public override Task StartAsync(CancellationToken cancellationToken) - { - GlobalData.CollectDeviceHostedService.Starting += CollectDeviceHostedService_Starting; - GlobalData.CollectDeviceHostedService.Started += CollectDeviceHostedService_Started; - GlobalData.CollectDeviceHostedService.Stoping += CollectDeviceHostedService_Stoping; - return base.StartAsync(cancellationToken); - } - - #endregion - - /// - public override async Task RestartChannelThreadAsync(long deviceId, bool isChanged, bool deleteCache = false) - { - try - { - // 等待单个重启锁 - await singleRestartLock.WaitAsync().ConfigureAwait(false); - - // 如果没有收到停止请求 - if (!_stoppingToken.IsCancellationRequested) - { - - // 获取包含指定设备ID的通道线程,如果找不到则抛出异常 - var channelThread = ChannelThreads.FirstOrDefault(it => it.Has(deviceId)) - ?? throw new Exception(Localizer["UpadteDeviceIdNotFound", deviceId]); - - // 获取设备运行时信息或者使用通道线程中当前设备的信息 - var dev = isChanged ? (await GetDeviceRunTimeAsync(deviceId).ConfigureAwait(false)).FirstOrDefault() : channelThread.GetDriver(deviceId).CurrentDevice; - - // 先移除设备驱动,此操作会取消线程,需要重新启动线程 - await channelThread.RemoveDriverAsync(deviceId).ConfigureAwait(false); - - if (deleteCache) - { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); - await Task.Delay(2000).ConfigureAwait(false); - var dir = CacheDBUtil.GetFileBasePath(); - var dirs = Directory.GetDirectories(dir).FirstOrDefault(a => Path.GetFileName(a) == deviceId.ToString()); - if (dirs != null) - { - //删除文件夹 - try - { - Directory.Delete(dirs, true); - } - catch { } - } - } - - // 如果设备信息不为空 - if (dev != null) - { - // 创建新的设备驱动并获取对应的通道线程 - DriverBase newDriverBase = dev.CreateDriver(PluginService); - var newChannelThread = await GetChannelThreadAsync(newDriverBase).ConfigureAwait(false); - - // 如果找到了对应的通道线程 - if (newChannelThread != null) - { - // 启动新的通道线程 - await StartChannelThreadAsync(newChannelThread).ConfigureAwait(false); - } - - } - else - { - } - } - - _ = Task.Run(() => - { - DispatchService.Dispatch(new()); - }); - } - finally - { - // 释放单个重启锁 - singleRestartLock.Release(); - } - } - - /// - public async Task RestartAsync(bool removeDevice = true) - { - try - { - await publicRestartLock.WaitAsync().ConfigureAwait(false); - await StopAsync(removeDevice).ConfigureAwait(false); - await StartAsync().ConfigureAwait(false); - } - finally - { - publicRestartLock.Release(); - } - } - - /// - /// 启动/创建全部设备,如果没有找到设备会创建 - /// - public async Task StartAsync() - { - try - { - await restartLock.WaitAsync().ConfigureAwait(false); - await singleRestartLock.WaitAsync().ConfigureAwait(false); - if (!started) - { - ChannelThreads.Clear(); - DriverBases.RemoveBusinessDeviceRuntime(); - await CreatAllChannelThreadsAsync().ConfigureAwait(false); - _ = Task.Run(() => - { - DispatchService.Dispatch(new()); - }); - } - await StartAllChannelThreadsAsync().ConfigureAwait(false); - - } - catch (Exception ex) - { - _logger.LogError(ex, "Start"); - } - finally - { - started = true; - singleRestartLock.Release(); - restartLock.Release(); - } - } - - /// - public async Task StopAsync(bool removeDevice) - { - try - { - await restartLock.WaitAsync().ConfigureAwait(false); - await singleRestartLock.WaitAsync().ConfigureAwait(false); - if (started) - { - //取消全部采集线程 - await BeforeRemoveAllChannelThreadAsync().ConfigureAwait(false); - //停止全部采集线程 - await RemoveAllChannelThreadAsync(removeDevice).ConfigureAwait(false); - DriverBases.RemoveBusinessDeviceRuntime(); - - - for (int i = 0; i < 3; i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Stop"); - } - finally - { - started = false; - singleRestartLock.Release(); - restartLock.Release(); - } - } - - /// - /// 读取数据库,创建全部设备 - /// - /// - private async Task CreatAllChannelThreadsAsync() - { - if (!_stoppingToken.IsCancellationRequested) - { - _logger.LogInformation(BusinessDeviceHostedServiceLocalizer["DeviceRuntimeGeting"]); - var deviceRunTimes = await DeviceService.GetBusinessDeviceRuntimeAsync().ConfigureAwait(false); - _logger.LogInformation(BusinessDeviceHostedServiceLocalizer["DeviceRuntimeGeted"]); - var idSet = deviceRunTimes.Where(a => a.RedundantEnable && a.RedundantDeviceId != null).Select(a => a.RedundantDeviceId ?? 0).ToHashSet().ToDictionary(a => a); - var result = deviceRunTimes.Where(a => !idSet.ContainsKey(a.Id)); - await result.ParallelForEachAsync(async (businessDeviceRunTime, token) => - { - if (!token.IsCancellationRequested) - { - try - { - DriverBase driverBase = businessDeviceRunTime.CreateDriver(PluginService); - await GetChannelThreadAsync(driverBase).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, Localizer["InitError", businessDeviceRunTime.Name]); - } - } - }, Environment.ProcessorCount / 2 == 0 ? 1 : Environment.ProcessorCount / 2, _stoppingToken).ConfigureAwait(false); - } - for (int i = 0; i < 3; i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - } - - - protected override async Task> GetDeviceRunTimeAsync(long deviceId) - { - return await DeviceService.GetBusinessDeviceRuntimeAsync(deviceId).ConfigureAwait(false); - } - - #region 事件通知 - - private async Task CollectDeviceHostedService_Started() - { - if (GlobalData.CollectDeviceHostedService.StartCollectDeviceEnable || GlobalData.BusinessDeviceHostedService.StartBusinessDeviceEnable) - { - await StartAsync().ConfigureAwait(false); - } - } - - private async Task CollectDeviceHostedService_Starting() - { - if (started) - { - await StopAsync(true).ConfigureAwait(false); - } - try - { - await restartLock.WaitAsync().ConfigureAwait(false); - await singleRestartLock.WaitAsync().ConfigureAwait(false); - if (!started) - { - ChannelThreads.Clear(); - DriverBases.RemoveBusinessDeviceRuntime(); - await CreatAllChannelThreadsAsync().ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "CreatThreads"); - } - finally - { - started = true; - singleRestartLock.Release(); - restartLock.Release(); - } - } - - private async Task CollectDeviceHostedService_Stoping() - { - if (!GlobalData.BusinessDeviceHostedService.StartBusinessDeviceEnable) - await StopAsync(true).ConfigureAwait(false); - } - - #endregion -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/CollectDeviceHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/CollectDeviceHostedService.cs deleted file mode 100644 index 9dd7a60a9..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/CollectDeviceHostedService.cs +++ /dev/null @@ -1,505 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging; - -using Newtonsoft.Json.Linq; - -using ThingsGateway.Gateway.Application.Extensions; - -using TouchSocket.Core; - -namespace ThingsGateway.Gateway.Application; - -/// -/// 采集设备服务 -/// -internal sealed class CollectDeviceHostedService : DeviceHostedService, ICollectDeviceHostedService -{ - /// - /// 线程检查时间,10分钟 - /// - public const int CheckIntervalTime = 600; - - private WaitLock _easyLock = new(false); - - /// - /// 已执行CreatThreads - /// - private volatile bool started = false; - - private WaitLock publicRestartLock = new(); - private IStringLocalizer CollectDeviceHostedServiceLocalizer { get; } - - /// - public bool StartCollectDeviceEnable { get; set; } = true; - - public CollectDeviceHostedService(ILogger logger, IStringLocalizer localizer) - { - _logger = logger; - CollectDeviceHostedServiceLocalizer = localizer; - } - - #region 服务 - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await Task.Yield(); - await Task.Delay(5000, stoppingToken).ConfigureAwait(false); - if (StartCollectDeviceEnable) - await StartAsync().ConfigureAwait(false); - GlobalData.DeviceStatusChangeEvent += DeviceRedundantThread; - while (!stoppingToken.IsCancellationRequested) - { - try - { - //每5分钟检测一次 - await Task.Delay(300000, stoppingToken).ConfigureAwait(false); - - //检测设备线程假死 - var data = DriverBases.ToList(); - var num = data.Count; - for (int i = 0; i < num; i++) - { - DriverBase driverBase = data[i]; - try - { - if (driverBase.CurrentDevice != null) - { - //线程卡死/初始化失败检测 - if ((driverBase.CurrentDevice.ActiveTime != null && driverBase.CurrentDevice.ActiveTime != DateTime.UnixEpoch.ToLocalTime() && driverBase.CurrentDevice.ActiveTime.Value.AddMinutes(CheckIntervalTime) <= DateTime.Now) - || (driverBase.IsInitSuccess == false) && !driverBase.DisposedValue) - { - //如果线程处于暂停状态,跳过 - if (driverBase.CurrentDevice.DeviceStatus == DeviceStatusEnum.Pause) - continue; - //如果初始化失败 - if (!driverBase.IsInitSuccess) - _logger?.LogWarning(Localizer["DeviceInitFail", driverBase.CurrentDevice.Name]); - else - _logger?.LogWarning(Localizer["DeviceTaskDeath", driverBase.CurrentDevice.Name]); - //重启线程 - await RestartChannelThreadAsync(driverBase.CurrentDevice.Id, false).ConfigureAwait(false); - break; - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "WhileExecute"); - } - } - } - catch (OperationCanceledException) - { - } - catch (ObjectDisposedException) - { - } - catch (Exception ex) - { - _logger.LogError(ex, "CollectDeviceHostedService WhileExecute"); - } - } - } - - /// - public override async Task StopAsync(CancellationToken cancellationToken) - { - using var stoppingToken = new CancellationTokenSource(); - _stoppingToken = stoppingToken.Token; - stoppingToken.Cancel(); - //取消全部采集线程 - await BeforeRemoveAllChannelThreadAsync().ConfigureAwait(false); - //取消其他后台服务 - await OnCollectDeviceStoping().ConfigureAwait(false); - //停止全部采集线程 - await RemoveAllChannelThreadAsync(true).ConfigureAwait(false); - //停止其他后台服务 - await OnCollectDeviceStoped().ConfigureAwait(false); - DriverBases.RemoveCollectDeviceRuntime(); - - await base.StopAsync(cancellationToken).ConfigureAwait(false); - } - - #endregion - - - /// - public event RestartEventHandler Started; - - /// - public event RestartEventHandler Starting; - - /// - public event RestartEventHandler Stoped; - - /// - public event RestartEventHandler Stoping; - - - /// - public override async Task RestartChannelThreadAsync(long deviceId, bool isChanged, bool deleteCache = false) - { - try - { - // 等待单个重启锁 - await singleRestartLock.WaitAsync().ConfigureAwait(false); - - // 如果没有收到停止请求 - if (!_stoppingToken.IsCancellationRequested) - { - // 如果设备已更改,则停止 - if (isChanged) - await OnCollectDeviceStoping().ConfigureAwait(false); - - // 获取包含指定设备ID的通道线程,如果找不到则抛出异常 - var channelThread = ChannelThreads.FirstOrDefault(it => it.Has(deviceId)) - ?? throw new Exception(Localizer["UpadteDeviceIdNotFound", deviceId]); - - // 获取设备运行时信息或者使用通道线程中当前设备的信息 - var dev = isChanged ? (await GetDeviceRunTimeAsync(deviceId).ConfigureAwait(false)).FirstOrDefault() : channelThread.GetDriver(deviceId).CurrentDevice; - - // 先移除设备驱动,此操作会取消线程,需要重新启动线程 - await channelThread.RemoveDriverAsync(deviceId).ConfigureAwait(false); - - if (isChanged) - await OnCollectDeviceStoped().ConfigureAwait(false); - - if (deleteCache) - { - Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); - await Task.Delay(2000).ConfigureAwait(false); - var dir = CacheDBUtil.GetFileBasePath(); - var dirs = Directory.GetDirectories(dir).FirstOrDefault(a => Path.GetFileName(a) == deviceId.ToString()); - if (dirs != null) - { - //删除文件夹 - try - { - Directory.Delete(dirs, true); - } - catch { } - } - } - - // 如果设备信息不为空 - if (dev != null) - { - // 创建新的设备驱动并获取对应的通道线程 - DriverBase newDriverBase = dev.CreateDriver(PluginService); - var newChannelThread = await GetChannelThreadAsync(newDriverBase).ConfigureAwait(false); - - // 如果找到了对应的通道线程 - if (newChannelThread != null) - { - // 如果设备已更改,则执行启动前的操作 - if (isChanged) - { - await OnCollectDeviceStarting().ConfigureAwait(false); - } - - try - { - // 启动新的通道线程 - await StartChannelThreadAsync(newChannelThread).ConfigureAwait(false); - } - finally - { - if (isChanged) - await OnCollectDeviceStarted().ConfigureAwait(false); - } - } - else - { - // 如果找不到对应的通道线程,则执行启动前后的操作 - if (isChanged) - { - await OnCollectDeviceStarting().ConfigureAwait(false); - await OnCollectDeviceStarted().ConfigureAwait(false); - } - } - } - else - { - // 如果设备信息为空,则执行启动前后的操作 - if (isChanged) - { - await OnCollectDeviceStarting().ConfigureAwait(false); - await OnCollectDeviceStarted().ConfigureAwait(false); - } - } - } - - _ = Task.Run(() => - { - DispatchService.Dispatch(new()); - }); - } - finally - { - // 释放单个重启锁 - singleRestartLock.Release(); - } - } - - /// - public async Task RestartAsync(bool removeDevice = true) - { - try - { - await publicRestartLock.WaitAsync().ConfigureAwait(false); - await StopAsync(removeDevice).ConfigureAwait(false); - await StartAsync().ConfigureAwait(false); - } - finally - { - publicRestartLock.Release(); - } - } - - /// - /// 启动/创建全部设备,如果没有找到设备会创建 - /// - public async Task StartAsync() - { - try - { - await restartLock.WaitAsync().ConfigureAwait(false); - await singleRestartLock.WaitAsync().ConfigureAwait(false); - if (!started) - { - ChannelThreads.Clear(); - DriverBases.RemoveCollectDeviceRuntime(); - - await CreatAllChannelThreadsAsync().ConfigureAwait(false); - await OnCollectDeviceStarting().ConfigureAwait(false); - } - - await StartAllChannelThreadsAsync().ConfigureAwait(false); - await OnCollectDeviceStarted().ConfigureAwait(false); - _ = Task.Run(() => - { - DispatchService.Dispatch(new()); - }); - } - catch (Exception ex) - { - _logger.LogError(ex, "CollectDeviceHostedService Start error"); - } - finally - { - started = true; - singleRestartLock.Release(); - restartLock.Release(); - } - } - - /// - public async Task StopAsync(bool removeDevice) - { - try - { - await restartLock.WaitAsync().ConfigureAwait(false); - await singleRestartLock.WaitAsync().ConfigureAwait(false); - if (started) - { - //取消全部采集线程 - await BeforeRemoveAllChannelThreadAsync().ConfigureAwait(false); - //取消其他后台服务 - await OnCollectDeviceStoping().ConfigureAwait(false); - - await SaveValue().ConfigureAwait(false); - - //停止全部采集线程 - await RemoveAllChannelThreadAsync(removeDevice).ConfigureAwait(false); - //停止其他后台服务 - await OnCollectDeviceStoped().ConfigureAwait(false); - DriverBases.RemoveCollectDeviceRuntime(); - - - for (int i = 0; i < 3; i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Stop"); - } - finally - { - started = false; - singleRestartLock.Release(); - restartLock.Release(); - } - } - - private async Task SaveValue() - { - try - { - //添加保存数据变量读取操作 - var saveVariable = DriverBases.SelectMany(a => a.CurrentDevice.VariableRunTimes).Where(a => a.Value.SaveValue).Select(a => (Variable)a.Value).ToList(); - - if (saveVariable.Count > 0) - { - using var db = DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); - var result = await db.Updateable(saveVariable).UpdateColumns(a => a.Value).ExecuteCommandAsync().ConfigureAwait(false); - } - } - catch (Exception ex) - { - _logger.LogWarning(ex, "SaveValue"); - } - } - - /// - /// 读取数据库,创建全部设备 - /// - /// - private async Task CreatAllChannelThreadsAsync() - { - if (!_stoppingToken.IsCancellationRequested) - { - _logger.LogInformation(CollectDeviceHostedServiceLocalizer["DeviceRuntimeGeting"]); - var collectDeviceRunTimes = (await DeviceService.GetCollectDeviceRuntimeAsync().ConfigureAwait(false)); - var idSet = collectDeviceRunTimes.Where(a => a.RedundantEnable && a.RedundantDeviceId != null).Select(a => a.RedundantDeviceId ?? 0).ToHashSet().ToDictionary(a => a); - var result = collectDeviceRunTimes.Where(a => !idSet.ContainsKey(a.Id)); - - var scripts = collectDeviceRunTimes.SelectMany(a => - - a.VariableRunTimes.Where(a => !a.Value.ReadExpressions.IsNullOrWhiteSpace()) - .Select(b => b.Value.ReadExpressions)) - - .Concat( - -collectDeviceRunTimes.SelectMany(a => - - a.VariableRunTimes.Where(a => !a.Value.WriteExpressions.IsNullOrWhiteSpace()) - .Select(b => b.Value.WriteExpressions))) - - .Distinct().ToList(); - await result.ParallelForEachAsync(async (collectDeviceRunTime, token) => - { - if (!token.IsCancellationRequested) - { - try - { - DriverBase driverBase = collectDeviceRunTime.CreateDriver(PluginService); - await GetChannelThreadAsync(driverBase).ConfigureAwait(false); - - } - catch (Exception ex) - { - _logger.LogError(ex, Localizer["InitError", collectDeviceRunTime.Name]); - } - } - }, Environment.ProcessorCount / 2 == 0 ? 1 : Environment.ProcessorCount / 2, _stoppingToken).ConfigureAwait(false); - - scripts.ParallelForEach(script => - { - if (!_stoppingToken.IsCancellationRequested) - { - try - { - _ = ExpressionEvaluatorExtension.GetOrAddScript(script); - } - catch - { - } - } - }); - _logger.LogInformation(CollectDeviceHostedServiceLocalizer["DeviceRuntimeGeted"]); - } - for (int i = 0; i < 3; i++) - { - GC.Collect(); - GC.WaitForPendingFinalizers(); - } - } - - - - protected override async Task> GetDeviceRunTimeAsync(long deviceId) - { - return await DeviceService.GetCollectDeviceRuntimeAsync(deviceId).ConfigureAwait(false); - } - - #region 事件通知 - private async Task OnCollectDeviceStarted() - { - try - { - if (Started != null) - await Started.Invoke().ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "OnCollectDeviceStarted warn"); - } - finally - { - started = true; - } - } - - private async Task OnCollectDeviceStarting() - { - try - { - if (Starting != null) - await Starting.Invoke().ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "OnCollectDeviceStarting warn"); - } - - } - - private async Task OnCollectDeviceStoped() - { - try - { - if (Stoped != null) - await Stoped.Invoke().ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "OnCollectDeviceStoped warn"); - } - finally - { - started = false; - } - } - - private async Task OnCollectDeviceStoping() - { - try - { - if (Stoping != null) - await Stoping.Invoke().ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "OnCollectDeviceStoping warn"); - } - - } - - #endregion - -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/DeviceHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/DeviceHostedService.cs deleted file mode 100644 index 18ebd95c3..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/DeviceHostedService.cs +++ /dev/null @@ -1,410 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using BootstrapBlazor.Components; - -using Mapster; - -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging; - -using TouchSocket.Core; -using TouchSocket.SerialPorts; - -namespace ThingsGateway.Gateway.Application; - -/// -/// 设备服务 -/// -internal abstract class DeviceHostedService : BackgroundService, IDeviceHostedService -{ - /// - /// 全部重启锁 - /// - protected readonly WaitLock restartLock = new(); - - /// - /// 单个重启锁 - /// - protected readonly WaitLock singleRestartLock = new(); - - protected ILogger _logger; - /// - /// 在软件关闭时取消 - /// - protected CancellationToken _stoppingToken; - - protected IChannelService ChannelService; - protected IDeviceService DeviceService; - protected IDispatchService DispatchService; - - protected IPluginService PluginService; - public DeviceHostedService() - { - DeviceService = App.RootServices.GetRequiredService(); - ChannelService = App.RootServices.GetRequiredService(); - PluginService = App.RootServices.GetRequiredService(); - Localizer = App.CreateLocalizerByType(typeof(DeviceHostedService))!; - DispatchService = App.RootServices.GetService>(); - } - - /// - public IEnumerable DriverBases => ChannelThreads.SelectMany(a => a.GetDriverEnumerable()).Where(a => a.CurrentDevice != null).OrderByDescending(a => a.CurrentDevice.DeviceStatus); - - /// - /// 设备子线程列表 - /// - protected ConcurrentList ChannelThreads { get; set; } = new(); - - protected IStringLocalizer Localizer { get; } - - /// - public async Task DeviceRedundantThreadAsync(long deviceId) - { - try - { - await singleRestartLock.WaitAsync().ConfigureAwait(false); - - if (!_stoppingToken.IsCancellationRequested) - { - var channelThread = ChannelThreads.FirstOrDefault(it => it.Has(deviceId)) - ?? throw new(Localizer["UpadteDeviceIdNotFound", deviceId]); - //这里先停止采集,操作会使线程取消,需要重新恢复线程 - var dev = channelThread.GetDriver(deviceId).CurrentDevice; - await channelThread.RemoveDriverAsync(deviceId).ConfigureAwait(false); - - if (dev.RedundantEnable) - { - if (dev.RedundantType == RedundantTypeEnum.Standby) - { - var newDev = DeviceService.GetDeviceById(deviceId); - if (dev == null) - { - _logger.LogWarning(Localizer["UpadteDeviceIdNotFound", deviceId]); - } - else - { - //冗余切换时,改变全部属性,但不改变变量信息 - SetRedundantDevice(dev, newDev); - dev.RedundantType = RedundantTypeEnum.Primary; - _logger?.LogInformation(Localizer["DeviceSwtichMain", dev.Name]); - } - } - else - { - try - { - var newDev = DeviceService.GetDeviceById(dev.RedundantDeviceId ?? 0); - if (newDev == null) - { - _logger.LogWarning(Localizer["UpadteDeviceIdNotFound", deviceId]); - } - else - { - SetRedundantDevice(dev, newDev); - dev.RedundantType = RedundantTypeEnum.Standby; - _logger?.LogInformation(Localizer["DeviceSwtichBackup", dev.Name]); - } - } - catch - { - } - } - } - - //初始化 - DriverBase newDriverBase = dev.CreateDriver(PluginService); - var newChannelThread = await GetChannelThreadAsync(newDriverBase).ConfigureAwait(false); - if (newChannelThread != null) - { - await StartChannelThreadAsync(newChannelThread).ConfigureAwait(false); - } - } - } - finally - { - singleRestartLock.Release(); - } - } - - /// - public Type GetDebugUI(string pluginName) - { - var driverPlugin = PluginService.GetDriver(pluginName); - driverPlugin?.SafeDispose(); - return driverPlugin?.DriverDebugUIType; - } - - /// - public List GetDriverMethodInfo(long deviceId) - { - var pluginName = (DeviceService.GetDeviceById(deviceId))?.PluginName; - if (!pluginName.IsNullOrEmpty()) - { - var propertys = PluginService.GetDriverMethodInfos(pluginName); - return propertys; - } - else - { - return new(); - } - } - - /// - public Type GetDriverUI(string pluginName) - { - var driverPlugin = PluginService.GetDriver(pluginName); - driverPlugin?.SafeDispose(); - return driverPlugin?.DriverUIType; - } - - /// - public void PauseThread(long deviceId, bool isStart) - { - if (deviceId == 0) - DriverBases.ForEach(a => a.PauseThread(isStart)); - else - DriverBases.FirstOrDefault(it => it.DeviceId == deviceId)?.PauseThread(isStart); - } - - - - /// - /// 在删除所有通道线程之前执行的操作 - /// - /// 异步任务 - protected async Task BeforeRemoveAllChannelThreadAsync() - { - // 遍历通道线程列表,并在每个通道线程上执行 BeforeStopThread 方法 - ChannelThreads.ParallelForEach((channelThread) => - { - try - { - channelThread.BeforeStopThread(); - } - catch (Exception ex) - { - // 记录执行 BeforeStopThread 方法时的异常信息 - _logger?.LogError(ex, channelThread.ToString()); - } - }); - - // 等待一小段时间,以确保 BeforeStopThread 方法有足够的时间执行 - await Task.Delay(100).ConfigureAwait(false); - } - - protected void DeviceRedundantThread(DeviceRunTime deviceRunTime, DeviceData deviceData) - { - _ = Task.Run(async () => - { - var driverBase = DriverBases.FirstOrDefault(a => a.CurrentDevice.Id == deviceData.Id); - if (driverBase != null) - { - if (driverBase.CurrentDevice.DeviceStatus == DeviceStatusEnum.OffLine && (driverBase.IsInitSuccess == false || driverBase.IsBeforStarted) && !driverBase.DisposedValue) - { - await Task.Delay(10000).ConfigureAwait(false);//10s后再次检测 - if (driverBase.CurrentDevice.DeviceStatus == DeviceStatusEnum.OffLine && (driverBase.IsInitSuccess == false || driverBase.IsBeforStarted) && !driverBase.DisposedValue) - { - //冗余切换 - if (driverBase.CurrentDevice.RedundantEnable && DeviceService.GetAll().Any(a => a.Id == driverBase.CurrentDevice.RedundantDeviceId)) - { - await DeviceRedundantThreadAsync(driverBase.CurrentDevice.Id).ConfigureAwait(false); - } - } - } - } - }); - } - - private WaitLock NewChannelLock = new(); - /// - /// 根据设备生成或获取通道线程管理器 - /// - /// 驱动程序实例 - /// 通道线程管理器 - protected async ValueTask GetChannelThreadAsync(DriverBase driverBase) - { - try - { - var channelId = driverBase.CurrentDevice.ChannelId; - await NewChannelLock.WaitAsync().ConfigureAwait(false); - { - // 尝试从现有的通道线程管理器列表中查找匹配的通道线程 - var channelThread = ChannelThreads.FirstOrDefault(t => t.ChannelId == channelId); - if (channelThread != null) - { - // 如果找到了匹配的通道线程,则将驱动程序添加到该线程中 - channelThread.AddDriver(driverBase); - if (channelThread.Channel != null) - { - await channelThread.Channel.SetupAsync(channelThread.FoundataionConfig?.Clone()).ConfigureAwait(false); - } - return channelThread; - } - - // 如果未找到匹配的通道线程,则创建一个新的通道线程 - return await NewChannelThreadAsync(driverBase, channelId).ConfigureAwait(false); - } - } - catch (Exception ex) - { - driverBase.SafeDispose(); - _logger.LogWarning(ex, nameof(GetChannelThreadAsync)); - return null; - } - finally - { - NewChannelLock.Release(); - } - // 创建新的通道线程的内部方法 - async ValueTask NewChannelThreadAsync(DriverBase driverBase, long channelId) - { - // 根据通道ID获取通道信息 - var channel = ChannelService.GetChannelById(channelId); - if (channel == null) - { - _logger.LogWarning(Localizer["ChannelNotNull", driverBase.CurrentDevice.Name, channelId]); - driverBase.SafeDispose(); - return null; - } - // 检查通道是否启用 - if (!channel.Enable) - { - _logger.LogWarning(Localizer["ChannelNotEnable", driverBase.CurrentDevice.Name, channel.Name]); - driverBase.SafeDispose(); - return null; - } - // 确保通道不为 null - ArgumentNullException.ThrowIfNull(channel); - if (ChannelThreads.Count > ChannelThread.MaxCount) - { - driverBase.SafeDispose(); - throw new Exception($"Exceeded maximum number of channels:{ChannelThread.MaxCount}"); - } - if (DriverBases.Select(a => a.CurrentDevice.VariableRunTimes.Count).Sum() > ChannelThread.MaxVariableCount) - { - driverBase.SafeDispose(); - throw new Exception($"Exceeded maximum number of variables:{ChannelThread.MaxVariableCount}"); - } - - var wts = Math.Max(ChannelThreads.Count, 10) * 10; - ThreadPool.SetMaxThreads(wts, wts); - - - var config = new TouchSocketConfig(); - var log= new LoggerGroup() { LogLevel = TouchSocket.Core.LogLevel.Warning }; - // 配置容器中注册日志记录器实例 - config.ConfigureContainer(a => a.RegisterSingleton(log)); - var ichannel = await config.GetChannelAsync(channel.ChannelType, channel.RemoteUrl, channel.BindUrl, channel.Adapt()).ConfigureAwait(false); - // 创建新的通道线程,并将驱动程序添加到其中 - ChannelThread channelThread = new ChannelThread(channel, config, ichannel, log); - channelThread.AddDriver(driverBase); - if (channelThread.Channel != null) - { - await channelThread.Channel.SetupAsync(channelThread.FoundataionConfig?.Clone()).ConfigureAwait(false); - } - ChannelThreads.Add(channelThread); - return channelThread; - } - } - - /// - /// 删除所有通道线程,并释放资源(可选择同时移除相关设备) - /// - /// 异步任务 - protected async Task RemoveAllChannelThreadAsync(bool removeDevice) - { - // 执行删除所有通道线程前的操作 - await BeforeRemoveAllChannelThreadAsync().ConfigureAwait(false); - - // 并行遍历通道线程列表,并停止每个通道线程 - await ChannelThreads.ParallelForEachAsync(async (channelThread, cancellationToken) => - { - try - { - await channelThread.StopThreadAsync(removeDevice).ConfigureAwait(false); - } - catch (Exception ex) - { - // 记录停止通道线程时的异常信息 - _logger?.LogError(ex, channelThread.ToString()); - } - }, Environment.ProcessorCount / 2).ConfigureAwait(false); - - // 如果指定了同时移除相关设备,则清空通道线程列表 - if (removeDevice) - ChannelThreads.Clear(); - } - - /// - /// 启动所有通道线程 - /// - /// 异步任务 - protected async Task StartAllChannelThreadsAsync() - { - // 检查是否已请求停止,如果没有则开始每个通道线程 - if (!_stoppingToken.IsCancellationRequested) - { - - foreach (var item in ChannelThreads) - { - if (!_stoppingToken.IsCancellationRequested) - { - await StartChannelThreadAsync(item).ConfigureAwait(false); - } - } - } - } - /// - /// 启动通道线程 - /// - /// 要启动的通道线程 - /// 异步任务 - protected virtual async Task StartChannelThreadAsync(ChannelThread item) - { - if (item.IsCollectChannel) - { - // 启动通道线程 - if (GlobalData.CollectDeviceHostedService.StartCollectDeviceEnable) - await item.StartThreadAsync().ConfigureAwait(false); - } - else - { - if (GlobalData.BusinessDeviceHostedService.StartBusinessDeviceEnable) - { - // 启动通道线程 - await item.StartThreadAsync().ConfigureAwait(false); - } - } - } - private static void SetRedundantDevice(DeviceRunTime? dev, Device? newDev) - { - dev.DevicePropertys = newDev.DevicePropertys; - dev.Description = newDev.Description; - dev.ChannelId = newDev.ChannelId; - dev.Enable = newDev.Enable; - dev.IntervalTime = newDev.IntervalTime; - dev.Name = newDev.Name; - dev.PluginName = newDev.PluginName; - } - #region 重写 - - protected abstract Task> GetDeviceRunTimeAsync(long deviceId); - - /// - /// 更新设备线程 - /// - public abstract Task RestartChannelThreadAsync(long deviceId, bool isChanged, bool deleteCache = false); - - #endregion -} - -public delegate Task RestartEventHandler(); diff --git a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/IDeviceHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/IDeviceHostedService.cs deleted file mode 100644 index e7e2d76f2..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/HostService/Device/IDeviceHostedService.cs +++ /dev/null @@ -1,66 +0,0 @@ -// ------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -// ------------------------------------------------------------------------------ - -namespace ThingsGateway.Gateway.Application; - -public interface IDeviceHostedService -{ - /// - /// 驱动列表 - /// - IEnumerable DriverBases { get; } - - /// - /// 更新设备线程,切换为冗余通道 - /// - /// 设备id - /// - Task DeviceRedundantThreadAsync(long deviceId); - - - - /// - /// 获取驱动调试UI - /// - /// 驱动名称 - /// - Type GetDebugUI(string pluginName); - - - /// - /// 获取驱动方法信息 - /// - /// 设备id - /// - List GetDriverMethodInfo(long deviceId); - - /// - /// 获取驱动UI - /// - /// 驱动名称 - /// - Type GetDriverUI(string pluginName); - - /// - /// 暂停控制 - /// - /// 设备id - /// 是否继续 - void PauseThread(long deviceId, bool isStart); - - /// - /// 更新设备线程 - /// - /// 设备Id - /// 是否重新获取组态数据 - /// 删除设备数据缓存 - /// - Task RestartChannelThreadAsync(long deviceId, bool isChanged, bool deleteCache = false); -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Job/LogJob/LogJob.cs b/src/Gateway/ThingsGateway.Gateway.Application/Job/LogJob.cs similarity index 84% rename from src/Gateway/ThingsGateway.Gateway.Application/Job/LogJob/LogJob.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Job/LogJob.cs index f8907dcb9..aa766cbf1 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Job/LogJob/LogJob.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Job/LogJob.cs @@ -8,8 +8,6 @@ // QQ群:605534569 // ------------------------------------------------------------------------------ -using Microsoft.Extensions.Configuration; - using ThingsGateway.NewLife.Extension; using ThingsGateway.Schedule; @@ -24,8 +22,9 @@ public class LogJob : IJob { public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken) { - var rpcLogDaysdaysAgo = App.Configuration.GetSection("LogJob:RpcLogDaysAgo").Get() ?? 30; - var backendLogdaysAgo = App.Configuration.GetSection("LogJob:BackendLogDaysAgo").Get() ?? 30; + var gatewayLogOptions = App.GetOptions(); + var rpcLogDaysdaysAgo = gatewayLogOptions?.RpcLogDaysAgo ?? 30; + var backendLogdaysAgo = gatewayLogOptions?.BackendLogDaysAgo ?? 30; await DeleteRpcLog(rpcLogDaysdaysAgo, stoppingToken).ConfigureAwait(false); await DeleteBackendLog(backendLogdaysAgo, stoppingToken).ConfigureAwait(false); await DeleteTextLog(stoppingToken).ConfigureAwait(false); @@ -47,11 +46,11 @@ public class LogJob : IJob - private static Task DeleteTextLog(CancellationToken stoppingToken) + private static async Task DeleteTextLog(CancellationToken stoppingToken) { //网关通道日志以通道id命名 var channelService = App.RootServices.GetService(); - var channelIds = channelService.GetAll().Select(a => a.Id.ToString()); + var channelIds = (await channelService.GetAllAsync().ConfigureAwait(false)).Select(a => a.Id.ToString()).ToHashSet(); var baseDir = LoggerExtensions.GetLogBasePath(); Directory.CreateDirectory(baseDir); @@ -62,7 +61,7 @@ public class LogJob : IJob { if (stoppingToken.IsCancellationRequested) { - return Task.CompletedTask; + return; } //删除文件夹 try @@ -86,7 +85,7 @@ public class LogJob : IJob { if (stoppingToken.IsCancellationRequested) { - return Task.CompletedTask; + return; } //删除文件夹 try @@ -96,20 +95,19 @@ public class LogJob : IJob catch { } } - return Task.CompletedTask; } - public Task DeleteLocalDB(CancellationToken stoppingToken) + public async Task DeleteLocalDB(CancellationToken stoppingToken) { var deviceService = App.RootServices.GetService(); - var data = deviceService.GetAll().Where(a => a.PluginType == PluginTypeEnum.Business).Select(a => a.Id); + var data = (await deviceService.GetAllAsync().ConfigureAwait(false)).Select(a => a.Id).ToHashSet(); var dir = CacheDBUtil.GetFileBasePath(); string[] dirs = Directory.GetDirectories(dir); foreach (var item in dirs) { if (stoppingToken.IsCancellationRequested) { - return Task.CompletedTask; + return; } //删除文件夹 try @@ -126,7 +124,6 @@ public class LogJob : IJob catch { } } - return Task.CompletedTask; } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Locales/en-US.json b/src/Gateway/ThingsGateway.Gateway.Application/Locales/en-US.json index 729dea586..3815771c4 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Locales/en-US.json +++ b/src/Gateway/ThingsGateway.Gateway.Application/Locales/en-US.json @@ -5,72 +5,25 @@ "IntervalOrChange": "IntervalOrChange" }, - "ThingsGateway.Gateway.Application.ConfigInfoController": { - "ConfigInfoController": "Get configuration information", - "GetChannelList": "Get channel information", - "GetCollectDeviceList": "Get device information", - "GetVariableList": "Get variable information" - }, "ThingsGateway.Gateway.Application.ControlController": { "ControlController": "Device control", - "PauseCollectThread": "Control collection thread start/stop", - "PauseBusinessThread": "Control business thread start/stop", - "RestartCollectDeviceThread": "Restart collection thread", - "RestartBusinessDeviceThread": "Restart business thread", - "WriteDeviceMethods": "Write variables" + "PauseDeviceThreadAsync": "Control device thread start/stop", + "RestartDeviceThreadAsync": "Restart device thread", + "WriteVariablesAsync": "Write variables", + "RemoveAllCache": "Remove all cache", + "RemoveCache": "Remove device/channel Cache", + "RestartAllThread": "Restart all thread" }, "ThingsGateway.Gateway.Application.RuntimeInfoController": { "RuntimeInfoController": "Get runtime information", - "GetCollectDeviceList": "Get device information", + "GetDeviceListAsync": "Get device information", + "GetChannelListAsync": "Get channel information", "GetVariableList": "Get variable information", "GetRealAlarmList": "Get real-time alarm information", "CheckRealAlarm": "Confirm real-time alarm" }, - "ThingsGateway.Gateway.Application.VariableSearchInput": { - "Name": "Name", - "DeviceId": "Device", - "BusinessDeviceId": "BusinessDevice", - "RegisterAddress": "RegisterAddress" - }, - "ThingsGateway.Gateway.Application.RpcLogPageInput": { - "SearchDate": "SearchDate", - "OperateObject": "OperateObject", - "OperateSource": "OperateSource" - }, - "ThingsGateway.Gateway.Application.BackendLogPageInput": { - "SearchDate": "SearchDate", - "LogLevel": "LogLevel", - "LogSource": "LogSource" - }, - "ThingsGateway.Gateway.Application.CollectDeviceRuntime": { - "SourceVariableCount": "SourceVariableCount", - "MethodVariableCount": "MethodVariableCount", - "ActiveTime": "ActiveTime", - "DeviceVariableCount": "DeviceVariableCount", - "LastErrorMessage": "LastErrorMessage", - - "Name": "Name", - "Name.Required": " {0} cannot be empty", - "Description": "Description", - "ChannelId": "Channel", - "ChannelName": "ChannelName", - "IntervalTime": "IntervalTime", - "IntervalTime.MinValue": " {0} value is too small", - - "PluginName": "PluginName", - "Enable": "Enable", - - "RedundantEnable": "RedundantEnable", - "RedundantDeviceId": "RedundantDevice", - - "Remark1": "Remark1", - "Remark2": "Remark2", - "Remark3": "Remark3", - "Remark4": "Remark4", - "Remark5": "Remark5" - }, "ThingsGateway.Gateway.Application.DeviceRuntime": { "SourceVariableCount": "SourceVariableCount", "MethodVariableCount": "MethodVariableCount", @@ -87,6 +40,14 @@ "IntervalTime.MinValue": " {0} value is too small", "PluginName": "PluginName", + "PluginName.Required": "{0} cannot be empty", + + "LogEnable": "LogEnable", + "LogLevel": "LogLevel", + "RedundantSwitchType": "RedundantSwitchType", + "RedundantScanIntervalTime": "RedundantScanIntervalTime", + "RedundantScript": "RedundantScript", + "Enable": "Enable", "RedundantEnable": "RedundantEnable", @@ -96,8 +57,31 @@ "Remark2": "Remark2", "Remark3": "Remark3", "Remark4": "Remark4", - "Remark5": "Remark5" + "Remark5": "Remark5", + + + "RedundantDeviceNotNull": "When enabling redundancy, you must select a redundant device", + "SaveDevice": "Add/Modify Device", + "CopyDevice": "Copy Device", + "DeleteDevice": "Delete Device", + "ClearDevice": "Clear Device", + "ExportDevice": "Export Device", + "ImportDevice": "Import Device", + "ImportNullError": "Unable to recognize", + "RedundantDeviceError": "Redundant device error", + "ChannelError": "Channel error", + "NotNull": "{0} does not exist", + "DeviceNotNull": "Device name does not exist", + "NameDump": "Duplicate device name {0}", + + + + "DeviceStatus": "DeviceStatus", + + "PluginNotNull": "Plugin name does not exist" + }, + "ThingsGateway.Gateway.Application.VariableRuntime": { "ChangeTime": "ChangeTime", "CollectTime": "CollectTime", @@ -123,6 +107,7 @@ "WriteExpressions": "WriteExpressions", "RpcWriteEnable": "RpcWriteEnable", "SaveValue": "SaveValue", + "ArrayLength": "ArrayLength", "AlarmDelay": "AlarmDelay", "BoolOpenAlarmEnable": "BoolOpenAlarmEnable", @@ -168,16 +153,6 @@ "EventType": "EventType" }, - "ThingsGateway.Gateway.Application.DeviceHostedService": { - "ChannelNotNull": "Device {0} - Channel {1} cannot be null", - "ChannelNotEnable": "Device {0} - Channel {1} is not enabled", - "UpadteDeviceIdNotFound": "Failed to update device thread, device with id {0} does not exist", - "DeviceSwtichMain": "Device {0} switched to main channel", - "DeviceSwtichBackup": "Device {0} switched to backup channel", - "DeviceInitFail": "Device {0} initialization failed, restarting thread", - "DeviceTaskDeath": "Device {0} thread died, restarting thread", - "InitError": "{0} creation error" - }, "ThingsGateway.Gateway.Application.BusinessBase": { "IntervalInsertVariableFail": "Interval insert variable failed", "IntervalInsertDeviceFail": "Interval insert device failed", @@ -193,14 +168,7 @@ "MethodSuccess": "{0} - Execute method[{1}] - Succeeded {2}", "WriteExpressionsError": "{0} conversion write expression {1} failed {2}" }, - "ThingsGateway.Gateway.Application.BusinessDeviceHostedService": { - "DeviceRuntimeGeting": "Getting business device configuration information", - "DeviceRuntimeGeted": "Business device configuration information obtained" - }, - "ThingsGateway.Gateway.Application.CollectDeviceHostedService": { - "DeviceRuntimeGeting": "Getting collection device configuration information", - "DeviceRuntimeGeted": "Collection device configuration information obtained" - }, + "ThingsGateway.Gateway.Application.DriverBase": { "DeviceTaskStart": "Device {0} thread started", "DeviceTaskStartTimeout": "Device {0} thread start timeout {1} s", @@ -208,20 +176,17 @@ "DeviceTaskPause": "Device {0} thread paused", "DeviceTaskContinue": "Device {0} thread continued" }, - "ThingsGateway.Gateway.Application.ChannelThread": { - "PluginTypeDiff": "Device {0} and channel {1} have different device types, cannot select the same channel", - "InitFail": "Plugin {0} device {1} initialization failed" - }, - "ThingsGateway.Gateway.Application.DoTask": { - "Timeout": "{0} stop timeout, exiting wait block", - "Error": "{0} stop error", - "Stoping": "{0} stopping", - "Stoped": "{0} stopped" + "ThingsGateway.Gateway.Application.DeviceThreadManage": { + "InitFail": "Plugin {0} device {1} initialization failed", + "ChannelCreate": "channel {0} create", + "ChannelDispose": "channel {0} dispose" + }, + "ThingsGateway.Gateway.Application.AlarmHostedService": { - "RealAlarmTask": "Real-time alarm thread", - "RealAlarmTaskStart": "Real-time alarm thread start" + "RealAlarmTask": "Realtime alarm service", + "RealAlarmTaskStart": "Realtime alarm service start" }, "ThingsGateway.Gateway.Application.PluginService": { "LoadTypeSuccess": "Plugin {0} loaded successfully", @@ -232,7 +197,7 @@ "LoadPluginFail": "Failed to load plugin {0}", "AddPluginFile": "Add plugin file {0}" }, - "ThingsGateway.Gateway.Application.PluginOutput": { + "ThingsGateway.Gateway.Application.PluginInfo": { "FullName": "FullName", "FileName": "FileName", "PluginType": "PluginType", @@ -266,12 +231,6 @@ "DeleteRpc": "DeleteRPCLog" }, - "ThingsGateway.Gateway.Application.DeviceSearchInput": { - "ChannelId": "ChannelId", - "Name": "Name", - "PluginName": "PluginName", - "PluginType": "PluginType" - }, "ThingsGateway.Gateway.Application.Device": { "Name": "Name", "Name.Required": "{0} cannot be empty", @@ -281,12 +240,15 @@ "ChannelId.Required": "{0} cannot be empty", "IntervalTime": "DefaultIntervalTime", "IntervalTime.MinValue": "{0} value is too small", - "PluginType": "PluginType", - "PluginName": "PluginName", - "PluginName.Required": "{0} cannot be empty", + "LogEnable": "LogEnable", + "LogLevel": "LogLevel", "Enable": "Enable", "RedundantEnable": "RedundantEnable", "RedundantDeviceId": "RedundantDevice", + "RedundantSwitchType": "RedundantSwitchType", + "RedundantScanIntervalTime": "RedundantScanIntervalTime", + "RedundantScript": "RedundantScript", + "Remark1": "Remark1", "Remark2": "Remark2", "Remark3": "Remark3", @@ -303,12 +265,18 @@ "RedundantDeviceError": "Redundant device error", "ChannelError": "Channel error", "NotNull": "{0} does not exist", - "DeviceNotNull": "Device name does not exist" + "DeviceNotNull": "Device name does not exist", + "PluginNotNull": "Plugin name does not exist", + "NameDump": "Duplicate device name {0}" }, - "ThingsGateway.Gateway.Application.Channel": { + "ThingsGateway.Gateway.Application.ChannelRuntime": { "Name": "Name", "Name.Required": "{0} cannot be empty", "ChannelType": "ChannelType", + "PluginName": "PluginName", + "PluginName.Required": "{0} cannot be empty", + "LogLevel": "LogLevel", + "Enable": "Enable", "LogEnable": "LogEnable", "RemoteUrl": "RemoteUrl", @@ -320,14 +288,9 @@ "StopBits": "StopBits", "DtrEnable": "DtrEnable", "RtsEnable": "RtsEnable", - "RemoteUrlNotNull": "Remote IP Address cannot be empty", - "BindUrlNotNull": "Local Bind IP Address cannot be empty", - "BindUrlOrRemoteUrlNotNull": "Remote IP Address or Local Bind IP Address cannot be empty", - "PortNameNotNull": "COM Port cannot be empty", - "BaudRateNotNull": "Baud Rate cannot be empty", - "DataBitsNotNull": "Data Bits can be empty", - "ParityNotNull": "Parity cannot be empty", - "StopBitsNotNull": "Stop Bits cannot be empty", + "CacheTimeout": "CacheTimeout", + "ConnectTimeout": "ConnectTimeout", + "MaxConcurrentCount": "MaxConcurrentCount", "SaveChannel": "Add/Modify Channel", "DeleteChannel": "Delete Channel", "ClearChannel": "Clear Channel", @@ -338,7 +301,41 @@ "Connect": "Connect", "Confim": "Confim", "Disconnect": "Disconnect", - "Channel": "Channel" + "Channel": "Channel", + "NameDump": "Duplicate channel name {0}" + }, + "ThingsGateway.Gateway.Application.Channel": { + "Name": "Name", + "Name.Required": "{0} cannot be empty", + "ChannelType": "ChannelType", + "PluginName": "PluginName", + "PluginName.Required": "{0} cannot be empty", + "LogLevel": "LogLevel", + + "Enable": "Enable", + "LogEnable": "LogEnable", + "RemoteUrl": "RemoteUrl", + "BindUrl": "BindUrl", + "PortName": "PortName", + "BaudRate": "BaudRate", + "DataBits": "DataBits", + "Parity": "Parity", + "StopBits": "StopBits", + "DtrEnable": "DtrEnable", + "RtsEnable": "RtsEnable", + + "SaveChannel": "Add/Modify Channel", + "DeleteChannel": "Delete Channel", + "ClearChannel": "Clear Channel", + "ExportChannel": "Export Channel", + "ImportChannel": "Import Channel", + "ImportNullError": "Unable to recognize", + "NotOther": "Not supporting other channel types", + "Connect": "Connect", + "Confim": "Confim", + "Disconnect": "Disconnect", + "Channel": "Channel", + "NameDump": "Duplicate channel name {0}" }, "ThingsGateway.Gateway.Application.Variable": { "AddressOrOtherMethodNotNull": "Variable address or special method cannot be empty at the same time", @@ -401,7 +398,20 @@ "Remark2": "Remark2", "Remark3": "Remark3", "Remark4": "Remark4", - "Remark5": "Remark5" + "Remark5": "Remark5", + "NameDump": "Duplicate variable name {0}", + "PluginNotNull": "Plugin name does not exist", + "ArrayLength": "ArrayLength" + }, + "ThingsGateway.Gateway.Application.RpcLogPageInput": { + "SearchDate": "SearchDate", + "OperateObject": "OperateObject", + "OperateSource": "OperateSource" + }, + "ThingsGateway.Gateway.Application.BackendLogPageInput": { + "SearchDate": "SearchDate", + "LogLevel": "LogLevel", + "LogSource": "LogSource" }, "ThingsGateway.Gateway.Application.RpcService": { "VariableNotNull": "{0} variable does not exist", diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Locales/zh-CN.json b/src/Gateway/ThingsGateway.Gateway.Application/Locales/zh-CN.json index 6aa6171cd..2e069023f 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Locales/zh-CN.json +++ b/src/Gateway/ThingsGateway.Gateway.Application/Locales/zh-CN.json @@ -4,86 +4,32 @@ "Interval": "定时", "IntervalOrChange": "定时或变化" }, - "ThingsGateway.Gateway.Application.ConfigInfoController": { - "ConfigInfoController": "获取配置信息", - "GetChannelList": "获取通道信息", - "GetCollectDeviceList": "获取设备信息", - "GetVariableList": "获取变量信息" - }, + "ThingsGateway.Gateway.Application.ControlController": { "ControlController": "设备控制", - "PauseCollectThread": "控制采集线程启停", - "PauseBusinessThread": "控制业务线程启停", - "RestartCollectDeviceThread": "重启采集线程", - "RestartBusinessDeviceThread": "重启业务线程", - "WriteDeviceMethods": "写入变量" + "RemoveAllCache": "清空全部缓存", + "RemoveCache": "删除通道/设备缓存", + "PauseDeviceThreadAsync": "控制设备线程启停", + "RestartAllThread": "重启全部线程", + "RestartDeviceThreadAsync": "重启设备线程", + "WriteVariablesAsync": "写入变量" }, "ThingsGateway.Gateway.Application.RuntimeInfoController": { "RuntimeInfoController": "获取运行态信息", - "GetCollectDeviceList": "获取设备信息", + "GetChannelListAsync": "获取通道信息", + "GetDeviceListAsync": "获取设备信息", "GetVariableList": "获取变量信息", "GetRealAlarmList": "获取实时报警信息", "CheckRealAlarm": "确认实时报警" }, - "ThingsGateway.Gateway.Application.VariableSearchInput": { - "Name": "变量名称", - "DeviceId": "采集设备", - "BusinessDeviceId": "业务设备", - "RegisterAddress": "寄存器地址" - }, - - "ThingsGateway.Gateway.Application.CollectDeviceRuntime": { - "SourceVariableCount": "打包数量", - "MethodVariableCount": "方法数量", - "ActiveTime": "活跃时间", - "DeviceVariableCount": "变量数量", - "LastErrorMessage": "离线原因", - - "Name": "名称", - "Name.Required": " {0} 不可为空", - "Description": "描述", - "ChannelId": "通道", - "ChannelName": "通道", - "ChannelId.MinValue": " {0} 不可为空", - "ChannelId.Required": " {0} 不可为空", - "IntervalTime": "默认执行间隔", - "IntervalTime.MinValue": " {0} 值太小", - - "PluginName": "插件名称", - "PluginName.Required": " {0} 不可为空", - "Enable": "设备使能", - - "RedundantEnable": "启用冗余", - "RedundantDeviceId": "冗余设备", - - "Remark1": "备用1", - "Remark2": "备用2", - "Remark3": "备用3", - "Remark4": "备用4", - "Remark5": "备用5", - - "RedundantDeviceNotNull": "启用冗余时,必须选择备用设备", - - "SaveDevice": "添加/修改设备", - "CopyDevice": "复制设备", - "DeleteDevice": "删除设备", - "ClearDevice": "清空设备", - "ExportDevice": "导出设备", - "ImportDevice": "导入设备", - - "ImportNullError": "无法识别", - "RedundantDeviceError": "冗余设备错误", - "ChannelError": "通道错误", - "NotNull": " {0} 不存在", - "DeviceNotNull": "设备名称不存在" - }, "ThingsGateway.Gateway.Application.DeviceRuntime": { "SourceVariableCount": "打包数量", "MethodVariableCount": "方法数量", "ActiveTime": "活跃时间", "DeviceVariableCount": "变量数量", "LastErrorMessage": "离线原因", + "Name": "名称", "Name.Required": " {0} 不可为空", "Description": "描述", @@ -93,14 +39,17 @@ "ChannelId.Required": " {0} 不可为空", "IntervalTime": "默认执行间隔", "IntervalTime.MinValue": " {0} 值太小", - "PluginName": "插件名称", - "PluginName.Required": " {0} 不可为空", - "Enable": "设备使能", + "PluginName.Required": "{0} 插件名称不可为空", + "Enable": "设备使能", + "LogEnable": "启用调试日志", + "LogLevel": "日志等级", "RedundantEnable": "启用冗余", "RedundantDeviceId": "冗余设备", - + "RedundantSwitchType": "冗余操作模式", + "RedundantScanIntervalTime": "冗余检测时间", + "RedundantScript": "冗余检测脚本", "Remark1": "备用1", "Remark2": "备用2", "Remark3": "备用3", @@ -116,11 +65,14 @@ "ExportDevice": "导出设备", "ImportDevice": "导入设备", + "DeviceStatus": "设备状态", + "ImportNullError": "无法识别", "RedundantDeviceError": "冗余设备错误", "ChannelError": "通道错误", "NotNull": " {0} 不存在", - "DeviceNotNull": "设备名称不存在" + "DeviceNotNull": "设备名称不存在", + "PluginNotNull": "插件不存在" }, "ThingsGateway.Gateway.Application.VariableRuntime": { "ChangeTime": "变化时间", @@ -142,6 +94,7 @@ "IntervalTime": "执行间隔", "IntervalTime.MinValue": " {0} 值太小", + "ArrayLength": "数组长度", "Enable": "变量使能", "ProtectType": "读写权限", "DataType": "数据类型", @@ -203,17 +156,6 @@ "EventType": "事件类型" }, - "ThingsGateway.Gateway.Application.DeviceHostedService": { - "ChannelNotNull": "设备 {0} -通道 {1} 不能为空", - "ChannelNotEnable": "设备 {0} -通道 {1} 未启用", - "UpadteDeviceIdNotFound": "更新设备线程失败,不存在id为 {0} 的设备", - "DeviceSwtichMain": "设备 {0} 切换到主通道", - "DeviceSwtichBackup": "设备 {0} 切换到备用通道", - "DeviceInitFail": "设备 {0} 初始化失败,重启线程", - "DeviceTaskDeath": "设备 {0} 线程假死,重启线程", - "InitError": " {0} 创建错误" - }, - "ThingsGateway.Gateway.Application.BusinessBase": { "IntervalInsertVariableFail": "间隔上传变量失败", "IntervalInsertDeviceFail": "间隔上传设备失败", @@ -229,14 +171,6 @@ "MethodSuccess": "{0} - 执行方法[{1}] - 成功 {2}", "WriteExpressionsError": " {0} 转换写入表达式 {1} 失败 {2} " }, - "ThingsGateway.Gateway.Application.BusinessDeviceHostedService": { - "DeviceRuntimeGeting": "正在获取业务设备组态信息", - "DeviceRuntimeGeted": "已获取业务设备组态信息" - }, - "ThingsGateway.Gateway.Application.CollectDeviceHostedService": { - "DeviceRuntimeGeting": "正在获取采集设备组态信息", - "DeviceRuntimeGeted": "已获取采集设备组态信息" - }, "ThingsGateway.Gateway.Application.DriverBase": { "DeviceTaskStart": "设备 {0} 线程开始", @@ -245,21 +179,16 @@ "DeviceTaskPause": "设备 {0} 线程暂停", "DeviceTaskContinue": "设备 {0} 线程继续" }, - "ThingsGateway.Gateway.Application.ChannelThread": { - "PluginTypeDiff": "设备 {0} 与通道 {1} 的其他设备类型不相同,不能选择同一个通道", - "InitFail": "插件 {0} 设备 {1} 初始化失败" - }, - "ThingsGateway.Gateway.Application.DoTask": { - "Timeout": " {0} 停止超时,退出等待阻塞", - "Error": "{0} 停止错误", - "Stoping": "{0} 停止中", - "Stoped": "{0} 已停止" + "ThingsGateway.Gateway.Application.DeviceThreadManage": { + "InitFail": "插件 {0} 设备 {1} 初始化失败", + "ChannelCreate": "通道 {0} 创建", + "ChannelDispose": "通道 {0} 销毁" }, "ThingsGateway.Gateway.Application.AlarmHostedService": { - "RealAlarmTask": "实时报警线程", - "RealAlarmTaskStart": "实时报警线程启动" + "RealAlarmTask": "实时报警服务", + "RealAlarmTaskStart": "实时报警服务启动" }, "ThingsGateway.Gateway.Application.PluginService": { "LoadTypeSuccess": "加载插件 {0} 成功", @@ -270,7 +199,7 @@ "LoadPluginFail": "加载插件 {0} 失败", "AddPluginFile": "添加插件文件 {0}" }, - "ThingsGateway.Gateway.Application.PluginOutput": { + "ThingsGateway.Gateway.Application.PluginInfo": { "FullName": "插件全名称", "FileName": "插件文件", "PluginType": "插件类型", @@ -308,13 +237,6 @@ "DeleteRpc": "删除RPC日志" }, - "ThingsGateway.Gateway.Application.DeviceSearchInput": { - "ChannelId": "通道", - "Name": "名称", - "PluginName": "插件名称", - "PluginType": "插件类型" - }, - "ThingsGateway.Gateway.Application.Device": { "Name": "名称", "Name.Required": " {0} 不可为空", @@ -324,14 +246,15 @@ "ChannelId.Required": " {0} 不可为空", "IntervalTime": "默认执行间隔", "IntervalTime.MinValue": " {0} 值太小", - - "PluginType": "插件类型", - "PluginName": "插件名称", - "PluginName.Required": " {0} 不可为空", + "LogEnable": "启用调试日志", + "LogLevel": "日志等级", "Enable": "设备使能", "RedundantEnable": "启用冗余", "RedundantDeviceId": "冗余设备", + "RedundantSwitchType": "冗余操作模式", + "RedundantScanIntervalTime": "冗余检测时间", + "RedundantScript": "冗余检测脚本", "Remark1": "备用1", "Remark2": "备用2", @@ -352,15 +275,19 @@ "RedundantDeviceError": "冗余设备错误", "ChannelError": "通道错误", "NotNull": " {0} 不存在", - "DeviceNotNull": "设备名称不存在" + "DeviceNotNull": "设备名称不存在", + "PluginNotNull": "插件不存在", + "NameDump": "设备名称 {0} 重复" }, - - "ThingsGateway.Gateway.Application.Channel": { + "ThingsGateway.Gateway.Application.ChannelRuntime": { "Name": "名称", "Name.Required": " {0} 不可为空", "ChannelType": "通道类型", "Enable": "启用", "LogEnable": "启用调试日志", + "LogLevel": "日志等级", + "PluginName": "插件名称", + "PluginName.Required": "{0} 插件名称不可为空", "RemoteUrl": "远程IP地址", "BindUrl": "本地绑定IP地址", @@ -372,15 +299,9 @@ "StopBits": "停止位", "DtrEnable": "Dtr", "RtsEnable": "Rts", - - "RemoteUrlNotNull": "远程IP地址不可为空", - "BindUrlNotNull": "本地绑定IP地址不可为空", - "BindUrlOrRemoteUrlNotNull": "远程IP地址或本地绑定IP地址不可为空", - "PortNameNotNull": "COM口不可为空", - "BaudRateNotNull": "波特率不可为空", - "DataBitsNotNull": "数据位可为空", - "ParityNotNull": "校验位不可为空", - "StopBitsNotNull": "停止位不可为空", + "CacheTimeout": "接收缓存超时", + "ConnectTimeout": "连接超时", + "MaxConcurrentCount": "最大并发数", "SaveChannel": "添加/修改通道", "DeleteChannel": "删除通道", @@ -394,7 +315,50 @@ "Connect": "连接", "Confim": "创建", "Disconnect": "断开", - "Channel": "通道" + "Channel": "通道", + "NameDump": "通道名称 {0} 重复", + + "DeviceRuntimeCounts": "设备数量" + }, + + "ThingsGateway.Gateway.Application.Channel": { + "Name": "名称", + "Name.Required": " {0} 不可为空", + "ChannelType": "通道类型", + "Enable": "启用", + "LogEnable": "启用调试日志", + "PluginName": "插件名称", + "PluginName.Required": "{0} 插件名称不可为空", + "LogLevel": "日志等级", + + "RemoteUrl": "远程IP地址", + "BindUrl": "本地绑定IP地址", + + "PortName": "COM口", + "BaudRate": "波特率", + "DataBits": "数据位", + "Parity": "校验位", + "StopBits": "停止位", + "DtrEnable": "Dtr", + "RtsEnable": "Rts", + "CacheTimeout": "接收缓存超时", + "ConnectTimeout": "连接超时", + "MaxConcurrentCount": "最大并发数", + + "SaveChannel": "添加/修改通道", + "DeleteChannel": "删除通道", + "ClearChannel": "清空通道", + "ExportChannel": "导出通道", + "ImportChannel": "导入通道", + + "ImportNullError": "无法识别", + + "NotOther": "不支持其他通道类型", + "Connect": "连接", + "Confim": "创建", + "Disconnect": "断开", + "Channel": "通道", + "NameDump": "通道名称 {0} 重复" }, "ThingsGateway.Gateway.Application.Variable": { @@ -408,6 +372,7 @@ "NotNull": " {0} 不存在", "DeviceNotNull": "设备名称不存在", + "PluginNotNull": "插件不存在", "VariableNotNull": "变量名称不存在", "Name": "名称", @@ -418,6 +383,7 @@ "DeviceId.Required": " {0} 不可为空", "IntervalTime": "执行间隔", "IntervalTime.MinValue": " {0} 值太小", + "ArrayLength": "数组长度", "Enable": "变量使能", "ProtectType": "读写权限", @@ -470,8 +436,11 @@ "Remark2": "备用2", "Remark3": "备用3", "Remark4": "备用4", - "Remark5": "备用5" + "Remark5": "备用5", + + "NameDump": "变量名称 {0} 重复" }, + "ThingsGateway.Gateway.Application.RpcLogPageInput": { "SearchDate": "时间区间", "OperateObject": "操作对象", diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Locales/zh-TW.json b/src/Gateway/ThingsGateway.Gateway.Application/Locales/zh-TW.json deleted file mode 100644 index 3573fca74..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/Locales/zh-TW.json +++ /dev/null @@ -1,465 +0,0 @@ -{ - "ThingsGateway.Gateway.Application.BusinessUpdateEnum": { - "Change": "變化", - "Interval": "定時", - "IntervalOrChange": "定時或變化" - }, - "ThingsGateway.Gateway.Application.ConfigInfoController": { - "ConfigInfoController": "獲取配置資訊", - "GetChannelList": "獲取通道資訊", - "GetCollectDeviceList": "獲取設備資訊", - "GetVariableList": "獲取變量資訊" - }, - "ThingsGateway.Gateway.Application.ControlController": { - "ControlController": "設備控制", - "PauseCollectThread": "控制採集線程啟停", - "PauseBusinessThread": "控制業務線程啟停", - "RestartCollectDeviceThread": "重启採集線程", - "RestartBusinessDeviceThread": "重启業務線程", - "WriteDeviceMethods": "寫入變量" - }, - "ThingsGateway.Gateway.Application.RuntimeInfoController": { - "RuntimeInfoController": "獲取運行態信息", - "GetCollectDeviceList": "獲取設備信息", - "GetVariableList": "獲取變量信息", - "GetRealAlarmList": "獲取實時警報信息", - "CheckRealAlarm": "確認實時警報" - }, - - "ThingsGateway.Gateway.Application.VariableSearchInput": { - "Name": "變數名稱", - "DeviceId": "採集設備", - "BusinessDeviceId": "業務設備", - "RegisterAddress": "暫存器地址" - }, - - "ThingsGateway.Gateway.Application.CollectDeviceRuntime": { - "SourceVariableCount": "打包數量", - "MethodVariableCount": "方法數量", - "ActiveTime": "活動時間", - "DeviceVariableCount": "變數數量", - "LastErrorMessage": "離線原因", - "Name": "名稱", - "Name.Required": " {0} 不可為空", - "Description": "描述", - "ChannelId": "通道", - "ChannelName": "通道", - "ChannelId.MinValue": " {0} 不可為空", - "ChannelId.Required": " {0} 不可為空", - "IntervalTime": "預設執行間隔", - "IntervalTime.MinValue": " {0} 值太小", - "PluginName": "插件名稱", - "PluginName.Required": " {0} 不可為空", - "Enable": "設備啟用", - "RedundantEnable": "啟用冗餘", - "RedundantDeviceId": "冗餘設備", - "Remark1": "備用1", - "Remark2": "備用2", - "Remark3": "備用3", - "Remark4": "備用4", - "Remark5": "備用5", - "RedundantDeviceNotNull": "啟用冗餘時,必須選擇備用設備", - "SaveDevice": "新增/修改設備", - "CopyDevice": "複製設備", - "DeleteDevice": "刪除設備", - "ClearDevice": "清空設備", - "ExportDevice": "匯出設備", - "ImportDevice": "匯入設備", - "ImportNullError": "無法識別", - "RedundantDeviceError": "冗餘設備錯誤", - "ChannelError": "通道錯誤", - "NotNull": " {0} 不存在", - "DeviceNotNull": "設備名稱不存在" - }, - - "ThingsGateway.Gateway.Application.DeviceRuntime": { - "SourceVariableCount": "打包數量", - "MethodVariableCount": "方法數量", - "ActiveTime": "活動時間", - "DeviceVariableCount": "變數數量", - "LastErrorMessage": "離線原因", - "Name": "名稱", - "Name.Required": " {0} 不可為空", - "Description": "描述", - "ChannelId": "通道", - "ChannelName": "通道", - "ChannelId.MinValue": " {0} 不可為空", - "ChannelId.Required": " {0} 不可為空", - "IntervalTime": "預設執行間隔", - "IntervalTime.MinValue": " {0} 值太小", - "PluginName": "插件名稱", - "PluginName.Required": " {0} 不可為空", - "Enable": "設備啟用", - "RedundantEnable": "啟用冗餘", - "RedundantDeviceId": "冗餘設備", - "Remark1": "備用1", - "Remark2": "備用2", - "Remark3": "備用3", - "Remark4": "備用4", - "Remark5": "備用5", - "RedundantDeviceNotNull": "啟用冗餘時,必須選擇備用設備", - "SaveDevice": "新增/修改設備", - "CopyDevice": "複製設備", - "DeleteDevice": "刪除設備", - "ClearDevice": "清空設備", - "ExportDevice": "匯出設備", - "ImportDevice": "匯入設備", - "ImportNullError": "無法識別", - "RedundantDeviceError": "冗餘設備錯誤", - "ChannelError": "通道錯誤", - "NotNull": " {0} 不存在", - "DeviceNotNull": "設備名稱不存在" - }, - - "ThingsGateway.Gateway.Application.VariableRuntime": { - "ChangeTime": "變化時間", - "CollectTime": "採集時間", - "DeviceName": "設備名稱", - "IsOnline": "上線", - "LastErrorMessage": "離線原因", - "LastSetValue": "上次值", - "RawValue": "原始值", - "Value": "即時值", - "AlarmEnable": "警報啟用", - "Name": "名稱", - "Name.Required": " {0} 不可為空", - "Description": "描述", - "DeviceId": "採集設備", - "DeviceId.MinValue": " {0} 不可為空", - "DeviceId.Required": " {0} 不可為空", - "IntervalTime": "執行間隔", - "IntervalTime.MinValue": " {0} 值太小", - "Enable": "變數啟用", - "ProtectType": "讀寫權限", - "DataType": "資料類型", - "ReadExpressions": "讀取表達式", - "WriteExpressions": "寫入表達式", - "RpcWriteEnable": "遠端寫入", - "SaveValue": "保存初始值", - "AlarmDelay": "警報延遲", - "BoolOpenAlarmEnable": "布林開警報啟用", - "BoolOpenRestrainExpressions": "布林開警報約束", - "BoolOpenAlarmText": "布林開警報文字", - "BoolCloseAlarmEnable": "布林關警報啟用", - "BoolCloseRestrainExpressions": "布林關警報約束", - "BoolCloseAlarmText": "布林關警報文字", - "HAlarmEnable": "高報啟用", - "HRestrainExpressions": "高報約束", - "HAlarmText": "高報文字", - "HAlarmCode": "高限值", - "HHAlarmEnable": "高高報啟用", - "HHRestrainExpressions": "高高報約束", - "HHAlarmText": "高高報文字", - "HHAlarmCode": "高高限值", - "LAlarmEnable": "低報啟用", - "LRestrainExpressions": "低報約束", - "LAlarmText": "低報文字", - "LAlarmCode": "低限值", - "LLAlarmEnable": "低低報啟用", - "LLRestrainExpressions": "低低報約束", - "LLAlarmText": "低低報文字", - "LLAlarmCode": "低低限值", - "CustomAlarmEnable": "自訂警報啟用", - "CustomRestrainExpressions": "自訂警報約束", - "CustomAlarmText": "自訂警報文字", - "CustomAlarmCode": "自訂警報限值", - "Unit": "單位", - "RegisterAddress": "變數地址", - "OtherMethod": "特殊方法", - "Remark1": "備用1", - "Remark2": "備用2", - "Remark3": "備用3", - "Remark4": "備用4", - "Remark5": "備用5", - "AlarmCode": "警報值", - "AlarmLimit": "警報限值", - "AlarmText": "警報文字", - "AlarmTime": "警報時間", - "AlarmType": "警報類型", - "EventTime": "事件時間", - "EventType": "事件類型" - }, - - "ThingsGateway.Gateway.Application.DeviceHostedService": { - "ChannelNotNull": "設備 {0} -通道 {1} 不能為空", - "ChannelNotEnable": "設備 {0} -通道 {1} 未啟用", - "UpadteDeviceIdNotFound": "更新設備執行緒失敗,不存在id為 {0} 的設備", - "DeviceSwtichMain": "設備 {0} 切換到主通道", - "DeviceSwtichBackup": "設備 {0} 切換到備用通道", - "DeviceInitFail": "設備 {0} 初始化失敗,重啟執行緒", - "DeviceTaskDeath": "設備 {0} 執行緒假死,重啟執行緒", - "InitError": " {0} 創建錯誤" - }, - - "ThingsGateway.Gateway.Application.BusinessBase": { - "IntervalInsertVariableFail": "間隔上傳變數失敗", - "IntervalInsertDeviceFail": "間隔上傳設備失敗", - "IntervalInsertAlarmFail": "間隔上傳警報失敗" - }, - - "ThingsGateway.Gateway.Application.CollectBase": { - "VariablePackError": "變數打包失敗 {0} ", - "GetMethodError": "插件方法初始化失敗 {0} ", - "MethodNotNull": "特殊方法變數 {0} 找不到執行方法 {1},請檢查現有方法列表", - "CollectFail": "{0} - 採集[{1} - {2}] 資料失敗 {3}", - "CollectSuccess": "{0} - 採集[{1} - {2}] 資料成功 {3}", - "MethodFail": "{0} - 執行方法[{1}] - 失敗 {2}", - "MethodSuccess": "{0} - 執行方法[{1}] - 成功 {2}", - "WriteExpressionsError": " {0} 轉換寫入表達式 {1} 失敗 {2} " - }, - - "ThingsGateway.Gateway.Application.BusinessDeviceHostedService": { - "DeviceRuntimeGeting": "正在獲取業務設備組態資訊", - "DeviceRuntimeGeted": "已獲取業務設備組態資訊" - }, - - "ThingsGateway.Gateway.Application.CollectDeviceHostedService": { - "DeviceRuntimeGeting": "正在獲取採集設備組態資訊", - "DeviceRuntimeGeted": "已獲取採集設備組態資訊" - }, - - "ThingsGateway.Gateway.Application.DriverBase": { - "DeviceTaskStart": "設備 {0} 執行緒開始", - "DeviceTaskStartTimeout": "設備 {0} 執行緒啟動超時 {1} 秒", - "DeviceTaskStop": "設備 {0} 執行緒停止", - "DeviceTaskPause": "設備 {0} 執行緒暫停", - "DeviceTaskContinue": "設備 {0} 執行緒繼續" - }, - - "ThingsGateway.Gateway.Application.ChannelThread": { - "PluginTypeDiff": "設備 {0} 與通道 {1} 的其他設備類型不相同,不能選擇同一個通道", - "InitFail": "插件 {0} 設備 {1} 初始化失敗" - }, - - "ThingsGateway.Gateway.Application.DoTask": { - "Timeout": " {0} 停止超時,退出等待阻塞", - "Error": "{0} 停止錯誤", - "Stoping": "{0} 停止中", - "Stoped": "{0} 已停止" - }, - - "ThingsGateway.Gateway.Application.AlarmHostedService": { - "RealAlarmTask": "即時警報執行緒", - "RealAlarmTaskStart": "即時警報執行緒啟動" - }, - - "ThingsGateway.Gateway.Application.PluginService": { - "LoadTypeSuccess": "載入插件 {0} 成功", - "LoadTypeFail1": "載入插件 {0} 失敗,插件類型不存在", - "LoadTypeFail2": "載入插件檔案 {0} 失敗,檔案不存在", - "PluginNotFound": "沒有發現插件類型", - "LoadOtherFileFail": "嘗試載入附屬程式集 {0} 失敗,如果此程式集為DllImport,可以忽略此警告。", - "LoadPluginFail": "載入插件 {0} 失敗", - "AddPluginFile": "添加插件檔案 {0}" - }, - - "ThingsGateway.Gateway.Application.PluginOutput": { - "FullName": "插件全名稱", - "FileName": "插件檔案", - "PluginType": "插件類型", - "Name": "插件名稱", - "DeviceCount": "關聯設備數量", - "Version": "插件版本", - "LastWriteTime": "插件編譯時間" - }, - - "ThingsGateway.Gateway.Application.PluginAddInput": { - "MainFile": "主程式集", - "OtherFiles": "附屬程式集,可上傳多個", - "SavePlugin": "添加/修改插件" - }, - - "ThingsGateway.Gateway.Application.BackendLog": { - "LogTime": "時間", - "LogLevel": "日誌級別", - "LogSource": "日誌來源", - "LogMessage": "具體消息", - "Exception": "異常對象", - "DeleteBackendLog": "刪除後台日誌" - }, - - "ThingsGateway.Gateway.Application.RpcLog": { - "LogTime": "時間", - "OperateSource": "操作源", - "OperateObject": "操作對象", - "OperateMethod": "RPC方法", - "IsSuccess": "操作結果", - "ParamJson": "請求參數", - "ResultJson": "返回結果", - "OperateMessage": "具體消息", - "DeleteRpc": "刪除RPC日誌" - }, - "ThingsGateway.Gateway.Application.DeviceSearchInput": { - "ChannelId": "通道", - "Name": "名稱", - "PluginName": "插件名稱", - "PluginType": "插件類型" - }, - "ThingsGateway.Gateway.Application.Device": { - "Name": "名稱", - "Name.Required": " {0} 不可為空", - "Description": "描述", - "ChannelId": "通道", - "ChannelId.MinValue": " {0} 不可為空", - "ChannelId.Required": " {0} 不可為空", - "IntervalTime": "預設執行間隔", - "IntervalTime.MinValue": " {0} 值太小", - "PluginType": "插件類型", - "PluginName": "插件名稱", - "PluginName.Required": " {0} 不可為空", - "Enable": "設備啟用", - "RedundantEnable": "啟用冗餘", - "RedundantDeviceId": "冗餘設備", - "Remark1": "備用1", - "Remark2": "備用2", - "Remark3": "備用3", - "Remark4": "備用4", - "Remark5": "備用5", - "RedundantDeviceNotNull": "啟用冗餘時,必須選擇備用設備", - "SaveDevice": "新增/修改設備", - "CopyDevice": "複製設備", - "DeleteDevice": "刪除設備", - "ClearDevice": "清空設備", - "ExportDevice": "匯出設備", - "ImportDevice": "匯入設備", - "ImportNullError": "無法識別", - "RedundantDeviceError": "冗餘設備錯誤", - "ChannelError": "通道錯誤", - "NotNull": " {0} 不存在", - "DeviceNotNull": "設備名稱不存在" - }, - - "ThingsGateway.Gateway.Application.Channel": { - "Name": "名稱", - "Name.Required": " {0} 不可為空", - "ChannelType": "通道類型", - "Enable": "啟用", - "LogEnable": "啟用調試日誌", - "RemoteUrl": "遠端IP地址", - "BindUrl": "本地綁定IP地址", - "PortName": "COM口", - "BaudRate": "傳輸速率", - "DataBits": "資料位", - "Parity": "檢驗位", - "StopBits": "停止位", - "DtrEnable": "Dtr", - "RtsEnable": "Rts", - "RemoteUrlNotNull": "遠端IP地址不可為空", - "BindUrlNotNull": "本地綁定IP地址不可為空", - "BindUrlOrRemoteUrlNotNull": "遠端IP地址或本地綁定IP地址不可為空", - "PortNameNotNull": "COM口不可為空", - "BaudRateNotNull": "傳輸速率不可為空", - "DataBitsNotNull": "資料位可為空", - "ParityNotNull": "檢驗位不可為空", - "StopBitsNotNull": "停止位不可為空", - "SaveChannel": "新增/修改通道", - "DeleteChannel": "刪除通道", - "ClearChannel": "清空通道", - "ExportChannel": "匯出通道", - "ImportChannel": "匯入通道", - "ImportNullError": "無法識別", - "NotOther": "不支援其他通道類型", - "Connect": "連接", - "Confim": "創建", - "Disconnect": "斷開", - "Channel": "通道" - }, - - "ThingsGateway.Gateway.Application.Variable": { - "AddressOrOtherMethodNotNull": " 變數地址或特殊方法不能同時為空 ", - "SaveVariable": "新增/修改變數", - "CopyVariable": "複製變數", - "DeleteVariable": "刪除變數", - "ClearVariable": "清空變數", - "ExportVariable": "匯出變數", - "ImportVariable": "匯入變數", - "NotNull": " {0} 不存在", - "DeviceNotNull": "設備名稱不存在", - "VariableNotNull": "變數名稱不存在", - "Name": "名稱", - "Name.Required": " {0} 不可為空", - "Description": "描述", - "DeviceId": "採集設備", - "DeviceId.MinValue": " {0} 不可為空", - "DeviceId.Required": " {0} 不可為空", - "IntervalTime": "執行間隔", - "IntervalTime.MinValue": " {0} 值太小", - "Enable": "變數啟用", - "ProtectType": "讀寫權限", - "DataType": "資料類型", - "ReadExpressions": "讀取表達式", - "WriteExpressions": "寫入表達式", - "RpcWriteEnable": "遠端寫入", - "SaveValue": "保存初始值", - "Value": "初始值", - "AlarmDelay": "警報延遲", - "BoolOpenAlarmEnable": "布林開警報啟用", - "BoolOpenRestrainExpressions": "布林開警報約束", - "BoolOpenAlarmText": "布林開警報文字", - "BoolCloseAlarmEnable": "布林關警報啟用", - "BoolCloseRestrainExpressions": "布林關警報約束", - "BoolCloseAlarmText": "布林關警報文字", - "HAlarmEnable": "高報啟用", - "HRestrainExpressions": "高報約束", - "HAlarmText": "高報文字", - "HAlarmCode": "高限值", - "HHAlarmEnable": "高高報啟用", - "HHRestrainExpressions": "高高報約束", - "HHAlarmText": "高高報文字", - "HHAlarmCode": "高高限值", - "LAlarmEnable": "低報啟用", - "LRestrainExpressions": "低報約束", - "LAlarmText": "低報文字", - "LAlarmCode": "低限值", - "LLAlarmEnable": "低低報啟用", - "LLRestrainExpressions": "低低報約束", - "LLAlarmText": "低低報文字", - "LLAlarmCode": "低低限值", - "CustomAlarmEnable": "自訂警報啟用", - "CustomRestrainExpressions": "自訂警報約束", - "CustomAlarmText": "自訂警報文字", - "CustomAlarmCode": "自訂警報限值", - "Unit": "單位", - "RegisterAddress": "變數地址", - "OtherMethod": "特殊方法", - "Remark1": "備用1", - "Remark2": "備用2", - "Remark3": "備用3", - "Remark4": "備用4", - "Remark5": "備用5" - }, - - "ThingsGateway.Gateway.Application.RpcLogPageInput": { - "SearchDate": "時間區間", - "OperateObject": "操作對象", - "OperateSource": "操作源" - }, - - "ThingsGateway.Gateway.Application.BackendLogPageInput": { - "SearchDate": "時間區間", - "LogLevel": "日誌等級", - "LogSource": "日誌來源" - }, - - "ThingsGateway.Gateway.Application.RpcService": { - "VariableNotNull": " {0} 變數不存在", - "VariableReadOnly": " {0} 變數唯讀", - "VariableWriteDisable": " {0} 變數不允許寫入", - "DriverNotNull": "系統錯誤,不存在對應採集設備,請稍候重試", - "DevicePause": " {0} 設備已暫停", - "WriteVariable": "寫入變數" - }, - - "ThingsGateway.Gateway.Application.ExportString": { - "VariableName": "變數", - "RedundantDeviceName": "冗餘設備", - "ChannelName": "通道", - "DeviceName": "設備" - }, - - "ThingsGateway.Gateway.Application.ProtectTypeEnum": { - "ReadOnly": "唯讀", - "ReadWrite": "讀寫", - "WriteOnly": "唯寫" - } -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/AlarmVariable.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/AlarmVariable.cs index f00873a53..ba5f24f0a 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/AlarmVariable.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/AlarmVariable.cs @@ -33,7 +33,7 @@ public class AlarmVariable : PrimaryIdEntity, IDBHistoryAlarm [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] public string? Description { get; set; } - /// + /// [SugarColumn(ColumnDescription = "设备名称", IsNullable = true)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] public string DeviceName { get; set; } @@ -50,27 +50,27 @@ public class AlarmVariable : PrimaryIdEntity, IDBHistoryAlarm [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] public DataTypeEnum DataType { get; set; } - /// + /// [SugarColumn(ColumnDescription = "报警值", IsNullable = false)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] public string AlarmCode { get; set; } - /// + /// [SugarColumn(ColumnDescription = "报警限值", IsNullable = false)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] public string AlarmLimit { get; set; } - /// + /// [SugarColumn(ColumnDescription = "报警文本", IsNullable = true)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] public string? AlarmText { get; set; } - /// + /// [SugarColumn(ColumnDescription = "报警时间", IsNullable = false)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] public DateTime AlarmTime { get; set; } - /// + /// [SugarColumn(ColumnDescription = "事件时间", IsNullable = false)] [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true)] [TimeDbSplitField(DateType.Month)] diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/ChannelRuntime.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/ChannelRuntime.cs new file mode 100644 index 000000000..628abd139 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/ChannelRuntime.cs @@ -0,0 +1,140 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Mapster; + +using System.Collections.Concurrent; + +using TouchSocket.Core; + +namespace ThingsGateway.Gateway.Application; + +/// +/// 业务设备运行状态 +/// +public class ChannelRuntime : Channel, IChannelOptions, IDisposable +{ + /// + /// 插件信息 + /// + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + public PluginInfo? PluginInfo { get; set; } + + /// + /// 是否采集 + /// + public PluginTypeEnum? PluginType => PluginInfo?.PluginType; + + /// + /// 是否采集 + /// + public bool? IsCollect => PluginInfo == null ? null : PluginInfo?.PluginType == PluginTypeEnum.Collect; + + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + public WaitLock WaitLock { get; private set; } = new WaitLock(); + + /// + [MinValue(1)] + public override int MaxConcurrentCount + { + get + { + return _maxConcurrentCount; + } + set + { + if (value > 0) + { + _maxConcurrentCount = value; + + if (WaitLock?.MaxCount != MaxConcurrentCount) + { + var _lock = WaitLock; + WaitLock = new WaitLock(_maxConcurrentCount); + _lock?.SafeDispose(); + } + } + } + } + + private volatile int _maxConcurrentCount = 1; + + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + public TouchSocketConfig Config { get; set; } = new(); + + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + [AutoGenerateColumn(Ignore = true)] + public IReadOnlyDictionary? ReadDeviceRuntimes => DeviceRuntimes; + + /// + /// 设备变量 + /// + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + [AutoGenerateColumn(Ignore = true)] + internal ConcurrentDictionary? DeviceRuntimes { get; } = new(Environment.ProcessorCount, 1000); + + /// + /// 设备数量 + /// + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + public int? DeviceRuntimeCounts => DeviceRuntimes?.Count; + + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + public IDeviceThreadManage? DeviceThreadManage { get; internal set; } + + public string LogPath => Id.GetLogPath(); + + public void Init() + { + // 通过插件名称获取插件信息 + PluginInfo = GlobalData.PluginService.GetList().FirstOrDefault(A => A.FullName == PluginName); + + if (PluginInfo == null) + { + //throw new Exception($"Plugin {PluginName} not found"); + } + + GlobalData.Channels.TryAdd(Id, this); + + } + + public void Dispose() + { + Config?.SafeDispose(); + + GlobalData.Channels.TryRemove(Id, out _); + DeviceThreadManage = null; + GC.SuppressFinalize(this); + } + public override string ToString() + { + if (ChannelType == ChannelTypeEnum.Other) + { + return Name; + } + return $"{Name}[{base.ToString()}]"; + } + +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/CollectDeviceRunTime.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/CollectDeviceRunTime.cs deleted file mode 100644 index 9a8e94cdb..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/CollectDeviceRunTime.cs +++ /dev/null @@ -1,62 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using Mapster; - -namespace ThingsGateway.Gateway.Application; - -/// -/// 采集设备状态表示 -/// -public class CollectDeviceRunTime : DeviceRunTime -{ - /// - /// 特殊方法数量 - /// - public int MethodVariableCount => VariableMethods?.Count ?? 0; - - /// - /// 特殊方法变量 - /// - [System.Text.Json.Serialization.JsonIgnore] - [Newtonsoft.Json.JsonIgnore] - [AdaptIgnore] - public List? ReadVariableMethods { get; set; } - - /// - /// 设备读取打包数量 - /// - public int SourceVariableCount => VariableSourceReads?.Count ?? 0; - - /// - /// 特殊方法变量 - /// - [System.Text.Json.Serialization.JsonIgnore] - [Newtonsoft.Json.JsonIgnore] - [AdaptIgnore] - public List? VariableMethods { get; set; } - - /// - /// 打包变量 - /// - [System.Text.Json.Serialization.JsonIgnore] - [Newtonsoft.Json.JsonIgnore] - [AdaptIgnore] - public List? VariableSourceReads { get; set; } - - - /// - /// 特殊地址变量 - /// - [System.Text.Json.Serialization.JsonIgnore] - [Newtonsoft.Json.JsonIgnore] - [AdaptIgnore] - public List? OtherVariableRunTimes { get; set; } -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceData.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceData.cs index 0c1b0f287..4295188a9 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceData.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceData.cs @@ -23,13 +23,13 @@ public class DeviceData : IPrimaryIdEntity /// public string Name { get; set; } - /// + /// public DateTime ActiveTime { get; set; } - /// + /// public DeviceStatusEnum DeviceStatus { get; set; } - /// + /// [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] public string LastErrorMessage { get; set; } @@ -65,7 +65,7 @@ public class DeviceData : IPrimaryIdEntity /// public class DeviceBasicData : DeviceData { - /// + /// public string PluginName { get; set; } /// @@ -73,20 +73,3 @@ public class DeviceBasicData : DeviceData [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] public string? Description { get; set; } } - -public class DeviceDataWithValue -{ - /// - public string Name { get; set; } - - /// - public DateTime ActiveTime { get; set; } - - /// - public DeviceStatusEnum DeviceStatus { get; set; } - - /// - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] - public string LastErrorMessage { get; set; } -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceRunTime.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceRunTime.cs index 923a9ebe9..fa377246d 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceRunTime.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/DeviceRunTime.cs @@ -8,8 +8,12 @@ // QQ群:605534569 //------------------------------------------------------------------------------ +using BootstrapBlazor.Components; + using Mapster; +using System.Collections.Concurrent; + using ThingsGateway.Extension; using ThingsGateway.NewLife.Extension; using ThingsGateway.NewLife.Threading; @@ -19,31 +23,45 @@ namespace ThingsGateway.Gateway.Application; /// /// 业务设备运行状态 /// -public class DeviceRunTime : Device +public class DeviceRuntime : Device, IDisposable { protected volatile DeviceStatusEnum _deviceStatus = DeviceStatusEnum.Default; - protected int _errorCount; - private string? _lastErrorMessage; /// /// 设备活跃时间 /// - public DateTime? ActiveTime { get; set; } = DateTime.UnixEpoch.ToLocalTime(); + public DateTime ActiveTime { get; set; } = DateTime.UnixEpoch.ToLocalTime(); /// - /// 通道表 + /// 插件名称 + /// + public virtual string PluginName => ChannelRuntime?.PluginName; + + /// + /// 插件名称 + /// + public virtual PluginTypeEnum? PluginType => ChannelRuntime?.PluginInfo?.PluginType; + + /// + /// 是否采集 + /// + public bool? IsCollect => PluginType == null ? null : PluginType == PluginTypeEnum.Collect; + + /// + /// 通道 /// [System.Text.Json.Serialization.JsonIgnore] [Newtonsoft.Json.JsonIgnore] [AdaptIgnore] - public Channel? Channel { get; set; } + public ChannelRuntime? ChannelRuntime { get; set; } /// /// 通道名称 /// - public string? ChannelName => Channel?.Name; + public string? ChannelName => ChannelRuntime?.Name; + public string LogPath => Id.GetLogPath(); /// /// 设备状态 @@ -52,7 +70,7 @@ public class DeviceRunTime : Device { get { - if (KeepRun) + if (!Pause) return _deviceStatus; else return DeviceStatusEnum.Pause; @@ -70,35 +88,12 @@ public class DeviceRunTime : Device /// /// 设备变量数量 /// - public int DeviceVariableCount { get => VariableRunTimes == null ? 0 : VariableRunTimes.Count; } + public int DeviceVariableCount { get => VariableRuntimes == null ? 0 : VariableRuntimes.Count; } /// - /// 距上次成功时的读取失败次数,超过3次设备更新为离线,等于0时设备更新为在线 + /// 暂停 /// - public virtual int ErrorCount - { - get - { - return _errorCount; - } - protected set - { - _errorCount = value; - if (_errorCount > 3) - { - DeviceStatus = DeviceStatusEnum.OffLine; - } - else if (_errorCount == 0) - { - DeviceStatus = DeviceStatusEnum.OnLine; - } - } - } - - /// - /// 运行 - /// - public bool KeepRun { get; set; } = true; + public bool Pause { get; set; } = false; /// /// 最后一次失败原因 @@ -131,27 +126,109 @@ public class DeviceRunTime : Device /// [System.Text.Json.Serialization.JsonIgnore] [Newtonsoft.Json.JsonIgnore] - public IReadOnlyDictionary? VariableRunTimes { get; set; } + [AdaptIgnore] + [AutoGenerateColumn(Ignore = true)] + public IReadOnlyDictionary? ReadVariableRuntimes => VariableRuntimes; + + /// + /// 设备变量 + /// + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + [AutoGenerateColumn(Ignore = true)] + internal ConcurrentDictionary? VariableRuntimes { get; set; } = new(Environment.ProcessorCount, 1000); + + #region 采集 + /// + /// 特殊方法数量 + /// + public int MethodVariableCount { get; set; } + + /// + /// 特殊方法变量 + /// + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + public List? ReadVariableMethods { get; set; } + + /// + /// 设备读取打包数量 + /// + public int SourceVariableCount => VariableSourceReads?.Count ?? 0; + + /// + /// 打包变量 + /// + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + public List? VariableSourceReads { get; set; } + + /// + /// 特殊地址变量 + /// + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + public List? OtherVariableRuntimes { get; set; } + + + public volatile bool CheckEnable; + + #endregion 采集 /// /// 传入设备的状态信息 /// /// - /// + /// /// - /// - public void SetDeviceStatus(DateTime? activeTime = null, int? errorCount = null, string lastErrorMessage = null, DeviceStatusEnum? deviceStatus = null) + public void SetDeviceStatus(DateTime? activeTime = null, bool? error = null, string lastErrorMessage = null) { - //lock (this) + if (activeTime != null) + ActiveTime = activeTime.Value; + if (error == true) { - if (activeTime != null) - ActiveTime = activeTime.Value; - if (errorCount != null) - ErrorCount = errorCount.Value; - if (lastErrorMessage != null) - LastErrorMessage = lastErrorMessage; - if (deviceStatus != null) - DeviceStatus = deviceStatus.Value; + DeviceStatus = DeviceStatusEnum.OffLine; } + else + { + DeviceStatus = DeviceStatusEnum.OnLine; + } + if (lastErrorMessage != null) + LastErrorMessage = lastErrorMessage; } + + + + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AdaptIgnore] + public IDriver? Driver { get; internal set; } + + public void Init(ChannelRuntime channelRuntime) + { + ChannelRuntime?.DeviceRuntimes?.TryRemove(Id, out _); + + ChannelRuntime = channelRuntime; + ChannelRuntime?.DeviceRuntimes?.TryRemove(Id, out _); + ChannelRuntime.DeviceRuntimes.TryAdd(Id, this); + + GlobalData.Devices.TryRemove(Id, out _); + GlobalData.Devices.TryAdd(Id, this); + + } + + public void Dispose() + { + ChannelRuntime?.DeviceRuntimes?.TryRemove(Id, out _); + + GlobalData.Devices.TryRemove(Id, out _); + + Driver = null; + GC.SuppressFinalize(this); + } + } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/Dto/PluginOutput.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/PluginInfo.cs similarity index 84% rename from src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/Dto/PluginOutput.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Model/PluginInfo.cs index 66a6355dc..f4b7cb5bf 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/Dto/PluginOutput.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/PluginInfo.cs @@ -10,18 +10,20 @@ using BootstrapBlazor.Components; +using SqlSugar; + namespace ThingsGateway.Gateway.Application; /// /// 插件分组 /// -public class PluginOutput +public class PluginInfo { /// /// 插件文件名称.插件类型名称 /// [AutoGenerateColumn(Ignore = true)] - public List Children { get; set; } = new(); + public List Children { get; set; } = new(); /// /// 插件文件名称.插件类型名称 @@ -44,7 +46,7 @@ public class PluginOutput /// /// 专业版插件 /// - [AutoGenerateColumn(Visible = false)] + [AutoGenerateColumn(Ignore = true)] public bool EducationPlugin { get; set; } /// @@ -53,12 +55,6 @@ public class PluginOutput [AutoGenerateColumn(Filterable = true, Sortable = true)] public string Name { get; set; } - /// - /// 关联设备数量 - /// - [AutoGenerateColumn(Filterable = true, Sortable = true)] - public int DeviceCount { get; set; } - /// /// 插件版本 /// @@ -70,4 +66,14 @@ public class PluginOutput /// [AutoGenerateColumn(Filterable = true, Sortable = true)] public DateTime LastWriteTime { get; set; } + + /// + /// 目录 + /// + [IgnoreExcel] + [SugarColumn(IsIgnore = true)] + [System.Text.Json.Serialization.JsonIgnore] + [Newtonsoft.Json.JsonIgnore] + [AutoGenerateColumn(Ignore = true)] + public string Directory { get; set; } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableData.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableData.cs index b2054e840..8ff1e4cdd 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableData.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableData.cs @@ -23,26 +23,26 @@ public class VariableData : IPrimaryIdEntity /// public string Name { get; set; } - /// + /// public string DeviceName { get; set; } - /// + /// public object Value { get; set; } - /// + /// public object RawValue { get; set; } - /// + /// public object LastSetValue { get; set; } - /// + /// public DateTime ChangeTime { get; set; } - /// + /// public DateTime CollectTime { get; set; } - /// + /// public bool IsOnline { get; set; } - /// + /// [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] public string? LastErrorMessage { get; set; } @@ -100,22 +100,3 @@ public class VariableBasicData : VariableData public DataTypeEnum DataType { get; set; } } -public class VariableDataWithValue -{ - /// - public string Name { get; set; } - - /// - public object RawValue { get; set; } - - /// - public DateTime CollectTime { get; set; } - - /// - public bool IsOnline { get; set; } - - /// - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] - public string? LastErrorMessage { get; set; } -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableMethod.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableMethod.cs index d4a38477d..21caba701 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableMethod.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableMethod.cs @@ -24,7 +24,7 @@ public class VariableMethod private object?[]? OS; - public VariableMethod(Method method, VariableRunTime variable, string delay) + public VariableMethod(Method method, VariableRuntime variable, string delay) { _timeTick = new TimeTick(delay); MethodInfo = method; @@ -45,14 +45,13 @@ public class VariableMethod /// /// 需分配的变量 /// - public VariableRunTime Variable { get; } + public VariableRuntime Variable { get; } /// /// 检测是否达到读取间隔 /// - /// /// - public bool CheckIfRequestAndUpdateTime(DateTime time) => _timeTick.IsTickHappen(time); + public bool CheckIfRequestAndUpdateTime() => _timeTick.IsTickHappen(); /// /// 执行方法 @@ -69,8 +68,8 @@ public class VariableMethod if (value == null && OS == null) { //默认的参数 - var addresss = Variable.RegisterAddress?.Trim()?.TrimEnd(',').Split(',') ?? Array.Empty(); - //通过分号分割,并且合并参数 + var addresss = Variable.RegisterAddress.SplitOS(); + //通过逗号分割,并且合并参数 var strs = addresss; OS = GetOS(strs, cancellationToken); @@ -78,11 +77,10 @@ public class VariableMethod } else { - var addresss = Variable.RegisterAddress?.Trim()?.TrimEnd(',').Split(',') ?? Array.Empty(); - var values = value?.Trim()?.TrimEnd(',').Split(',') ?? Array.Empty(); + var addresss = Variable.RegisterAddress.SplitOS(); + var values = value.SplitOS(); //通过分号分割,并且合并参数 - var strs = DataTransUtil.SpliceArray(addresss, values); - + var strs = addresss.Concat(values).ToList(); os = GetOS(strs, cancellationToken); } @@ -115,7 +113,7 @@ public class VariableMethod } } - private object?[] GetOS(string[] strs, CancellationToken cancellationToken) + private object?[] GetOS(List strs, CancellationToken cancellationToken) { var method = MethodInfo; var ps = method.Info.GetParameters(); @@ -129,14 +127,14 @@ public class VariableMethod } else if (ps[i].HasDefaultValue) { - if (strs.Length <= index) + if (strs.Count <= index) { os[i] = ps[i].DefaultValue; } } else { - if (strs.Length <= index) + if (strs.Count <= index) continue; os[i] = ThingsGatewayStringConverter.Default.Deserialize(null, strs[index], ps[i].ParameterType); index++; diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableRunTime.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableRunTime.cs index 54cc7bbd1..806fc9d42 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableRunTime.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableRunTime.cs @@ -12,43 +12,16 @@ using BootstrapBlazor.Components; using Mapster; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; - -using ThingsGateway.Core.Json.Extension; using ThingsGateway.Gateway.Application.Extensions; +using ThingsGateway.NewLife.Json.Extension; namespace ThingsGateway.Gateway.Application; /// /// 变量运行态 /// -public class VariableRunTime : Variable, IVariable +public class VariableRuntime : Variable, IVariable, IDisposable { - #region 重写 - - [AutoGenerateColumn(Visible = false)] - [NotNull] - public override long? DeviceId { get; set; } - - - [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true, Order = 3)] - public override string? Unit { get; set; } - - [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] - public override string? ReadExpressions { get; set; } - - [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] - public override string? WriteExpressions { get; set; } - - [AutoGenerateColumn(Visible = false)] - public override bool Enable { get; set; } - - [AutoGenerateColumn(Visible = false, Filterable = true, Sortable = true)] - public override string? IntervalTime { get; set; } - - #endregion 重写 - private bool _isOnline; private bool? _isOnlineChanged; protected object? _value; @@ -65,8 +38,8 @@ public class VariableRunTime : Variable, IVariable [Newtonsoft.Json.JsonIgnore] [System.Text.Json.Serialization.JsonIgnore] [AdaptIgnore] - [AutoGenerateColumn(Visible = false)] - public CollectDeviceRunTime? CollectDeviceRunTime { get; set; } + [AutoGenerateColumn(Ignore = true)] + public DeviceRuntime? DeviceRuntime { get; set; } /// /// VariableSource @@ -74,7 +47,7 @@ public class VariableRunTime : Variable, IVariable [Newtonsoft.Json.JsonIgnore] [System.Text.Json.Serialization.JsonIgnore] [AdaptIgnore] - [AutoGenerateColumn(Visible = false)] + [AutoGenerateColumn(Ignore = true)] public IVariableSource VariableSource { get; set; } /// @@ -83,7 +56,7 @@ public class VariableRunTime : Variable, IVariable [Newtonsoft.Json.JsonIgnore] [System.Text.Json.Serialization.JsonIgnore] [AdaptIgnore] - [AutoGenerateColumn(Visible = false)] + [AutoGenerateColumn(Ignore = true)] public VariableMethod VariableMethod { get; set; } /// @@ -96,7 +69,7 @@ public class VariableRunTime : Variable, IVariable /// 设备名称 /// [AutoGenerateColumn(Visible = true, Filterable = true, Sortable = true, Order = 4)] - public string? DeviceName { get; set; } + public string? DeviceName => DeviceRuntime?.Name; /// /// 是否在线 @@ -122,7 +95,7 @@ public class VariableRunTime : Variable, IVariable } } - private string lastErrorMessage; + private string _lastErrorMessage; /// /// @@ -133,7 +106,7 @@ public class VariableRunTime : Variable, IVariable get { if (_isOnline == false) - return lastErrorMessage ?? VariableSource?.LastErrorMessage ?? VariableMethod?.LastErrorMessage; + return _lastErrorMessage ?? VariableSource?.LastErrorMessage ?? VariableMethod?.LastErrorMessage; else return null; } @@ -176,21 +149,27 @@ public class VariableRunTime : Variable, IVariable { try { - var data = ReadExpressions.GetExpressionsResult(RawValue); + var data = ReadExpressions.GetExpressionsResult(RawValue, DeviceRuntime?.Driver?.LogMessage); Set(data, dateTime); } catch (Exception ex) { IsOnline = false; Set(null, dateTime); + var oldMessage = _lastErrorMessage; if (ex.StackTrace != null) { string stachTrace = string.Join(Environment.NewLine, ex.StackTrace.Split(new[] { Environment.NewLine }, StringSplitOptions.None).Take(3)); - lastErrorMessage = $"{Name} Conversion expression failed:{ex.Message}{Environment.NewLine}{stachTrace}"; + _lastErrorMessage = $"{Name} Conversion expression failed:{ex.Message}{Environment.NewLine}{stachTrace}"; + } else { - lastErrorMessage = $"{Name} Conversion expression failed:{ex.Message}{Environment.NewLine}"; + _lastErrorMessage = $"{Name} Conversion expression failed:{ex.Message}{Environment.NewLine}"; + } + if (oldMessage != _lastErrorMessage) + { + DeviceRuntime?.Driver?.LogMessage?.LogWarning(_lastErrorMessage); } return new($"{Name} Conversion expression failed", ex); } @@ -219,12 +198,11 @@ public class VariableRunTime : Variable, IVariable { //判断变化,插件传入的Value可能是基础类型,也有可能是class,比较器无法识别是否变化,这里json处理序列化比较 //检查IComparable - if (data != _value) + if (!data.Equals(_value)) { - Type type = data?.GetType(); - if (typeof(IComparable).IsAssignableFrom(type)) + if (data is IComparable) { - changed = !(data.Equals(_value)); + changed = true; } else { @@ -252,36 +230,24 @@ public class VariableRunTime : Variable, IVariable GlobalData.VariableValueChange(this); } - GlobalData.VariableCollectChange(this); } - /// - public async ValueTask SetValueToDeviceAsync(string value, string? executive = "BLAZOR", CancellationToken cancellationToken = default) - { - var data = await GlobalData.RpcService.InvokeDeviceMethodAsync(executive, new Dictionary() { { Name, value } }, cancellationToken).ConfigureAwait(false); - return data.Values.FirstOrDefault(); - } - - public void SetErrorMessage(string value) - { - lastErrorMessage = value; - } #region LoadSourceRead /// - /// 这个参数值由自动打包方法写入 + /// 这个参数值由自动打包方法写入 /// [AutoGenerateColumn(Visible = false)] public int Index { get; set; } /// - /// 这个参数值由自动打包方法写入 + /// 这个参数值由自动打包方法写入 /// [Newtonsoft.Json.JsonIgnore] [System.Text.Json.Serialization.JsonIgnore] - [AutoGenerateColumn(Visible = false)] + [AutoGenerateColumn(Ignore = true)] public IThingsGatewayBitConverter ThingsGatewayBitConverter { get; set; } #endregion LoadSourceRead @@ -338,8 +304,8 @@ public class VariableRunTime : Variable, IVariable /// /// 事件时间 /// - [AutoGenerateColumn(Visible = false)] - public DateTime? PrepareEventTime { get; set; } + [AutoGenerateColumn(Ignore = true)] + internal DateTime? PrepareEventTime { get; set; } /// /// 事件类型 @@ -348,4 +314,47 @@ public class VariableRunTime : Variable, IVariable public EventTypeEnum? EventType { get; set; } #endregion 报警 + public void Init(DeviceRuntime deviceRuntime) + { + DeviceRuntime?.VariableRuntimes?.TryRemove(Name, out _); + + DeviceRuntime = deviceRuntime; + + DeviceRuntime.VariableRuntimes.TryAdd(Name, this); + GlobalData.IdVariables.TryRemove(Id, out _); + GlobalData.IdVariables.TryAdd(Id, this); + GlobalData.Variables.TryRemove(Name, out _); + GlobalData.Variables.TryAdd(Name, this); + if (AlarmEnable) + { + GlobalData.AlarmEnableVariables.TryRemove(Name, out _); + GlobalData.AlarmEnableVariables.TryAdd(Name, this); + } + } + + + public void Dispose() + { + DeviceRuntime?.VariableRuntimes?.TryRemove(Name, out _); + + GlobalData.IdVariables.TryRemove(Id, out _); + GlobalData.Variables.TryRemove(Name, out _); + + GlobalData.AlarmEnableVariables.TryRemove(Name, out _); + + GC.SuppressFinalize(this); + } + + /// + public async ValueTask RpcAsync(string value, string? executive = "brower", CancellationToken cancellationToken = default) + { + var data = await GlobalData.RpcService.InvokeDeviceMethodAsync(executive, new Dictionary() { { Name, value } }, cancellationToken).ConfigureAwait(false); + return data.FirstOrDefault().Value; + } + + public void SetErrorMessage(string? lastErrorMessage) + { + _lastErrorMessage = lastErrorMessage; + } } + diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableSourceRead.cs b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableSourceRead.cs index 9db563ce0..162525e3d 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableSourceRead.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Model/VariableSourceRead.cs @@ -20,7 +20,7 @@ public class VariableSourceRead : IVariableSource /// public ulong ReadCount; - private List _variableRunTimes = new List(); + private List _variableRuntimes = new List(); /// /// 离线原因 @@ -33,7 +33,7 @@ public class VariableSourceRead : IVariableSource public int Length { get; set; } /// - /// 读取地址,传入时需要去除额外信息 + /// 读取地址 /// public string RegisterAddress { get; set; } @@ -45,12 +45,12 @@ public class VariableSourceRead : IVariableSource /// /// 需分配的变量列表 /// - public IEnumerable VariableRunTimes => _variableRunTimes; + public IEnumerable VariableRuntimes => _variableRuntimes; public void AddVariable(IVariable variable) { variable.VariableSource = this; - _variableRunTimes.Add(variable); + _variableRuntimes.Add(variable); } /// @@ -60,13 +60,21 @@ public class VariableSourceRead : IVariableSource { variable.VariableSource = this; } - _variableRunTimes.AddRange(variables); + _variableRuntimes.AddRange(variables); } + /// /// 检测是否达到读取间隔 /// - /// /// - public bool CheckIfRequestAndUpdateTime(DateTime time) => TimeTick.IsTickHappen(time); + public bool CheckIfRequestAndUpdateTime() + { + var result = TimeTick.IsTickHappen(); + if (result) + { + ReadCount++; + } + return result; + } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Options/ChannelThreadOptions.cs b/src/Gateway/ThingsGateway.Gateway.Application/Options/ChannelThreadOptions.cs new file mode 100644 index 000000000..c7753a14b --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Options/ChannelThreadOptions.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Gateway.Application; + +public sealed class ChannelThreadOptions : IConfigurableOptions +{ + + public int MinCycleInterval { get; set; } + public int MaxCycleInterval { get; set; } + public int CheckInterval { get; set; } + +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Options/GatewayLogOptions.cs b/src/Gateway/ThingsGateway.Gateway.Application/Options/GatewayLogOptions.cs new file mode 100644 index 000000000..763261da6 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Options/GatewayLogOptions.cs @@ -0,0 +1,22 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Gateway.Application; + + +public sealed class GatewayLogOptions : IConfigurableOptions +{ + + public int RpcLogDaysAgo { get; set; } + public int BackendLogDaysAgo { get; set; } + +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Options/RpcLogOptions.cs b/src/Gateway/ThingsGateway.Gateway.Application/Options/RpcLogOptions.cs new file mode 100644 index 000000000..33dd90a9a --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Options/RpcLogOptions.cs @@ -0,0 +1,18 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.ConfigurableOptions; + +namespace ThingsGateway.Gateway.Application; + +public sealed class RpcLogOptions : IConfigurableOptions +{ + public bool SuccessLog { get; set; } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/ChannelThread.cs b/src/Gateway/ThingsGateway.Gateway.Application/Plugin/ChannelThread.cs deleted file mode 100644 index beee03b35..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/ChannelThread.cs +++ /dev/null @@ -1,692 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging; - -using System.Collections.Concurrent; - -using ThingsGateway.NewLife.Extension; - -using TouchSocket.Core; - -namespace ThingsGateway.Gateway.Application; - -/// -/// 设备线程管理 -/// -public class ChannelThread -{ - #region 动态配置 - - /// - /// 线程等待间隔时间 - /// - public static volatile int CycleInterval = 10; - - /// - /// 线程最大等待间隔时间 - /// - public static int MaxCycleInterval = 100; - - /// - /// 线程最小等待间隔时间 - /// - public static int MinCycleInterval = 10; - - internal static volatile int MaxCount; - - internal static volatile int MaxVariableCount; - - static ChannelThread() - { - var minCycleInterval = App.Configuration.GetSection("ChannelThread:MinCycleInterval").Get() ?? 10; - MinCycleInterval = minCycleInterval < 10 ? 10 : minCycleInterval; - - var maxCycleInterval = App.Configuration.GetSection("ChannelThread:MaxCycleInterval").Get() ?? 100; - MaxCycleInterval = maxCycleInterval < 100 ? 100 : maxCycleInterval; - - var maxCount = App.Configuration.GetSection("ChannelThread:MaxCount").Get() ?? 1000; - MaxCount = maxCount < 10 ? 10 : maxCount; - - var maxVariableCount = App.Configuration.GetSection("ChannelThread:MaxVariableCount").Get() ?? 1000000; - MaxVariableCount = maxVariableCount < 1000 ? 1000 : maxVariableCount; - - CycleInterval = MaxCycleInterval; - - Task.Factory.StartNew(SetCycleInterval, TaskCreationOptions.LongRunning); - } - - private static async Task SetCycleInterval() - { - var appLifetime = App.RootServices!.GetService()!; - var hardwareJob = GlobalData.HardwareJob; - - List cpus = new(); - while (!((appLifetime?.ApplicationStopping ?? default).IsCancellationRequested || (appLifetime?.ApplicationStopped ?? default).IsCancellationRequested)) - { - try - { - if (hardwareJob?.HardwareInfo?.MachineInfo?.CpuRate == null) continue; - cpus.Add((float)(hardwareJob.HardwareInfo.MachineInfo.CpuRate * 100)); - if (cpus.Count == 1 || cpus.Count > 5) - { - var avg = cpus.Average(); - cpus.RemoveAt(0); - //Console.WriteLine($"CPU平均值:{avg}"); - if (avg > 80) - { - CycleInterval = Math.Max(CycleInterval, (int)(MaxCycleInterval * avg / 100)); - } - else if (avg < 50) - { - CycleInterval = Math.Min(CycleInterval, MinCycleInterval); - } - } - await Task.Delay(30000, appLifetime?.ApplicationStopping ?? default).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - catch (Exception ex) - { - Console.WriteLine(ex.ToString()); - } - } - } - - #endregion 动态配置 - - /// - /// 通道线程构造函数,用于初始化通道线程实例。 - /// - /// 通道表 - /// 通道设置实例 - /// 通道实例 - /// 日志 - public ChannelThread(Channel channel, TouchSocketConfig foundataionConfig, IChannel ichannel, LoggerGroup loggerGroup) - { - Localizer = App.CreateLocalizerByType(typeof(ChannelThread))!; - // 初始化日志记录器,使用通道名称作为日志记录器的名称 - Logger = App.RootServices.GetService().CreateLogger($"Channel[{channel.Name}]"); - - // 设置通道信息 - ChannelTable = channel; - ChannelId = channel.Id; - - // 初始化底层配置 - LogMessage = loggerGroup; - - // 添加默认日志记录器 - LogMessage.AddLogger(new EasyLogger(Log_Out) { LogLevel = TouchSocket.Core.LogLevel.Trace }); - - - // 根据配置获取通道实例 - Channel = ichannel; - - // 设置日志路径为通道ID对应的日志路径 - LogPath = channel.Id.GetLogPath(); - - // 设置日志使能状态 - LogEnable = channel.LogEnable; - } - - /// - /// 是否采集通道 - /// - public bool IsCollectChannel { get; private set; } - - /// - /// 设备线程 - /// - protected internal DoTask DriverTask { get; set; } - - /// - /// - /// - protected internal TouchSocketConfig FoundataionConfig => Channel?.Config; - - /// - /// 读写锁 - /// - protected internal WaitLock WriteLock { get; } = new(); - - /// - /// 启停锁 - /// - protected WaitLock RestartLock { get; } = new(); - - /// - /// 取消令箭列表 - /// - private ConcurrentDictionary CancellationTokenSources { get; set; } = new(); - - /// - /// 插件集合 - /// - private ConcurrentList DriverBases { get; set; } = new(); - - private IStringLocalizer Localizer { get; } - - #region 日志 - - public LoggerGroup LogMessage { get; internal set; } - - public string LogPath { get; } - - /// - /// 日志 - /// - protected internal ILogger Logger { get; set; } - - /// - /// 底层错误日志输出 - /// - protected internal virtual void Log_Out(TouchSocket.Core.LogLevel arg1, object arg2, string arg3, Exception arg4) - { - if (arg1 >= TouchSocket.Core.LogLevel.Warning) - { - foreach (var item in DriverBases) - { - item.CurrentDevice.SetDeviceStatus(lastErrorMessage: arg3); - } - } - Logger?.Log_Out(arg1, arg2, arg3, arg4); - } - - #endregion 日志 - - #region 通道 - - public long ChannelId { get; } - protected internal IChannel? Channel { get; } - protected internal Channel ChannelTable { get; } - - #endregion 通道 - - #region 调试日志 - - private object logEnableLock = new(); - private TextFileLogger? TextLogger; - - /// - /// 获取或设置日志使能状态。当设置为 true 时,将启用日志记录功能;当设置为 false 时,将禁用日志记录功能。 - /// - public bool LogEnable - { - get - { - // 返回日志使能状态 - return logEnable; - } - set - { - // 使用锁确保线程安全 - lock (logEnableLock) - { - // 更新通道的日志使能状态 - ChannelTable.LogEnable = value; - - // 更新日志使能状态 - logEnable = value; - // 如果日志使能状态为 true - if (value) - { - LogMessage.LogLevel = TouchSocket.Core.LogLevel.Trace; - // 移除旧的文件日志记录器并释放资源 - if (TextLogger != null) - { - LogMessage.RemoveLogger(TextLogger); - TextLogger?.Dispose(); - } - - // 创建新的文件日志记录器,并设置日志级别为 Trace - TextLogger = TextFileLogger.CreateTextLogger(LogPath); - TextLogger.LogLevel = TouchSocket.Core.LogLevel.Trace; - - // 将文件日志记录器添加到日志消息组中 - LogMessage.AddLogger(TextLogger); - } - else - { - LogMessage.LogLevel = TouchSocket.Core.LogLevel.Warning; - // 如果日志使能状态为 false,移除文件日志记录器并释放资源 - if (TextLogger != null) - { - LogMessage.RemoveLogger(TextLogger); - TextLogger?.Dispose(); - } - } - } - } - } - - private bool logEnable { get; set; } - - #endregion 调试日志 - - #region 线程管理 - - /// - /// 向当前通道添加驱动程序。 - /// - /// 要添加的驱动程序对象。 - internal void AddDriver(DriverBase driverBase) - { - if (DriverBases.Count > 0) - { - if (DriverBases[0].IsCollectDevice != driverBase.IsCollectDevice) - { - Logger?.LogWarning(Localizer["PluginTypeDiff", driverBase.DeviceName, ChannelTable.Name]); - return; - } - } - - // 将驱动程序对象添加到驱动程序集合中 - DriverBases.Add(driverBase); - - // 将当前通道线程分配给驱动程序对象 - driverBase.ChannelThread = this; - - try - { - // 初始化驱动程序对象,并加载源读取 - driverBase.Init(Channel); - driverBase.LoadSourceRead(driverBase.CurrentDevice?.VariableRunTimes.Select(a => a.Value)); - } - catch (Exception ex) - { - // 如果初始化过程中发生异常,设置初始化状态为失败,并记录警告日志 - driverBase.IsInitSuccess = false; - Logger?.LogWarning(ex, Localizer["InitFail", driverBase.CurrentDevice.PluginName, driverBase.DeviceName]); - } - - // 创建令牌并与驱动程序对象的设备ID关联,用于取消操作 - lock (CancellationTokenSources) - { - if (!CancellationTokenSources.ContainsKey(0)) - CancellationTokenSources.TryAdd(0, new CancellationTokenSource()); - - CancellationTokenSources.TryGetValue(0, out var cts); - if (!CancellationTokenSources.ContainsKey(driverBase.DeviceId)) - CancellationTokenSources.TryAdd(driverBase.DeviceId, CancellationTokenSource.CreateLinkedTokenSource(cts.Token)); - } - - // 更新当前通道是否正在收集数据的状态 - IsCollectChannel = driverBase.IsCollectDevice; - } - - /// - /// 异步移除指定设备ID对应的驱动程序。 - /// - /// 要移除的设备ID。 - /// 表示异步移除操作的任务。 - internal async Task RemoveDriverAsync(long deviceId) - { - // 查找具有指定设备ID的驱动程序对象 - var driverBase = DriverBases.FirstOrDefault(a => a.DeviceId == deviceId); - if (driverBase != null) - { - // 取消驱动程序的操作 - lock (CancellationTokenSources) - { - if (CancellationTokenSources.TryGetValue(deviceId, out var token)) - { - if (token != null) - { - token.Cancel(); - token.Dispose(); - } - } - } - - await Task.Delay(100).ConfigureAwait(false); - - driverBase.AfterStop(); - - - // 如果需要移除的是采集设备 - if (IsCollectChannel) - { - try - { - //添加保存数据变量读取操作 - var saveVariable = driverBase.CurrentDevice.VariableRunTimes.Where(a => a.Value.SaveValue).Select(a => (Variable)a.Value).ToList(); - - if (saveVariable.Count > 0) - { - using var db = DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); - var result = await db.Updateable(saveVariable).UpdateColumns(a => a.Value).ExecuteCommandAsync().ConfigureAwait(false); - } - } - catch (Exception ex) - { - LogMessage.LogWarning(ex, "SaveValue"); - } - - driverBase.RemoveCollectDeviceRuntime(); - } - else - { - driverBase.RemoveBusinessDeviceRuntime(); - } - - // 从驱动程序集合和令牌源集合中移除驱动程序对象和相关令牌 - DriverBases.Remove(driverBase); - CancellationTokenSources.Remove(deviceId); - } - } - - #endregion 线程管理 - - #region 外部获取 - - internal DriverBase GetDriver(long deviceId) - { - var driverBase = DriverBases.FirstOrDefault(a => a.DeviceId == deviceId); - return driverBase; - } - - internal IEnumerable GetDriverEnumerable() - { - return DriverBases; - } - - internal bool Has(long deviceId) - { - return DriverBases.Any(a => a.DeviceId == deviceId); - } - - #endregion 外部获取 - - #region 线程生命周期 - - private int releaseCount = 0; - - /// - /// 停止插件前,执行取消传播 - /// - internal virtual void BeforeStopThread() - { - lock (CancellationTokenSources) - { - CancellationTokenSources.TryGetValue(0, out var cts); - - if (cts != null) - { - try - { - if (!cts.IsCancellationRequested)// 检查是否已请求取消,若未请求取消则尝试取消操作 - { - cts?.Cancel(); - } - } - catch - { - // 捕获异常以确保不会影响其他令牌的取消操作 - } - } - } - } - - /// - /// 异步开始执行线程任务。 - /// - internal virtual async Task StartThreadAsync() - { - try - { - // 等待WaitLock锁的获取 - await RestartLock.WaitAsync().ConfigureAwait(false); - - // 如果DriverTask不为null,则执行以下操作 - if (DriverTask != null) - { - // 从FoundataionConfig中移除TouchSocketCoreConfigExtension.ConfigurePluginsProperty - FoundataionConfig?.RemoveValue(TouchSocketCoreConfigExtension.ConfigurePluginsProperty); - - // 配置每个驱动程序的底层插件 - foreach (var driver in DriverBases) - { - driver?.ConfigurePlugins(); - } - // 设置通道的底层配置 - if (Channel != null) - { - await Channel.SetupAsync(FoundataionConfig?.Clone()).ConfigureAwait(false); - } - } - else - { - // 初始化业务线程 - DriverTask = new(DoWork, Logger, null); - lock (CancellationTokenSources) - { - if (!CancellationTokenSources.ContainsKey(0)) - CancellationTokenSources.TryAdd(0, new CancellationTokenSource()); - - CancellationTokenSources.TryGetValue(0, out var cts); - DriverTask.Start(cts.Token); - } - } - } - finally - { - // 释放WaitLock锁 - RestartLock.Release(); - } - } - - /// - /// 异步停止线程任务。 - /// - internal virtual async Task StopThreadAsync(bool removeDevice) - { - // 如果DriverTask为null,则直接返回,无需执行停止操作 - if (DriverTask == null) - { - return; - } - - try - { - // 等待WaitLock锁的获取 - await RestartLock.WaitAsync().ConfigureAwait(false); - - BeforeStopThread(); - - // 等待DriverTask最多30s - await DriverTask.StopAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); - - DriverBases.ForEach(a => - { - a.AfterStop(); - }); - - // 如果需要移除设备 - if (removeDevice) - { - // 如果需要移除的是采集设备 - if (IsCollectChannel) - { - DriverBases.RemoveCollectDeviceRuntime(); - } - else - { - DriverBases.RemoveBusinessDeviceRuntime(); - } - DriverBases.Clear(); - } - - // 将DriverTask置为null - DriverTask = null; - - // 清空CancellationTokenSources集合 - CancellationTokenSources.ForEach(a => a.Value.SafeDispose()); - CancellationTokenSources.Clear(); - } - finally - { - // 释放WaitLock锁 - RestartLock.Release(); - } - } - - /// - /// DoWork - /// - /// 取消标记。 - protected async ValueTask DoWork(CancellationToken stoppingToken) - { - if (Channel?.ChannelType == ChannelTypeEnum.TcpService && IsCollectChannel) - { - //DTU采集,建立同一个Tcp服务通道,多个采集设备(对应各个DTU设备),并发采集 - releaseCount = 0; - List tasks = new List(); - ConcurrentList driverBases = new(); - WaitLock easyLock = new(false); - using CancellationTokenSource cancellationTokenSource = new(); - foreach (var driver1 in DriverBases) - { - var task = DoWork(driver1, DriverBases.Count, stoppingToken, cancellationTokenSource.Token).ContinueWith(_ => - { - if (driverBases.Count < DriverBases.Count) - { - if (!driverBases.Any(a => a == driver1)) - driverBases.Add(driver1);//添加到已完成的任务列表 - - // 如果所有任务都已完成,则取消剩余的等待任务 - if (driverBases.Count >= DriverBases.Count) - cancellationTokenSource.Cancel(); - - _ = Task.Run(async () => - { - while (driverBases.Count < DriverBases.Count) - { - await DoWork(driver1, DriverBases.Count, stoppingToken, cancellationTokenSource.Token).ConfigureAwait(false); - await Task.Delay(MinCycleInterval, stoppingToken).ConfigureAwait(false); - } - Interlocked.Increment(ref releaseCount); - if (releaseCount >= DriverBases.Count) - { - easyLock.Release(); - } - }); - } - }, stoppingToken - ); - tasks.Add(task); - } - - await Task.WhenAll(tasks).ConfigureAwait(false); - await easyLock.WaitAsync(stoppingToken).ConfigureAwait(false); - } - else - { - //foreach (var driver in DriverBases) - //{ - // await DoWork(driver, DriverBases.Count, stoppingToken, CancellationToken.None).ConfigureAwait(false); - //} - - ParallelOptions parallelOptions = new(); - parallelOptions.CancellationToken = stoppingToken; - parallelOptions.MaxDegreeOfParallelism = DriverBases.Count == 0 ? 1 : DriverBases.Count; - await Parallel.ForEachAsync(DriverBases, parallelOptions, (async (driver, stoppingToken) => - { - await DoWork(driver, DriverBases.Count, stoppingToken, CancellationToken.None).ConfigureAwait(false); - })).ConfigureAwait(false); - } - - // 如果驱动实例数量大于1,则延迟一段时间后继续执行下一轮循环 - if (DriverBases.Count > 1) - await Task.Delay(MinCycleInterval, stoppingToken).ConfigureAwait(false); - - // 如果驱动实例数量为0,则延迟一段时间后继续执行下一轮循环 - if (DriverBases.Count == 0) - await Task.Delay(1000, stoppingToken).ConfigureAwait(false); - } - - private async Task DoWork(DriverBase driver, int count, CancellationToken stoppingToken, CancellationToken cancellationToken) - { - try - { - if (stoppingToken.IsCancellationRequested || cancellationToken.IsCancellationRequested) - return; - if (!CancellationTokenSources.TryGetValue(driver.DeviceId, out var stoken)) - { - await Task.Delay(CycleInterval, stoppingToken).ConfigureAwait(false); - return; - } - - using CancellationTokenSource cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, stoken.Token); - - var token = cancellationTokenSource.Token; - - if (token.IsCancellationRequested) - return; - - // 只有当驱动成功初始化后才执行操作 - if (driver.IsInitSuccess) - { - if (!driver.IsBeforStarted) - await driver.BeforStartAsync(token).ConfigureAwait(false); // 调用驱动的启动前异步方法,如果已经执行,会直接返回 - - var result = await driver.ExecuteAsync(token).ConfigureAwait(false); // 执行驱动的异步执行操作 - - // 根据执行结果进行不同的处理 - if (result == ThreadRunReturnTypeEnum.None) - { - // 如果驱动处于离线状态且为采集驱动,则根据配置的间隔时间进行延迟 - if (driver.CurrentDevice.DeviceStatus == DeviceStatusEnum.OffLine && driver.IsCollectDevice) - { - if (count == 1) - await Task.Delay(Math.Max(Math.Min(((CollectBase)driver).CollectProperties.ReIntervalTime, CollectDeviceHostedService.CheckIntervalTime / 2) * 1000 - CycleInterval, 3000), token).ConfigureAwait(false); - } - else - { - if (count == 1) - await Task.Delay(CycleInterval, token).ConfigureAwait(false); // 默认延迟一段时间后再继续执行 - } - } - else if (result == ThreadRunReturnTypeEnum.Continue) - { - if (count == 1) - await Task.Delay(1000, token).ConfigureAwait(false); // 如果执行结果为继续,则延迟一段较短的时间后再继续执行 - } - else if (result == ThreadRunReturnTypeEnum.Break && stoppingToken.IsCancellationRequested) - { - driver.AfterStop(); // 执行驱动的释放操作 - return; // 结束当前循环 - } - } - else - { - if (count == 1) - await Task.Delay(1000, token).ConfigureAwait(false); // 默认延迟一段时间后再继续执行 - } - } - catch (OperationCanceledException) - { - if (stoppingToken.IsCancellationRequested) - driver.AfterStop(); // 执行驱动的释放操作 - return; - } - catch (ObjectDisposedException) - { - if (stoppingToken.IsCancellationRequested) - driver.AfterStop(); // 执行驱动的释放操作 - return; - } - } - - #endregion 线程生命周期 -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DriverBase.cs b/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DriverBase.cs deleted file mode 100644 index 3b7a32332..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DriverBase.cs +++ /dev/null @@ -1,437 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using BootstrapBlazor.Components; - -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging; - -using ThingsGateway.NewLife.Threading; - -using TouchSocket.Core; - -namespace ThingsGateway.Gateway.Application; - -/// -/// 插件基类 -/// -public abstract class DriverBase : DisposableObject -{ - /// - public DriverBase() - { - PluginService = App.RootServices.GetRequiredService(); - RpcService = App.RootServices.GetRequiredService(); - Localizer = App.CreateLocalizerByType(typeof(DriverBase))!; - } - - #region 属性 - /// - /// 当前设备 - /// - public DeviceRunTime? CurrentDevice { get; protected set; } - - /// - /// 当前设备Id - /// - public long DeviceId => CurrentDevice?.Id ?? 0; - - /// - /// 当前设备名称 - /// - public string? DeviceName => CurrentDevice?.Name; - - /// - /// 调试UI Type,如果不存在,返回null - /// - public virtual Type DriverDebugUIType { get; } - - /// - /// 插件UI Type,继承如果不存在,返回null - /// - public virtual Type DriverUIType { get; } - - /// - /// 插件属性UI Type,如果不存在,返回null - /// - public virtual Type DriverPropertyUIType { get; } - - /// - /// 插件配置项 - /// - public abstract object DriverProperties { get; } - - /// - /// 是否执行了BeforStart方法 - /// - public bool IsBeforStarted { get; protected set; } = false; - - /// - /// 是否初始化成功 - /// - public bool IsInitSuccess { get; internal set; } = true; - - /// - /// 是否采集插件 - /// - public virtual bool IsCollectDevice => CurrentDevice.PluginType == PluginTypeEnum.Collect; - - /// - /// 是否继续运行 - /// - public bool KeepRun => CurrentDevice?.KeepRun == true; - - public List PluginPropertyEditorItems - { - get - { - if (CurrentDevice?.PluginName?.IsNullOrWhiteSpace() == true) - { - var result = PluginService.GetDriverPropertyTypes(CurrentDevice.PluginName, this); - return result.EditorItems.ToList(); - } - else - { - var editorItems = PluginServiceUtil.GetEditorItems(DriverProperties?.GetType()); - return editorItems.ToList(); - } - } - } - - /// - /// 底层驱动,有可能为null - /// - public abstract IProtocol? Protocol { get; } - - /// - /// RPC服务 - /// - public IRpcService RpcService { get; } - - /// - /// 全局插件服务 - /// - protected IPluginService PluginService { get; } - - private IStringLocalizer Localizer { get; } - - - #endregion 属性 - - #region 方法 - - /// - /// 配置底层的通道插件,通常在使用前都执行一次获取新的插件管理器 - /// - internal protected virtual void ConfigurePlugins() - { - if (Protocol != null) - { - FoundataionConfig?.ConfigurePlugins(Protocol.ConfigurePlugins()); - } - } - - /// - /// 是否连接成功,注意非通用设备需重写 - /// - public virtual bool IsConnected() - { - return Protocol?.OnLine == true; - } - - /// - /// 暂停 - /// - /// 是否继续 - internal void PauseThread(bool keepRun) - { - lock (this) - { - if (CurrentDevice == null) return; - var str = keepRun == false ? "DeviceTaskPause" : "DeviceTaskContinue"; - Logger?.LogInformation(Localizer[str, DeviceName]); - CurrentDevice.KeepRun = keepRun; - } - } - - public override string ToString() - { - return Protocol?.ToString() ?? base.ToString(); - } - - - #endregion 方法 - - #region 任务管理器传入 - - /// - /// 任务管理器 - /// - public ChannelThread ChannelThread { get; internal set; } - - /// - /// 当前插件目录 - /// - public string Directory { get; internal set; } - - /// - /// 底层驱动配置 - /// - public TouchSocketConfig? FoundataionConfig => ChannelThread?.FoundataionConfig; - - /// - /// 日志 - /// - public ILogger Logger => ChannelThread?.Logger; - - /// - /// 底层日志,需由线程管理器传入 - /// - public LoggerGroup LogMessage => ChannelThread?.LogMessage; - - /// - /// 日志路径 - /// - public string LogPath => ChannelThread?.LogPath; - - /// - /// 写入锁 - /// - protected internal WaitLock WriteLock => ChannelThread?.WriteLock; - - #endregion 任务管理器传入 - - #region 插件生命周期 - - /// - /// 在停止设备线程后执行的异步操作。 - /// - /// 表示异步操作的任务 - internal void AfterStop() - { - lock (this) - { - if (!DisposedValue) - { - try - { - // 执行资源释放操作 - this.SafeDispose(); - } - catch (Exception ex) - { - // 记录 Dispose 方法执行失败的错误信息 - Logger?.LogError(ex, "Dispose"); - } - - // 记录设备线程已停止的信息 - Logger?.LogInformation(Localizer["DeviceTaskStop", DeviceName]); - } - } - } - - /// - /// 在线程开始之前执行异步操作。 - /// - /// 取消操作的令牌。 - /// 表示异步操作的任务。 - internal async ValueTask BeforStartAsync(CancellationToken cancellationToken) - { - // 如果已经执行过初始化,则直接返回 - if (IsBeforStarted) - { - return; - } - - try - { - - // 如果已经取消了操作,则直接返回 - if (cancellationToken.IsCancellationRequested) - { - return; - } - - // 记录设备任务开始信息 - Logger?.LogInformation(Localizer["DeviceTaskStart", DeviceName]); - - var timeout = 30; // 设置超时时间为30秒 - - try - { - // 异步执行初始化操作,并设置超时时间 - await ProtectedBeforStartAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(timeout), cancellationToken).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - } - catch (TimeoutException) - { - // 如果初始化操作超时,则记录警告信息 - Logger?.LogWarning(Localizer["DeviceTaskStartTimeout", DeviceName, timeout]); - } - - // 设置设备状态为当前时间 - CurrentDevice.SetDeviceStatus(TimerX.Now); - } - catch (Exception ex) - { - // 记录执行过程中的异常信息,并设置设备状态为异常 - Logger?.LogWarning(ex, "BeforStart Fail"); - CurrentDevice.SetDeviceStatus(TimerX.Now, 999, ex.Message); - } - finally - { - // 标记已执行初始化 - IsBeforStarted = true; - } - } - - /// - /// 执行任务的间隔操作。 - /// - /// 取消操作的令牌。 - /// 表示异步操作结果的枚举。 - internal protected async ValueTask ExecuteAsync(CancellationToken cancellationToken) - { - try - { - // 如果取消操作被请求,则返回中断状态 - if (cancellationToken.IsCancellationRequested) - { - return ThreadRunReturnTypeEnum.Break; - } - - // 如果标志为停止,则暂停执行 - if (!KeepRun) - { - // 暂停 - return ThreadRunReturnTypeEnum.Continue; - } - - // 再次检查取消操作是否被请求 - if (cancellationToken.IsCancellationRequested) - { - return ThreadRunReturnTypeEnum.Break; - } - - // 获取设备连接状态并更新设备活动时间 - if (IsConnected()) - { - // 如果不是采集设备,则直接更新设备状态为当前时间与错误计数 - if (!IsCollectDevice) - { - CurrentDevice.SetDeviceStatus(TimerX.Now, 0); - } - else - { - // 否则,更新设备活动时间 - CurrentDevice.SetDeviceStatus(TimerX.Now); - } - } - else - { - // 如果设备未连接,则更新设备状态为断开 - if (!IsConnected()) - { - // 如果不是采集设备,则直接更新设备状态为当前时间与错误计数 - if (!IsCollectDevice) - { - CurrentDevice.SetDeviceStatus(TimerX.Now, CurrentDevice.ErrorCount + 1); - } - } - } - - // 再次检查取消操作是否被请求 - if (cancellationToken.IsCancellationRequested) - { - return ThreadRunReturnTypeEnum.Break; - } - - // 执行任务操作 - await ProtectedExecuteAsync(cancellationToken).ConfigureAwait(false); - - // 再次检查取消操作是否被请求 - if (cancellationToken.IsCancellationRequested) - { - return ThreadRunReturnTypeEnum.Break; - } - - // 正常返回None状态 - return ThreadRunReturnTypeEnum.None; - } - catch (OperationCanceledException) - { - return ThreadRunReturnTypeEnum.Break; - } - catch (ObjectDisposedException) - { - return ThreadRunReturnTypeEnum.Break; - } - catch (Exception ex) - { - // 记录异常信息,并更新设备状态为异常 - LogMessage?.LogError(ex, $"Execute"); - CurrentDevice.SetDeviceStatus(TimerX.Now, CurrentDevice.ErrorCount + 1, ex.Message); - return ThreadRunReturnTypeEnum.None; - } - } - - /// - /// 内部初始化 - /// - internal protected virtual void Init(DeviceRunTime device) - { - CurrentDevice = device; - } - - #endregion 插件生命周期 - - #region 插件重写 - - /// - /// 初始化,在开始前执行,异常时会标识重启 - /// - /// 通道,当通道类型为时,传入null - internal protected abstract void Init(IChannel? channel = null); - - /// - /// 获取设备变量打包列表/特殊方法列表 - /// - /// - internal protected virtual void LoadSourceRead(IEnumerable collectVariableRunTimes) - { - } - - /// - protected override void Dispose(bool disposing) - { - Protocol?.Dispose(); - base.Dispose(disposing); - } - - /// - /// 开始通讯执行的方法 - /// - /// - /// - protected virtual async Task ProtectedBeforStartAsync(CancellationToken cancellationToken) - { - if (Protocol?.Channel != null) - await Protocol.Channel.ConnectAsync(3000, cancellationToken).ConfigureAwait(false); - } - - /// - /// 间隔执行 - /// - protected abstract ValueTask ProtectedExecuteAsync(CancellationToken cancellationToken); - - #endregion 插件重写 -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DriverBaseExtension.cs b/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DriverBaseExtension.cs deleted file mode 100644 index 942bda99a..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/Plugin/DriverBaseExtension.cs +++ /dev/null @@ -1,113 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using ThingsGateway.Extension.Generic; -using ThingsGateway.NewLife.Extension; - -namespace ThingsGateway.Gateway.Application; - -public static class DriverBaseExtension -{ - /// - /// 创建插件实例,并根据设备属性设置实例 - /// - /// 当前设备 - /// 插件服务 - /// 插件实例 - public static DriverBase CreateDriver(this DeviceRunTime deviceRunTime, IPluginService pluginService) - { - var driver = pluginService.GetDriver(deviceRunTime.PluginName); - - // 初始化插件配置项 - driver.Init(deviceRunTime); - - // 设置设备属性到插件实例 - pluginService.SetDriverProperties(driver, deviceRunTime.DevicePropertys); - - return driver; - } - - public static void RefreshCollectDeviceRuntime(this CollectDeviceRunTime newDevice, long oldDeviceId) - { - // 从全局设备字典中移除具有相同 Id 的设备 - GlobalData.CollectDevices.RemoveWhere(it => it.Value.Id == oldDeviceId); - GlobalData.Variables.RemoveWhere(it => it.Value.DeviceId == oldDeviceId); - - // 尝试向全局设备字典中添加当前设备,使用设备名称作为键 - GlobalData.CollectDevices.TryAdd(newDevice.Name, newDevice); - foreach (var item in newDevice.VariableRunTimes) - { - GlobalData.Variables.TryAdd(item.Key, item.Value); - } - - } - public static void RefreshBusinessDeviceRuntime(this DeviceRunTime newDevice, long oldDeviceId) - { - // 移除全局业务设备中与当前设备相同Id的项 - GlobalData.BusinessDevices.RemoveWhere(it => it.Value.Id == oldDeviceId); - - // 添加当前设备到全局业务设备字典中 - GlobalData.BusinessDevices.TryAdd(newDevice.Name, newDevice); - } - - public static void RemoveCollectDeviceRuntime(this IEnumerable driverBases) - { - GlobalData.CollectDevices.RemoveWhere(it => driverBases.Any(a => a.DeviceId == it.Value.Id)); - - GlobalData.Variables.RemoveWhere(it => driverBases.Any(a => a.DeviceId == it.Value.DeviceId)); - - } - public static void RemoveBusinessDeviceRuntime(this IEnumerable driverBases) - { - GlobalData.BusinessDevices.RemoveWhere(it => driverBases.Any(a => a.DeviceId == it.Value.Id)); - - GlobalData.Variables.RemoveWhere(it => driverBases.Any(a => a.DeviceId == it.Value.DeviceId)); - - } - public static void RemoveCollectDeviceRuntime(this DriverBase driverBase) - { - GlobalData.CollectDevices.RemoveWhere(it => driverBase.DeviceId == it.Value.Id); - - } - public static void RemoveBusinessDeviceRuntime(this DriverBase driverBase) - { - GlobalData.BusinessDevices.RemoveWhere(it => driverBase.DeviceId == it.Value.Id); - } - - /// - /// 获取设备的属性值 - /// - /// 当前设备 - /// 属性名称 - /// 属性值,如果不存在则返回null - public static string? GetDevicePropertyValue(this DeviceRunTime collectDeviceRunTime, string propertyName) - { - if (collectDeviceRunTime == null || propertyName.IsNullOrWhiteSpace()) - return null; - - // 尝试获取指定属性的值 - collectDeviceRunTime.DevicePropertys.TryGetValue(propertyName, out var value); - return value; // 返回属性值 - } - - - -} - - -public interface IDynamicModel -{ - IEnumerable GetList(IEnumerable datas); -} - -public interface IDynamicModelData -{ - dynamic GeData(object datas); -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/SeedData/Gateway/seed_gateway_resource.json b/src/Gateway/ThingsGateway.Gateway.Application/SeedData/Gateway/seed_gateway_resource.json index 4ceff8f73..cf0cfad1d 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/SeedData/Gateway/seed_gateway_resource.json +++ b/src/Gateway/ThingsGateway.Gateway.Application/SeedData/Gateway/seed_gateway_resource.json @@ -1,806 +1,43 @@ { "RECORDS": [ { - "Id": 6, - "ParentId": 0, + "Id": "6", + "ParentId": "0", + "Module": "0", "Title": "物联网关", - "Code": "System", - "Category": "MODULE", - "Target": "_self", - "Href": "", "Icon": "fa-solid fa-house-user", - - "CreateTime": null, + "Code": "System", + "Category": "0", + "Target": "0", + "CreateTime": "2025-01-14 20:16:18.361", + "CreateUserId": "0", + "IsDelete": "0", + "SortCode": "0" + }, + { + "Id": 61000012000, + "ParentId": 6100001, + "Module": 6, + "Title": "插件调试", + "Icon": "fas fa-bug", + "Code": "System", + "Category": 1, + "Target": 0, + "NavLinkMatch": 1, + "Href": "/gateway/plugindebug", + "CreateTime": "5/11/2024 04:08:09.92", "CreateUser": null, "CreateUserId": 0, "IsDelete": "0", - "UpdateTime": null, + "UpdateTime": "5/11/2024 04:28:42.827", "UpdateUser": null, "UpdateUserId": null, "SortCode": 0, "ExtJson": null }, - - { - "Id": 61100022003, - "ParentId": 6100001, - "Module": 6, - "Title": "系统配置", - "Icon": "fas fa-journal-whills", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/gateway/system", - "CreateTime": "5/11/2024 04:08:09.88", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.79", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": -1, - "ExtJson": null - }, - { - "Id": 61000022002001, - "ParentId": 61000022002, - "Module": 6, - "Title": "写入变量", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000022001006, - "ParentId": 61000022001, - "Module": 6, - "Title": "删除缓存", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000022001005, - "ParentId": 61000022001, - "Module": 6, - "Title": "切换冗余", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000022001004, - "ParentId": 61000022001, - "Module": 6, - "Title": "暂停", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000022001003, - "ParentId": 61000022001, - "Module": 6, - "Title": "通道日志", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000022001002, - "ParentId": 61000022001, - "Module": 6, - "Title": "重启", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000022001001, - "ParentId": 61000022001, - "Module": 6, - "Title": "插件页面", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012005007, - "ParentId": 61000012005, - "Module": 6, - "Title": "测试", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012005006, - "ParentId": 61000012005, - "Module": 6, - "Title": "清空", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012005005, - "ParentId": 61000012005, - "Module": 6, - "Title": "导入", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012005004, - "ParentId": 61000012005, - "Module": 6, - "Title": "导出", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012005003, - "ParentId": 61000012005, - "Module": 6, - "Title": "删除", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012005002, - "ParentId": 61000012005, - "Module": 6, - "Title": "编辑", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012005001, - "ParentId": 61000012005, - "Module": 6, - "Title": "新增", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012004006, - "ParentId": 61000012004, - "Module": 6, - "Title": "清空", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012004005, - "ParentId": 61000012004, - "Module": 6, - "Title": "导入", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012004004, - "ParentId": 61000012004, - "Module": 6, - "Title": "导出", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012004003, - "ParentId": 61000012004, - "Module": 6, - "Title": "删除", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012004002, - "ParentId": 61000012004, - "Module": 6, - "Title": "编辑", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012004001, - "ParentId": 61000012004, - "Module": 6, - "Title": "新增", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012003006, - "ParentId": 61000012003, - "Module": 6, - "Title": "清空", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012003005, - "ParentId": 61000012003, - "Module": 6, - "Title": "导入", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.83", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012003004, - "ParentId": 61000012003, - "Module": 6, - "Title": "导出", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012003003, - "ParentId": 61000012003, - "Module": 6, - "Title": "删除", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012003002, - "ParentId": 61000012003, - "Module": 6, - "Title": "编辑", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012003001, - "ParentId": 61000012003, - "Module": 6, - "Title": "新增", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012002006, - "ParentId": 61000012002, - "Module": 6, - "Title": "清空", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012002005, - "ParentId": 61000012002, - "Module": 6, - "Title": "导入", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012002004, - "ParentId": 61000012002, - "Module": 6, - "Title": "导出", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012002003, - "ParentId": 61000012002, - "Module": 6, - "Title": "删除", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012002002, - "ParentId": 61000012002, - "Module": 6, - "Title": "编辑", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012002001, - "ParentId": 61000012002, - "Module": 6, - "Title": "新增", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012001002, - "ParentId": 61000012001, - "Module": 6, - "Title": "导出", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000012001001, - "ParentId": 61000012001, - "Module": 6, - "Title": "新增", - "Icon": null, - "Code": "System", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": null, - "ExtJson": null - }, - { - "Id": 61000032002, - "ParentId": 6100003, - "Module": 6, - "Title": "RPC日志", - "Icon": "fas fa-journal-whills", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/gateway/rpclog", - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 2, - "ExtJson": null - }, - { - "Id": 61000032001, - "ParentId": 6100003, - "Module": 6, - "Title": "后台日志", - "Icon": "fas fa-journal-whills", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/gateway/backendlog", - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, { "Id": 61000022003, - "ParentId": 6100002, + "ParentId": 6100001, "Module": 6, "Title": "实时报警", "Icon": "fas fa-journal-whills", @@ -816,133 +53,7 @@ "UpdateTime": "5/11/2024 04:28:42.827", "UpdateUser": null, "UpdateUserId": null, - "SortCode": 3, - "ExtJson": null - }, - { - "Id": 61000022002, - "ParentId": 6100002, - "Module": 6, - "Title": "实时数据", - "Icon": "fas fa-journal-whills", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 0, - "Href": "/gateway/variableruntime", - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 2, - "ExtJson": null - }, - { - "Id": 61000022001, - "ParentId": 6100002, - "Module": 6, - "Title": "设备状态", - "Icon": "fas fa-journal-whills", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/gateway/devicestatus", - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 61000012005, - "ParentId": 6100001, - "Module": 6, - "Title": "变量管理", - "Icon": "fas fa-bezier-curve", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 0, - "Href": "/gateway/variable", - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 4, - "ExtJson": null - }, - { - "Id": 61000012004, - "ParentId": 6100001, - "Module": 6, - "Title": "业务设备", - "Icon": "fas fa-bezier-curve", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/gateway/businessdevice", - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 3, - "ExtJson": null - }, - { - "Id": 61000012003, - "ParentId": 6100001, - "Module": 6, - "Title": "采集设备", - "Icon": "fas fa-bezier-curve", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/gateway/collectdevice", - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 2, - "ExtJson": null - }, - { - "Id": 61000012002, - "ParentId": 6100001, - "Module": 6, - "Title": "通道管理", - "Icon": "fas fa-bezier-curve", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/gateway/channel", - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, + "SortCode": 5, "ExtJson": null }, { @@ -967,676 +78,181 @@ "ExtJson": null }, { - "Id": 61000012000, - "ParentId": 6100001, - "Module": 6, - "Title": "插件调试", - "Icon": "fas fa-bug", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/gateway/driverdebug", - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 0, - "ExtJson": null + "Id": "61000012004006", + "ParentId": "61000012004", + "Module": "6", + "Title": "写入变量", + "Code": "1", + "Category": "2", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUser": "SuperAdmin", + "CreateUserId": "212725263002001", + "IsDelete": "0" }, { - "Id": 6100003, - "ParentId": 0, - "Module": 6, + "Id": "61000012004005", + "ParentId": "61000012004", + "Module": "6", + "Title": "导入", + "Code": "1", + "Category": "2", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUser": "SuperAdmin", + "CreateUserId": "212725263002001", + "IsDelete": "0" + }, + { + "Id": "61000012004004", + "ParentId": "61000012004", + "Module": "6", + "Title": "导出", + "Code": "1", + "Category": "2", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUser": "SuperAdmin", + "CreateUserId": "212725263002001", + "IsDelete": "0" + }, + { + "Id": "61000012004003", + "ParentId": "61000012004", + "Module": "6", + "Title": "删除", + "Code": "1", + "Category": "2", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUser": "SuperAdmin", + "CreateUserId": "212725263002001", + "IsDelete": "0" + }, + { + "Id": "61000012004002", + "ParentId": "61000012004", + "Module": "6", + "Title": "编辑", + "Code": "1", + "Category": "2", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUser": "SuperAdmin", + "CreateUserId": "212725263002001", + "IsDelete": "0" + }, + { + "Id": "61000012004001", + "ParentId": "61000012004", + "Module": "6", + "Title": "新增", + "Code": "1", + "Category": "2", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUser": "SuperAdmin", + "CreateUserId": "212725263002001", + "IsDelete": "0" + }, + { + "Id": "61000012001002", + "ParentId": "61000012001", + "Module": "6", + "Title": "导出", + "Code": "1", + "Category": "2", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUser": "SuperAdmin", + "CreateUserId": "212725263002001", + "IsDelete": "0" + }, + { + "Id": "61000012001001", + "ParentId": "61000012001", + "Module": "6", + "Title": "新增", + "Code": "1", + "Category": "2", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUser": "SuperAdmin", + "CreateUserId": "212725263002001", + "IsDelete": "0" + }, + { + "Id": "61000032002", + "ParentId": "6100003", + "Module": "6", + "Title": "RPC日志", + "Icon": "fas fa-journal-whills", + "Code": "1", + "Category": "1", + "Target": "0", + "NavLinkMatch": "1", + "Href": "/gateway/rpclog", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUserId": "0", + "IsDelete": "0", + "SortCode": "2" + }, + { + "Id": "61000032001", + "ParentId": "6100003", + "Module": "6", + "Title": "后台日志", + "Icon": "fas fa-journal-whills", + "Code": "1", + "Category": "1", + "Target": "0", + "NavLinkMatch": "1", + "Href": "/gateway/backendlog", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUserId": "0", + "IsDelete": "0", + "SortCode": "1" + }, + { + "Id": "61000012004", + "ParentId": "6100001", + "Module": "6", + "Title": "网关监控", + "Icon": "fas fa-bezier-curve", + "Code": "1", + "Category": "1", + "Target": "0", + "NavLinkMatch": "1", + "Href": "/gateway/monitor", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUserId": "0", + "IsDelete": "0", + "UpdateTime": "2025-01-14 22:32:22.0148207", + "UpdateUser": "SuperAdmin", + "UpdateUserId": "212725263002001", + "SortCode": "3" + }, + { + "Id": "6100003", + "ParentId": "0", + "Module": "6", "Title": "网关日志", "Icon": "fas fa-journal-whills", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, + "Code": "1", + "Category": "1", + "Target": "0", + "NavLinkMatch": "1", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUserId": "0", "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 102, - "ExtJson": null + "SortCode": "102" }, { - "Id": 6100002, - "ParentId": 0, - "Module": 6, - "Title": "网关状态", - "Icon": "fa-solid fa-users-gear", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, - "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 101, - "ExtJson": null - }, - { - "Id": 6100001, - "ParentId": 0, - "Module": 6, + "Id": "6100001", + "ParentId": "0", + "Module": "6", "Title": "网关管理", "Icon": "fa-solid fa-users-gear", - "Code": "System", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": null, - "CreateTime": "5/11/2024 04:08:09.92", - "CreateUser": null, - "CreateUserId": 0, + "Code": "1", + "Category": "1", + "Target": "0", + "NavLinkMatch": "1", + "CreateTime": "2025-01-14 20:16:18.362", + "CreateUserId": "0", "IsDelete": "0", - "UpdateTime": "5/11/2024 04:28:42.827", - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 100, - "ExtJson": null - }, - { - "Id": 608610055675973, - "ParentId": 0, - "Module": 6, - "Title": "权限管理", - "Icon": "fa-solid fa-users-gear", - "Code": "34R8F3KKaz", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.397", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 103, - "ExtJson": null - }, - { - "Id": 608610055680085, - "ParentId": 608610055680078, - "Module": 6, - "Title": "删除", - "Icon": null, - "Code": "91320irmQJ", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 3, - "ExtJson": null - }, - { - "Id": 608610055680084, - "ParentId": 608610055680078, - "Module": 6, - "Title": "编辑", - "Icon": null, - "Code": "F9auu3jy8u", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 2, - "ExtJson": null - }, - { - "Id": 608610055680083, - "ParentId": 608610055680078, - "Module": 6, - "Title": "查询", - "Icon": null, - "Code": "1HK67pPN3U", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055680082, - "ParentId": 608610055680078, - "Module": 6, - "Title": "新增", - "Icon": null, - "Code": "r3wXnioQ8J", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055680081, - "ParentId": 608610055680078, - "Module": 6, - "Title": "授权Api", - "Icon": null, - "Code": "Op186zJj26", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 0, - "ExtJson": null - }, - { - "Id": 608610055680080, - "ParentId": 608610055680078, - "Module": 6, - "Title": "授权用户", - "Icon": null, - "Code": "6Xmk1tH3kb", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 0, - "ExtJson": null - }, - { - "Id": 608610055680079, - "ParentId": 608610055680078, - "Module": 6, - "Title": "授权资源", - "Icon": null, - "Code": "4jl595cskE", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 0, - "ExtJson": null - }, - { - "Id": 608610055680078, - "ParentId": 608610055675973, - "Module": 6, - "Title": "角色管理", - "Icon": "fa-solid fa-users-gear", - "Code": "g9ruOH29Yz", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/admin/role", - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 2, - "ExtJson": null - }, - { - "Id": 608610055680077, - "ParentId": 608610055680073, - "Module": 6, - "Title": "删除", - "Icon": null, - "Code": "9E5uA0dugU", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 3, - "ExtJson": null - }, - { - "Id": 608610055680076, - "ParentId": 608610055680073, - "Module": 6, - "Title": "编辑", - "Icon": null, - "Code": "J7XxGW1PrN", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 2, - "ExtJson": null - }, - { - "Id": 608610055680075, - "ParentId": 608610055680073, - "Module": 6, - "Title": "新增", - "Icon": null, - "Code": "1us8wr445n", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055680074, - "ParentId": 608610055680073, - "Module": 6, - "Title": "查询", - "Icon": null, - "Code": "Kp0A7X4j38", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055680073, - "ParentId": 608610055675973, - "Module": 6, - "Title": "机构管理", - "Icon": "fa-solid fa-user-gear", - "Code": "qCvpBAAi96", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/admin/org", - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055680072, - "ParentId": 608610055675983, - "Module": 6, - "Title": "删除", - "Icon": null, - "Code": "D17eKxr03Z", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 3, - "ExtJson": null - }, - { - "Id": 608610055680071, - "ParentId": 608610055675983, - "Module": 6, - "Title": "编辑", - "Icon": null, - "Code": "BcWV3Xe7Fs", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 2, - "ExtJson": null - }, - { - "Id": 608610055680070, - "ParentId": 608610055675983, - "Module": 6, - "Title": "新增", - "Icon": null, - "Code": "X6ySOA55Uu", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055680069, - "ParentId": 608610055675983, - "Module": 6, - "Title": "查询", - "Icon": null, - "Code": "m5rhU5eVlj", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055675983, - "ParentId": 608610055675973, - "Module": 6, - "Title": "职位管理", - "Icon": "fa-solid fa-user-gear", - "Code": "WycbQ03v3v", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/admin/position", - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055675982, - "ParentId": 608610055675974, - "Module": 6, - "Title": "删除", - "Icon": null, - "Code": "7eacu1j6a0", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 3, - "ExtJson": null - }, - { - "Id": 608610055675981, - "ParentId": 608610055675974, - "Module": 6, - "Title": "编辑", - "Icon": null, - "Code": "I9Qx4UIk0i", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 2, - "ExtJson": null - }, - { - "Id": 608610055675980, - "ParentId": 608610055675974, - "Module": 6, - "Title": "新增", - "Icon": null, - "Code": "hd8Du4w17Z", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055675979, - "ParentId": 608610055675974, - "Module": 6, - "Title": "查询", - "Icon": null, - "Code": "POdzOb4P9T", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null - }, - { - "Id": 608610055675978, - "ParentId": 608610055675974, - "Module": 6, - "Title": "授权Api", - "Icon": null, - "Code": "IsM9zLgDNw", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 0, - "ExtJson": null - }, - { - "Id": 608610055675977, - "ParentId": 608610055675974, - "Module": 6, - "Title": "授权资源", - "Icon": null, - "Code": "MHmOx74s3Y", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 0, - "ExtJson": null - }, - { - "Id": 608610055675976, - "ParentId": 608610055675974, - "Module": 6, - "Title": "授权角色", - "Icon": null, - "Code": "38yg1d1YH8", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 0, - "ExtJson": null - }, - { - "Id": 608610055675975, - "ParentId": 608610055675974, - "Module": 6, - "Title": "重置密码", - "Icon": null, - "Code": "0z3V00in43", - "Category": 2, - "Target": null, - "NavLinkMatch": null, - "Href": null, - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 0, - "ExtJson": null - }, - { - "Id": 608610055675974, - "ParentId": 608610055675973, - "Module": 6, - "Title": "用户管理", - "Icon": "fa-solid fa-user-gear", - "Code": "pQLNfSYZ0v", - "Category": 1, - "Target": 0, - "NavLinkMatch": 1, - "Href": "/admin/user", - "CreateTime": "5/11/2024 04:20:41.4", - "CreateUser": "SuperAdmin", - "CreateUserId": 212725263002001, - "IsDelete": "0", - "UpdateTime": null, - "UpdateUser": null, - "UpdateUserId": null, - "SortCode": 1, - "ExtJson": null + "SortCode": "100" } ] -} +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/SeedData/SysResourceSeedData.cs b/src/Gateway/ThingsGateway.Gateway.Application/SeedData/SysResourceSeedData.cs index e657119cb..279e42c08 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/SeedData/SysResourceSeedData.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/SeedData/SysResourceSeedData.cs @@ -19,9 +19,8 @@ public class SysResourceSeedData : ISqlSugarEntitySeedData public IEnumerable SeedData() { var data1 = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Gateway", "seed_gateway_resource.json")); - var data2 = SeedDataUtil.GetSeedData(PathExtensions.CombinePathWithOs("SeedData", "Gateway", "seed_gateway_resourcebutton.json")); var assembly = GetType().Assembly; - return SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Gateway.seed_gateway_resource.json")).Concat(SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Gateway.seed_gateway_resourcebutton.json"))).Concat(data1).Concat(data2); + return SeedDataUtil.GetSeedDataByJson(SeedDataUtil.GetManifestResourceStream(assembly, "SeedData.Gateway.seed_gateway_resource.json")).Concat(data1); } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/ChannelRuntimeService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/ChannelRuntimeService.cs new file mode 100644 index 000000000..27fa0a3aa --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/ChannelRuntimeService.cs @@ -0,0 +1,183 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Mapster; + +using Microsoft.AspNetCore.Components.Forms; + +using ThingsGateway.Extension.Generic; + +namespace ThingsGateway.Gateway.Application; + +public class ChannelRuntimeService : IChannelRuntimeService +{ + private WaitLock WaitLock { get; set; } = new WaitLock(); + public async Task BatchEditAsync(IEnumerable models, Channel oldModel, Channel model) + { + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + models = models.Adapt>(); + oldModel = oldModel.Adapt(); + model = model.Adapt(); + var result = await GlobalData.ChannelService.BatchEditAsync(models, oldModel, model).ConfigureAwait(false); + + var newChannelRuntimes = (await GlobalData.ChannelService.GetAllAsync().ConfigureAwait(false)).Where(a => models.Select(a => a.Id).ToHashSet().Contains(a.Id)).Adapt>(); + + //批量修改之后,需要重新加载通道 + foreach (var newChannelRuntime in newChannelRuntimes) + { + if (GlobalData.Channels.TryGetValue(newChannelRuntime.Id, out var channelRuntime)) + { + channelRuntime.Dispose(); + newChannelRuntime.Init(); + newChannelRuntime.DeviceRuntimes.AddRange(channelRuntime.DeviceRuntimes); + } + else + { + newChannelRuntime.Init(); + + } + + } + + //根据条件重启通道线程 + await GlobalData.ChannelThreadManage.RestartChannelAsync(newChannelRuntimes).ConfigureAwait(false); + + return true; + } + finally + { + WaitLock.Release(); + } + } + + public async Task DeleteChannelAsync(IEnumerable ids) + { + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + ids = ids.ToHashSet(); + var result = await GlobalData.ChannelService.DeleteChannelAsync(ids).ConfigureAwait(false); + + //批量修改之后,需要重新加载通道 + foreach (var id in ids) + { + if (GlobalData.Channels.TryGetValue(id, out var channelRuntime)) + { + channelRuntime.Dispose(); + + //也需要删除设备和变量 + channelRuntime.DeviceRuntimes.ParallelForEach(a => + { + + a.Value.VariableRuntimes.ParallelForEach(v => v.Value.Dispose()); + a.Value.Dispose(); + + }); + } + + } + + //根据条件重启通道线程 + await GlobalData.ChannelThreadManage.RemoveChannelAsync(ids).ConfigureAwait(false); + + return true; + + } + finally + { + WaitLock.Release(); + } + } + public Task> PreviewAsync(IBrowserFile browserFile) => GlobalData.ChannelService.PreviewAsync(browserFile); + + public Task> ExportChannelAsync(ExportFilter exportFilter) => GlobalData.ChannelService.ExportChannelAsync(exportFilter); + public Task ExportMemoryStream(List data) => + GlobalData.ChannelService.ExportMemoryStream(data); + + public async Task ImportChannelAsync(Dictionary input) + { + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + var result = await GlobalData.ChannelService.ImportChannelAsync(input).ConfigureAwait(false); + + var newChannelRuntimes = (await GlobalData.ChannelService.GetAllAsync().ConfigureAwait(false)).Where(a => result.Contains(a.Id)).Adapt>(); + + //批量修改之后,需要重新加载通道 + foreach (var newChannelRuntime in newChannelRuntimes) + { + if (GlobalData.Channels.TryGetValue(newChannelRuntime.Id, out var channelRuntime)) + { + channelRuntime.Dispose(); + newChannelRuntime.Init(); + newChannelRuntime.DeviceRuntimes.AddRange(channelRuntime.DeviceRuntimes); + } + else + { + newChannelRuntime.Init(); + + } + + } + + //根据条件重启通道线程 + await GlobalData.ChannelThreadManage.RestartChannelAsync(newChannelRuntimes).ConfigureAwait(false); + + } + + finally + { + WaitLock.Release(); + } + } + public async Task SaveChannelAsync(Channel input, ItemChangedType type) + { + try + { + input = input.Adapt(); + await WaitLock.WaitAsync().ConfigureAwait(false); + + var result = await GlobalData.ChannelService.SaveChannelAsync(input, type).ConfigureAwait(false); + + var newChannelRuntime = (await GlobalData.ChannelService.GetAllAsync().ConfigureAwait(false)).FirstOrDefault(a => a.Id == input.Id)?.Adapt(); + + if (newChannelRuntime == null) return false; + //批量修改之后,需要重新加载通道 + if (GlobalData.Channels.TryGetValue(newChannelRuntime.Id, out var channelRuntime)) + { + channelRuntime.Dispose(); + newChannelRuntime.Init(); + newChannelRuntime.DeviceRuntimes.AddRange(channelRuntime.DeviceRuntimes); + } + else + { + newChannelRuntime.Init(); + + } + + + //根据条件重启通道线程 + await GlobalData.ChannelThreadManage.RestartChannelAsync(newChannelRuntime).ConfigureAwait(false); + + return true; + } + finally + { + WaitLock.Release(); + } + } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/ChannelService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/ChannelService.cs index c994cbb9f..79d071112 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/ChannelService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/ChannelService.cs @@ -35,18 +35,7 @@ namespace ThingsGateway.Gateway.Application; internal sealed class ChannelService : BaseService, IChannelService { private readonly IDispatchService _dispatchService; - private ISysUserService _sysUserService; - private ISysUserService SysUserService - { - get - { - if (_sysUserService == null) - { - _sysUserService = App.GetService(); - } - return _sysUserService; - } - } + /// public ChannelService( IDispatchService? dispatchService @@ -55,43 +44,20 @@ internal sealed class ChannelService : BaseService, IChannelService _dispatchService = dispatchService; } - /// - [OperDesc("SaveChannel", localizerType: typeof(Channel), isRecordPar: false)] - public async Task BatchEditAsync(IEnumerable models, Channel oldModel, Channel model) + #region CURD + + + public async Task UpdateLogAsync(long channelId, bool logEnable, LogLevel logLevel) { - var differences = models.GetDiffProperty(oldModel, model); - if (differences?.Count > 0) - { - using var db = GetDB(); - - var result = (await db.Updateable(models.ToList()).UpdateColumns(differences.Select(a => a.Key).ToArray()).ExecuteCommandAsync().ConfigureAwait(false)) > 0; - if (result) - { - DeleteChannelFromCache(); - } - return result; - } - else - { - return true; - } - } - - [OperDesc("ClearChannel", localizerType: typeof(Channel))] - public async Task ClearChannelAsync() - { - var deviceService = App.RootServices.GetRequiredService(); - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - using var db = GetDB(); + //事务 var result = await db.UseTranAsync(async () => { - var data = GetAll() - .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 - .WhereIf(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId).Select(a => a.Id).ToList(); - await db.Deleteable(data).ExecuteCommandAsync().ConfigureAwait(false); - await deviceService.DeleteByChannelIdAsync(data, db).ConfigureAwait(false); + //更新数据库 + + await db.Updateable().SetColumns(it => new Channel() { LogEnable = logEnable, LogLevel = logLevel }).Where(a => a.Id == channelId).ExecuteCommandAsync().ConfigureAwait(false); + }).ConfigureAwait(false); if (result.IsSuccess)//如果成功了 { @@ -104,16 +70,60 @@ internal sealed class ChannelService : BaseService, IChannelService } } - [OperDesc("DeleteChannel", localizerType: typeof(Channel))] + /// + [OperDesc("SaveChannel", localizerType: typeof(Channel), isRecordPar: false)] + public async Task BatchEditAsync(IEnumerable models, Channel oldModel, Channel model) + { + var differences = models.GetDiffProperty(oldModel, model); + if (differences?.Count > 0) + { + using var db = GetDB(); + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + + //事务 + var result = await db.UseTranAsync(async () => + { + var data = models + .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + .WhereIf(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ToList(); + + //更新数据库 + await db.Updateable(data).UpdateColumns(differences.Select(a => a.Key).ToArray()).ExecuteCommandAsync().ConfigureAwait(false); + + }).ConfigureAwait(false); + if (result.IsSuccess)//如果成功了 + { + DeleteChannelFromCache(); + return true; + } + else + { + //写日志 + throw new(result.ErrorMessage, result.ErrorException); + } + } + else + { + return true; + } + } + + + [OperDesc("DeleteChannel", localizerType: typeof(Channel), isRecordPar: false)] public async Task DeleteChannelAsync(IEnumerable ids) { var deviceService = App.RootServices.GetRequiredService(); + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); using var db = GetDB(); //事务 var result = await db.UseTranAsync(async () => { - await db.Deleteable().Where(a => ids.Contains(a.Id)).ExecuteCommandAsync().ConfigureAwait(false); + await db.Deleteable() + .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .Where(a => ids.Contains(a.Id)).ExecuteCommandAsync().ConfigureAwait(false); await deviceService.DeleteByChannelIdAsync(ids, db).ConfigureAwait(false); }).ConfigureAwait(false); if (result.IsSuccess)//如果成功了 @@ -139,50 +149,42 @@ internal sealed class ChannelService : BaseService, IChannelService /// 从缓存/数据库获取全部信息 /// /// 列表 - public List GetAll() + public async Task> GetAllAsync(SqlSugarClient db = null) { var key = ThingsGatewayCacheConst.Cache_Channel; var channels = App.CacheService.Get>(key); if (channels == null) { - using var db = GetDB(); - channels = db.Queryable().ToList(); + db ??= GetDB(); + channels = await db.Queryable().ToListAsync().ConfigureAwait(false); App.CacheService.Set(key, channels); } return channels; } - /// - /// 从缓存/数据库获取全部信息 - /// - /// 列表 - public async Task> GetAllByOrgAsync() - { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - return GetAll() - .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.CreateOrgId)) - .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId).ToList(); - } - - public Channel? GetChannelById(long id) - { - var data = GetAll(); - return data?.FirstOrDefault(x => x.Id == id); - } - /// /// 报表查询 /// - /// 查询条件 - /// 查询条件 - public async Task> PageAsync(QueryPageOptions option, FilterKeyValueAction filterKeyValueAction = null) + /// 查询条件 + public async Task> PageAsync(ExportFilter exportFilter) { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - return await QueryAsync(option, a => a - .WhereIF(!option.SearchText.IsNullOrWhiteSpace(), a => a.Name.Contains(option.SearchText!)) - .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + HashSet? channel = null; + if (exportFilter.PluginType != null) + { + var pluginInfo = GlobalData.PluginService.GetList(exportFilter.PluginType).Select(a => a.FullName).ToHashSet(); + channel = (await GetAllAsync().ConfigureAwait(false)).Where(a => pluginInfo.Contains(a.PluginName)).Select(a => a.Id).ToHashSet(); + } + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + return await QueryAsync(exportFilter.QueryPageOptions, a => a + .WhereIF(!exportFilter.QueryPageOptions.SearchText.IsNullOrWhiteSpace(), a => a.Name.Contains(exportFilter.QueryPageOptions.SearchText!)) + .WhereIF(!exportFilter.PluginName.IsNullOrWhiteSpace(), a => a.PluginName == exportFilter.PluginName) + .WhereIF(channel != null, a => channel.Contains(a.Id)) + .WhereIF(exportFilter.ChannelId != null, a => a.Id == exportFilter.ChannelId) + + .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) - , filterKeyValueAction).ConfigureAwait(false); + + , exportFilter.FilterKeyValueAction).ConfigureAwait(false); } /// @@ -193,12 +195,16 @@ internal sealed class ChannelService : BaseService, IChannelService [OperDesc("SaveChannel", localizerType: typeof(Channel))] public async Task SaveChannelAsync(Channel input, ItemChangedType type) { - - //验证 - CheckInput(input); + if ((await GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Name).TryGetValue(input.Name, out var channel)) + { + if (channel.Id != input.Id) + { + throw Oops.Bah(Localizer["NameDump", channel.Name]); + } + } if (type == ItemChangedType.Update) - await SysUserService.CheckApiDataScopeAsync(input.CreateOrgId, input.CreateUserId).ConfigureAwait(false); + await GlobalData.SysUserService.CheckApiDataScopeAsync(input.CreateOrgId, input.CreateUserId).ConfigureAwait(false); if (await base.SaveAsync(input, type).ConfigureAwait(false)) { @@ -208,75 +214,15 @@ internal sealed class ChannelService : BaseService, IChannelService return false; } - private void CheckInput(Channel input) - { - - if (input.ChannelType == ChannelTypeEnum.TcpClient) - { - if (string.IsNullOrEmpty(input.RemoteUrl)) - throw Oops.Bah(Localizer["RemoteUrlNotNull"]); - } - else if (input.ChannelType == ChannelTypeEnum.TcpService) - { - if (string.IsNullOrEmpty(input.BindUrl)) - throw Oops.Bah(Localizer["BindUrlNotNull"]); - } - else if (input.ChannelType == ChannelTypeEnum.UdpSession) - { - if (string.IsNullOrEmpty(input.BindUrl) && string.IsNullOrEmpty(input.RemoteUrl)) - throw Oops.Bah(Localizer["BindUrlOrRemoteUrlNotNull"]); - } - else if (input.ChannelType == ChannelTypeEnum.SerialPort) - { - if (string.IsNullOrEmpty(input.PortName)) - throw Oops.Bah(Localizer["PortNameNotNull"]); - if (input.BaudRate == null) - throw Oops.Bah(Localizer["BaudRateNotNull"]); - if (input.DataBits == null) - throw Oops.Bah(Localizer["DataBitsNotNull"]); - if (input.Parity == null) - throw Oops.Bah(Localizer["ParityNotNull"]); - if (input.StopBits == null) - throw Oops.Bah(Localizer["StopBitsNotNull"]); - } - } - - #region API查询 - - public async Task> PageAsync(ChannelPageInput input) - { - using var db = GetDB(); - var query = await GetPageAsync(db, input).ConfigureAwait(false); - return await query.ToPagedListAsync(input.Current, input.Size).ConfigureAwait(false);//分页 - } - - /// - private async Task> GetPageAsync(SqlSugarClient db, ChannelPageInput input) - { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - ISugarQueryable query = db.Queryable() - .WhereIF(!string.IsNullOrEmpty(input.Name), u => u.Name.Contains(input.Name)) - .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 - .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) - .WhereIF(input.ChannelType != null, u => u.ChannelType == input.ChannelType); - for (int i = input.SortField.Count - 1; i >= 0; i--) - { - query = query.OrderByIF(!string.IsNullOrEmpty(input.SortField[i]), $"{input.SortField[i]} {(input.SortDesc[i] ? "desc" : "asc")}"); - } - query = query.OrderBy(it => it.Id, OrderByType.Desc);//排序 - - return query; - } - - #endregion API查询 + #endregion #region 导出 /// [OperDesc("ExportChannel", isRecordPar: false, localizerType: typeof(Channel))] - public async Task> ExportChannelAsync(QueryPageOptions options, FilterKeyValueAction filterKeyValueAction = null) + public async Task> ExportChannelAsync(ExportFilter exportFilter) { - var data = await PageAsync(options, filterKeyValueAction).ConfigureAwait(false); + var data = await PageAsync(exportFilter).ConfigureAwait(false); return ExportChannelCore(data.Items); } @@ -348,7 +294,7 @@ internal sealed class ChannelService : BaseService, IChannelService /// [OperDesc("ImportChannel", isRecordPar: false, localizerType: typeof(Channel))] - public async Task ImportChannelAsync(Dictionary input) + public async Task> ImportChannelAsync(Dictionary input) { var channels = new List(); foreach (var item in input) @@ -366,6 +312,7 @@ internal sealed class ChannelService : BaseService, IChannelService await db.Fastest().PageSize(100000).BulkCopyAsync(insertData).ConfigureAwait(false); await db.Fastest().PageSize(100000).BulkUpdateAsync(upData).ConfigureAwait(false); DeleteChannelFromCache(); + return channels.Select(a => a.Id).ToHashSet(); } /// @@ -374,10 +321,9 @@ internal sealed class ChannelService : BaseService, IChannelService var path = await browserFile.StorageLocal().ConfigureAwait(false); try { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); var sheetNames = MiniExcel.GetSheetNames(path); - var channelDicts = GetAll().ToDictionary(a => a.Name); + var channelDicts = (await GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Name); //导入检验结果 Dictionary ImportPreviews = new(); //设备页 @@ -482,14 +428,6 @@ internal sealed class ChannelService : BaseService, IChannelService } } + #endregion 导入 } - -public class ChannelPageInput : BasePageInput -{ - /// - public ChannelTypeEnum? ChannelType { get; set; } - - /// - public string Name { get; set; } -} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/IChannelRuntimeService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/IChannelRuntimeService.cs new file mode 100644 index 000000000..515f677c5 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/IChannelRuntimeService.cs @@ -0,0 +1,47 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.AspNetCore.Components.Forms; + +namespace ThingsGateway.Gateway.Application; + +public interface IChannelRuntimeService +{ + /// + /// 保存通道 + /// + /// 通道对象 + /// 保存类型 + Task SaveChannelAsync(Channel input, ItemChangedType type); + + /// + /// 批量修改 + /// + /// 列表 + /// 旧数据 + /// 新数据 + /// + Task BatchEditAsync(IEnumerable models, Channel oldModel, Channel model); + + /// + /// 删除通道 + /// + Task DeleteChannelAsync(IEnumerable ids); + + /// + /// 导入通道数据 + /// + Task ImportChannelAsync(Dictionary input); + Task> ExportChannelAsync(ExportFilter exportFilter); + Task> PreviewAsync(IBrowserFile browserFile); + Task ExportMemoryStream(List data); +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/IChannelService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/IChannelService.cs index 7ba2670e7..c57edfb21 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/IChannelService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Channel/IChannelService.cs @@ -12,12 +12,14 @@ using BootstrapBlazor.Components; using Microsoft.AspNetCore.Components.Forms; +using SqlSugar; + namespace ThingsGateway.Gateway.Application; /// /// 通道服务 /// -public interface IChannelService +internal interface IChannelService { /// /// 批量修改 @@ -28,11 +30,6 @@ public interface IChannelService /// Task BatchEditAsync(IEnumerable models, Channel oldModel, Channel model); - /// - /// 清除所有通道 - /// - Task ClearChannelAsync(); - /// /// 删除通道 /// @@ -48,7 +45,7 @@ public interface IChannelService /// 导出通道为文件流结果 /// /// 文件流结果 - Task> ExportChannelAsync(QueryPageOptions options, FilterKeyValueAction filterKeyValueAction = null); + Task> ExportChannelAsync(ExportFilter exportFilter); /// /// 导出通道为内存流 @@ -61,38 +58,19 @@ public interface IChannelService /// 从缓存/数据库获取全部信息 /// /// 通道列表 - List GetAll(); - /// - /// 从缓存/数据库获取全部信息 - /// - /// 通道列表 - Task> GetAllByOrgAsync(); - /// - /// 通过ID获取通道 - /// - /// 通道ID - /// 通道对象 - Channel? GetChannelById(long id); + Task> GetAllAsync(SqlSugarClient db = null); /// /// 导入通道数据 /// /// 导入数据 - Task ImportChannelAsync(Dictionary input); + Task> ImportChannelAsync(Dictionary input); /// /// 报表查询 /// - /// 查询条件 - /// 查询条件 - Task> PageAsync(QueryPageOptions option, FilterKeyValueAction filterKeyValueAction = null); - - /// - /// API查询 - /// - /// - /// - Task> PageAsync(ChannelPageInput input); + /// 查询条件 + Task> PageAsync(ExportFilter exportFilter); /// /// 预览导入数据 @@ -107,4 +85,10 @@ public interface IChannelService /// 通道对象 /// 保存类型 Task SaveChannelAsync(Channel input, ItemChangedType type); + + /// + /// 保存是否输出日志和日志等级 + /// + Task UpdateLogAsync(long channelId, bool logEnable, TouchSocket.Core.LogLevel logLevel); + } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/DeviceRuntimeService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/DeviceRuntimeService.cs new file mode 100644 index 000000000..5886ff701 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/DeviceRuntimeService.cs @@ -0,0 +1,236 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Mapster; + +using Microsoft.AspNetCore.Components.Forms; + +using ThingsGateway.Extension.Generic; + +namespace ThingsGateway.Gateway.Application; + +public class DeviceRuntimeService : IDeviceRuntimeService +{ + private WaitLock WaitLock { get; set; } = new WaitLock(); + public async Task BatchEditAsync(IEnumerable models, Device oldModel, Device model, bool restart = true) + { + try + { + models = models.Adapt>(); + oldModel = oldModel.Adapt(); + model = model.Adapt(); + await WaitLock.WaitAsync().ConfigureAwait(false); + + var result = await GlobalData.DeviceService.BatchEditAsync(models, oldModel, model).ConfigureAwait(false); + + var newDeviceRuntimes = (await GlobalData.DeviceService.GetAllAsync().ConfigureAwait(false)).Where(a => models.Select(a => a.Id).ToHashSet().Contains(a.Id)).Adapt>(); + + if (restart) + { + //先找出线程管理器,停止 + var data = GlobalData.Devices.Where(a => newDeviceRuntimes.Select(a => a.Id).ToHashSet().Contains(a.Key)).GroupBy(a => a.Value.ChannelRuntime?.DeviceThreadManage); + foreach (var group in data) + { + if (group.Key != null) + await group.Key.RemoveDeviceAsync(group.Select(a => a.Value.Id)).ConfigureAwait(false); + } + } + + //批量修改之后,需要重新加载通道 + foreach (var newDeviceRuntime in newDeviceRuntimes) + { + if (GlobalData.Devices.TryGetValue(newDeviceRuntime.Id, out var deviceRuntime)) + { + deviceRuntime.Dispose(); + } + if (GlobalData.Channels.TryGetValue(newDeviceRuntime.ChannelId, out var channelRuntime)) + { + newDeviceRuntime.Init(channelRuntime); + } + if (deviceRuntime != null) + { + newDeviceRuntime.VariableRuntimes.AddRange(deviceRuntime.VariableRuntimes); + } + } + + //根据条件重启通道线程 + + if (restart) + { + foreach (var group in newDeviceRuntimes.Where(a => a.ChannelRuntime?.DeviceThreadManage != null).GroupBy(a => a.ChannelRuntime)) + { + if (group.Key?.DeviceThreadManage != null) + await group.Key.DeviceThreadManage.RestartDeviceAsync(group, false).ConfigureAwait(false); + } + } + + return true; + } + finally + { + WaitLock.Release(); + } + } + + public async Task DeleteDeviceAsync(IEnumerable ids, bool restart = true) + { + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + + ids = ids.ToHashSet(); + + var result = await GlobalData.DeviceService.DeleteDeviceAsync(ids).ConfigureAwait(false); + + //根据条件重启通道线程 + var deviceRuntimes = GlobalData.Devices.Where(a => ids.Contains(a.Key)).Select(a => a.Value).ToList(); + + + + foreach (var deviceRuntime in deviceRuntimes) + { + //也需要删除变量 + deviceRuntime.VariableRuntimes.ParallelForEach(a => + { + a.Value.Dispose(); + }); + deviceRuntime.Dispose(); + } + + if (restart) + { + var groups = GlobalData.GetDeviceThreadManages(deviceRuntimes); + foreach (var group in groups) + { + if (group.Key != null) + await group.Key.RemoveDeviceAsync(group.Value.Select(a => a.Id)).ConfigureAwait(false); + } + } + + return true; + + } + finally + { + WaitLock.Release(); + } + } + public Task> ExportDeviceAsync(ExportFilter exportFilter) => GlobalData.DeviceService.ExportDeviceAsync(exportFilter); + public Task> PreviewAsync(IBrowserFile browserFile) => GlobalData.DeviceService.PreviewAsync(browserFile); + public Task ExportMemoryStream(List data, string channelName) => + GlobalData.DeviceService.ExportMemoryStream(data, channelName); + + + public async Task ImportDeviceAsync(Dictionary input, bool restart = true) + { + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + var result = await GlobalData.DeviceService.ImportDeviceAsync(input).ConfigureAwait(false); + + var newDeviceRuntimes = (await GlobalData.DeviceService.GetAllAsync().ConfigureAwait(false)).Where(a => result.Contains(a.Id)).Adapt>(); + + if (restart) + { + //先找出线程管理器,停止 + var data = GlobalData.Devices.Where(a => newDeviceRuntimes.Select(a => a.Id).ToHashSet().Contains(a.Key)).GroupBy(a => a.Value.ChannelRuntime?.DeviceThreadManage); + foreach (var group in data) + { + if (group.Key != null) + await group.Key.RemoveDeviceAsync(group.Select(a => a.Value.Id)).ConfigureAwait(false); + } + } + + //批量修改之后,需要重新加载通道 + foreach (var newDeviceRuntime in newDeviceRuntimes) + { + if (GlobalData.Devices.TryGetValue(newDeviceRuntime.Id, out var deviceRuntime)) + { + deviceRuntime.Dispose(); + } + if (GlobalData.Channels.TryGetValue(newDeviceRuntime.ChannelId, out var channelRuntime)) + { + newDeviceRuntime.Init(channelRuntime); + } + if (deviceRuntime != null) + { + newDeviceRuntime.VariableRuntimes.AddRange(deviceRuntime.VariableRuntimes); + } + } + + //根据条件重启通道线程 + if (restart) + { + foreach (var group in newDeviceRuntimes.Where(a => a.ChannelRuntime?.DeviceThreadManage != null).GroupBy(a => a.ChannelRuntime)) + { + if (group.Key?.DeviceThreadManage != null) + await group.Key.DeviceThreadManage.RestartDeviceAsync(group, false).ConfigureAwait(false); + } + } + + + } + finally + { + WaitLock.Release(); + } + + } + + public async Task SaveDeviceAsync(Device input, ItemChangedType type, bool restart = true) + { + try + { + input = input.Adapt(); + + await WaitLock.WaitAsync().ConfigureAwait(false); + + var result = await GlobalData.DeviceService.SaveDeviceAsync(input, type).ConfigureAwait(false); + + var newDeviceRuntime = (await GlobalData.DeviceService.GetAllAsync().ConfigureAwait(false)).FirstOrDefault(a => a.Id == input.Id)?.Adapt(); + + if (newDeviceRuntime == null) return false; + + + //批量修改之后,需要重新加载通道 + if (GlobalData.Devices.TryGetValue(newDeviceRuntime.Id, out var deviceRuntime)) + { + if (restart) + { + + if (GlobalData.TryGetDeviceThreadManage(deviceRuntime, out var deviceThreadManage)) + await deviceThreadManage.RemoveDeviceAsync(deviceRuntime.Id).ConfigureAwait(false); + } + deviceRuntime.Dispose(); + newDeviceRuntime.VariableRuntimes.AddRange(deviceRuntime.VariableRuntimes); + } + + if (GlobalData.Channels.TryGetValue(newDeviceRuntime.ChannelId, out var channelRuntime)) + { + newDeviceRuntime.Init(channelRuntime); + } + if (restart) + { + //根据条件重启通道线程 + await channelRuntime.DeviceThreadManage.RestartDeviceAsync(newDeviceRuntime, false).ConfigureAwait(false); + } + + return true; + } + finally + { + WaitLock.Release(); + } + } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/DeviceService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/DeviceService.cs index 708c6597e..40a389b93 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/DeviceService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/DeviceService.cs @@ -37,18 +37,7 @@ internal sealed class DeviceService : BaseService, IDeviceService private readonly IChannelService _channelService; private readonly IPluginService _pluginService; private readonly IDispatchService _dispatchService; - private ISysUserService _sysUserService; - private ISysUserService SysUserService - { - get - { - if (_sysUserService == null) - { - _sysUserService = App.GetService(); - } - return _sysUserService; - } - } + public DeviceService( IDispatchService dispatchService ) @@ -58,45 +47,17 @@ internal sealed class DeviceService : BaseService, IDeviceService _dispatchService = dispatchService; } - /// - [OperDesc("SaveDevice", localizerType: typeof(Device), isRecordPar: false)] - public async Task BatchEditAsync(IEnumerable models, Device oldModel, Device model) + public async Task UpdateLogAsync(long channelId, bool logEnable, LogLevel logLevel) { - var differences = models.GetDiffProperty(oldModel, model); - differences.Remove(nameof(Device.DevicePropertys)); - - if (differences?.Count > 0) - { - using var db = GetDB(); - - var result = (await db.Updateable(models.ToList()).UpdateColumns(differences.Select(a => a.Key).ToArray()).ExecuteCommandAsync().ConfigureAwait(false)) > 0; - if (result) - DeleteDeviceFromCache(); - return result; - } - else - { - return true; - } - } - - [OperDesc("ClearDevice", localizerType: typeof(Device))] - public async Task ClearDeviceAsync(PluginTypeEnum pluginType) - { - var variableService = App.RootServices.GetRequiredService(); - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); using var db = GetDB(); + //事务 var result = await db.UseTranAsync(async () => { - var data = GetAll() - .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 - .WhereIf(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) - .Where(a => a.PluginType == pluginType) - .Select(a => a.Id).ToList(); - await db.Deleteable(data).ExecuteCommandAsync().ConfigureAwait(false); - if (pluginType == PluginTypeEnum.Collect) - await variableService.DeleteByDeviceIdAsync(data, db).ConfigureAwait(false); + //更新数据库 + + await db.Updateable().SetColumns(it => new Device() { LogEnable = logEnable, LogLevel = logLevel }).Where(a => a.Id == channelId).ExecuteCommandAsync().ConfigureAwait(false); + }).ConfigureAwait(false); if (result.IsSuccess)//如果成功了 { @@ -109,17 +70,44 @@ internal sealed class DeviceService : BaseService, IDeviceService } } + /// + [OperDesc("SaveDevice", localizerType: typeof(Device), isRecordPar: false)] + public async Task BatchEditAsync(IEnumerable models, Device oldModel, Device model) + { + var differences = models.GetDiffProperty(oldModel, model); + differences.Remove(nameof(Device.DevicePropertys)); + + if (differences?.Count > 0) + { + using var db = GetDB(); + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + var data = models + .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + .WhereIf(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ToList(); + var result = (await db.Updateable(data).UpdateColumns(differences.Select(a => a.Key).ToArray()).ExecuteCommandAsync().ConfigureAwait(false)) > 0; + if (result) + DeleteDeviceFromCache(); + return result; + } + else + { + return true; + } + } + [OperDesc("DeleteDevice", isRecordPar: false, localizerType: typeof(Device))] public async Task DeleteByChannelIdAsync(IEnumerable ids, SqlSugarClient db) { var variableService = App.RootServices.GetRequiredService(); - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); //事务 var result = await db.UseTranAsync(async () => { - var data = GetAll().Where(a => ids.Contains(a.ChannelId)) - .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + var data = (await GetAllAsync(db).ConfigureAwait(false)) + .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 .WhereIf(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .Where(a => ids.ToHashSet().Contains(a.ChannelId)) .Select(a => a.Id).ToList(); await db.Deleteable(data).ExecuteCommandAsync().ConfigureAwait(false); await variableService.DeleteByDeviceIdAsync(data, db).ConfigureAwait(false); @@ -139,12 +127,16 @@ internal sealed class DeviceService : BaseService, IDeviceService public async Task DeleteDeviceAsync(IEnumerable ids) { var variableService = App.RootServices.GetRequiredService(); + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); using var db = GetDB(); //事务 var result = await db.UseTranAsync(async () => { - await db.Deleteable().Where(a => ids.Contains(a.Id)).ExecuteCommandAsync().ConfigureAwait(false); + await db.Deleteable().Where(a => ids.ToHashSet().Contains(a.Id)) + .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ExecuteCommandAsync().ConfigureAwait(false); await variableService.DeleteByDeviceIdAsync(ids, db).ConfigureAwait(false); }).ConfigureAwait(false); if (result.IsSuccess)//如果成功了 @@ -170,138 +162,49 @@ internal sealed class DeviceService : BaseService, IDeviceService /// 从缓存/数据库获取全部信息 /// /// 列表 - public List GetAll() + public async Task> GetAllAsync(SqlSugarClient db = null) { var key = ThingsGatewayCacheConst.Cache_Device; var devices = App.CacheService.Get>(key); if (devices == null) { - using var db = GetDB(); - devices = db.Queryable().ToList(); + db ??= GetDB(); + devices = await db.Queryable().ToListAsync().ConfigureAwait(false); App.CacheService.Set(key, devices); } return devices; } - /// - /// 从缓存/数据库获取全部信息 - /// - /// 列表 - public async Task> GetAllByOrgAsync() + + public async Task GetDeviceByIdAsync(long id) { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - return GetAll() - .WhereIF(dataScope != null && dataScope?.Count > 0, b => dataScope.Contains(b.CreateOrgId)) - .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId).ToList(); - } - public async Task> GetBusinessDeviceRuntimeAsync(long? devId = null) - { - await Task.CompletedTask.ConfigureAwait(false); - if (devId == null) - { - var devices = GetAll().Where(a => a.Enable && a.PluginType == PluginTypeEnum.Business); - var channels = _channelService.GetAll().Where(a => a.Enable); - devices = devices.Where(a => channels.Select(a => a.Id).Contains(a.ChannelId)); - - var runtime = devices.Adapt>(); - runtime.ParallelForEach(device => - { - device.Channel = channels.FirstOrDefault(a => a.Id == device.ChannelId); - }); - return runtime; - } - else - { - var device = GetAll().FirstOrDefault(a => a.Enable && a.PluginType == PluginTypeEnum.Business && a.Id == devId); - if (device == null) - { - return new List() { }; - } - var channels = _channelService.GetAll().Where(a => a.Enable); - if (!channels.Select(a => a.Id).Contains(device.ChannelId)) - { - return new List() { }; - } - var runtime = device.Adapt(); - runtime.Channel = channels.FirstOrDefault(a => a.Id == runtime.ChannelId); - return new List() { runtime }; - } - } - - public async Task> GetCollectDeviceRuntimeAsync(long? devId = null) - { - if (devId == null) - { - var variableService = App.RootServices.GetRequiredService(); - var devices = GetAll().Where(a => a.Enable && a.PluginType == PluginTypeEnum.Collect); - var channels = _channelService.GetAll().Where(a => a.Enable); - devices = devices.Where(a => channels.Select(a => a.Id).Contains(a.ChannelId)); - var runtime = devices.Adapt>().ToDictionary(a => a.Id); - var collectVariableRunTimes = await variableService.GetVariableRuntimeAsync().ConfigureAwait(false); - runtime.Values.ParallelForEach(device => - { - device.Channel = channels.FirstOrDefault(a => a.Id == device.ChannelId); - - device.VariableRunTimes = collectVariableRunTimes.Where(a => a.DeviceId == device.Id).ToDictionary(a => a.Name); - }); - - collectVariableRunTimes.ParallelForEach(variable => - { - if (runtime.TryGetValue(variable.DeviceId.Value, out var device)) - { - variable.CollectDeviceRunTime = device; - variable.DeviceName = device.Name; - } - }); - return runtime.Values.ToList(); - } - else - { - var device = GetAll().FirstOrDefault(a => a.Enable && a.PluginType == PluginTypeEnum.Collect && a.Id == devId); - if (device == null) - { - return new List() { }; - } - var channels = _channelService.GetAll().Where(a => a.Enable); - if (!channels.Select(a => a.Id).Contains(device.ChannelId)) - { - return new List() { }; - } - - var runtime = device.Adapt(); - var variableService = App.RootServices.GetRequiredService(); - var collectVariableRunTimes = await variableService.GetVariableRuntimeAsync(devId).ConfigureAwait(false); - runtime.VariableRunTimes = collectVariableRunTimes.ToDictionary(a => a.Name); - runtime.Channel = channels.FirstOrDefault(a => a.Id == runtime.ChannelId); - - collectVariableRunTimes.ParallelForEach(variable => - { - variable.CollectDeviceRunTime = runtime; - variable.DeviceName = runtime.Name; - }); - return new List() { runtime }; - } - } - - public Device? GetDeviceById(long id) - { - var data = GetAll(); + var data = await GetAllAsync().ConfigureAwait(false); return data?.FirstOrDefault(x => x.Id == id); } /// /// 报表查询 /// - /// 查询条件 - /// 查询条件 - /// 查询条件 - public async Task> PageAsync(QueryPageOptions option, PluginTypeEnum pluginType, FilterKeyValueAction filterKeyValueAction = null) + /// 查询条件 + public async Task> PageAsync(ExportFilter exportFilter) { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - return await QueryAsync(option, a => a - .WhereIF(!option.SearchText.IsNullOrWhiteSpace(), a => a.Name.Contains(option.SearchText!)).Where(a => a.PluginType == pluginType) - .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + HashSet? channel = null; + if (!exportFilter.PluginName.IsNullOrWhiteSpace()) + { + channel = (await _channelService.GetAllAsync().ConfigureAwait(false)).Where(a => a.PluginName == exportFilter.PluginName).Select(a => a.Id).ToHashSet(); + } + if (exportFilter.PluginType != null) + { + var pluginInfo = GlobalData.PluginService.GetList(exportFilter.PluginType).Select(a => a.FullName).ToHashSet(); + channel = (await _channelService.GetAllAsync().ConfigureAwait(false)).Where(a => pluginInfo.Contains(a.PluginName)).Select(a => a.Id).ToHashSet(); + } + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + return await QueryAsync(exportFilter.QueryPageOptions, a => a + .WhereIF(channel != null, a => channel.Contains(a.ChannelId)) + .WhereIF(exportFilter.DeviceId != null, a => a.Id == exportFilter.DeviceId) + .WhereIF(!exportFilter.QueryPageOptions.SearchText.IsNullOrWhiteSpace(), a => a.Name.Contains(exportFilter.QueryPageOptions.SearchText!)) + .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) - , filterKeyValueAction).ConfigureAwait(false); + , exportFilter.FilterKeyValueAction).ConfigureAwait(false); } @@ -313,10 +216,15 @@ internal sealed class DeviceService : BaseService, IDeviceService [OperDesc("SaveDevice", localizerType: typeof(Device))] public async Task SaveDeviceAsync(Device input, ItemChangedType type) { - CheckInput(input); - + if ((await GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Name).TryGetValue(input.Name, out var device)) + { + if (device.Id != input.Id) + { + throw Oops.Bah(Localizer["NameDump", device.Name]); + } + } if (type == ItemChangedType.Update) - await SysUserService.CheckApiDataScopeAsync(input.CreateOrgId, input.CreateUserId).ConfigureAwait(false); + await GlobalData.SysUserService.CheckApiDataScopeAsync(input.CreateOrgId, input.CreateUserId).ConfigureAwait(false); if (await base.SaveAsync(input, type).ConfigureAwait(false)) { @@ -326,46 +234,6 @@ internal sealed class DeviceService : BaseService, IDeviceService return false; } - private void CheckInput(Device input) - { - - if (input.RedundantEnable && input.RedundantDeviceId == null) - { - throw Oops.Bah(Localizer["RedundantDeviceNotNull"]); - } - } - - #region API查询 - - public async Task> PageAsync(DevicePageInput input) - { - using var db = GetDB(); - var query = await GetPageAsync(db, input).ConfigureAwait(false); - return await query.ToPagedListAsync(input.Current, input.Size).ConfigureAwait(false);//分页 - } - - /// - private async Task> GetPageAsync(SqlSugarClient db, DevicePageInput input) - { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - ISugarQueryable query = db.Queryable() - .WhereIF(!string.IsNullOrEmpty(input.Name), u => u.Name.Contains(input.Name)) - .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 - .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) - .WhereIF(input.ChannelId != null, u => u.ChannelId == input.ChannelId) - .WhereIF(!string.IsNullOrEmpty(input.PluginName), u => u.PluginName == input.PluginName) - .Where(u => u.PluginType == input.PluginType); - for (int i = input.SortField.Count - 1; i >= 0; i--) - { - query = query.OrderByIF(!string.IsNullOrEmpty(input.SortField[i]), $"{input.SortField[i]} {(input.SortDesc[i] ? "desc" : "asc")}"); - } - query = query.OrderBy(it => it.Id, OrderByType.Desc);//排序 - - return query; - } - - #endregion API查询 - #region 导出 /// @@ -373,13 +241,11 @@ internal sealed class DeviceService : BaseService, IDeviceService /// /// [OperDesc("ExportDevice", isRecordPar: false, localizerType: typeof(Device))] - public async Task> ExportDeviceAsync(QueryPageOptions options, PluginTypeEnum pluginType, FilterKeyValueAction filterKeyValueAction = null) + public async Task> ExportDeviceAsync(ExportFilter exportFilter) { //导出 - var data = await PageAsync(options, pluginType, filterKeyValueAction).ConfigureAwait(false); - string fileName; - Dictionary sheets; - ExportCore(data.Items, pluginType, out fileName, out sheets); + var data = await PageAsync(exportFilter).ConfigureAwait(false); + var sheets = await ExportCoreAsync(data.Items).ConfigureAwait(false); return sheets; } @@ -387,28 +253,25 @@ internal sealed class DeviceService : BaseService, IDeviceService /// 导出文件 /// [OperDesc("ExportDevice", isRecordPar: false, localizerType: typeof(Device))] - public async Task ExportMemoryStream(IEnumerable? data, PluginTypeEnum pluginType, string channelName = null) + public async Task ExportMemoryStream(IEnumerable? data, string channelName = null) { - string fileName; - Dictionary sheets; - ExportCore(data, pluginType, out fileName, out sheets, channelName); + var sheets = await ExportCoreAsync(data, channelName).ConfigureAwait(false); var memoryStream = new MemoryStream(); await memoryStream.SaveAsAsync(sheets).ConfigureAwait(false); memoryStream.Seek(0, SeekOrigin.Begin); return memoryStream; } - private void ExportCore(IEnumerable? data, PluginTypeEnum pluginType, out string fileName, out Dictionary sheets, string channelName = null) + private async Task> ExportCoreAsync(IEnumerable? data, string channelName = null) { if (data == null || !data.Any()) { data = new List(); } - fileName = pluginType == PluginTypeEnum.Collect ? "CollectDevice" : "BusinessDevice"; - var deviceDicts = GetAll().ToDictionary(a => a.Id); - var channelDicts = _channelService.GetAll().ToDictionary(a => a.Id); + var deviceDicts = (await GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Id); + var channelDicts = (await _channelService.GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Id); //总数据 - sheets = new(); + Dictionary sheets = new(); //设备页 List> deviceExports = new(); //设备附加属性,转成Dict<表名,List>>的形式 @@ -467,21 +330,32 @@ internal sealed class DeviceService : BaseService, IDeviceService Dictionary driverInfo = new(); var propDict = device.DevicePropertys; - if (propertysDict.TryGetValue(device.PluginName, out var propertys)) + if (propertysDict.TryGetValue(channel.PluginName, out var propertys)) { } else { - var driverProperties = _pluginService.GetDriver(device.PluginName).DriverProperties; - propertys.Item1 = driverProperties; - var driverPropertyType = driverProperties.GetType(); - propertys.Item2 = driverPropertyType.GetRuntimeProperties() -.Where(a => a.GetCustomAttribute() != null) -.ToDictionary(a => driverPropertyType.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description), a => a); + try + { + var driverProperties = _pluginService.GetDriver(channel.PluginName).DriverProperties; + propertys.Item1 = driverProperties; + var driverPropertyType = driverProperties.GetType(); + propertys.Item2 = driverPropertyType.GetRuntimeProperties() + .Where(a => a.GetCustomAttribute() != null) + .ToDictionary(a => driverPropertyType.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description), a => a); + propertysDict.TryAdd(channel.PluginName, propertys); + + } + catch (Exception) + { + + } - propertysDict.TryAdd(device.PluginName, propertys); } + if (propertys.Item2 == null) + continue; + if (propertys.Item2.Count > 0) { //没有包含设备名称,手动插入 @@ -501,7 +375,7 @@ internal sealed class DeviceService : BaseService, IDeviceService } } - var pluginName = PluginServiceUtil.GetFileNameAndTypeName(device.PluginName); + var pluginName = PluginServiceUtil.GetFileNameAndTypeName(channel.PluginName); if (devicePropertys.ContainsKey(pluginName.TypeName)) { if (driverInfo.Count > 0) @@ -544,6 +418,8 @@ internal sealed class DeviceService : BaseService, IDeviceService sheets.Add(item.Key, item.Value); } + + return sheets; } @@ -554,41 +430,42 @@ internal sealed class DeviceService : BaseService, IDeviceService /// [OperDesc("ImportDevice", isRecordPar: false, localizerType: typeof(Device))] - public async Task ImportDeviceAsync(Dictionary input) + public async Task> ImportDeviceAsync(Dictionary input) { - var collectDevices = new List(); + var devices = new List(); foreach (var item in input) { if (item.Key == ExportString.DeviceName) { var collectDeviceImports = ((ImportPreviewOutput)item.Value).Data; - collectDevices = new List(collectDeviceImports.Values); + devices = new List(collectDeviceImports.Values); break; } } - var upData = collectDevices.Where(a => a.IsUp).ToList(); - var insertData = collectDevices.Where(a => !a.IsUp).ToList(); + var upData = devices.Where(a => a.IsUp).ToList(); + var insertData = devices.Where(a => !a.IsUp).ToList(); using var db = GetDB(); await db.Fastest().PageSize(100000).BulkCopyAsync(insertData).ConfigureAwait(false); await db.Fastest().PageSize(100000).BulkUpdateAsync(upData).ConfigureAwait(false); DeleteDeviceFromCache(); + return devices.Select(a => a.Id).ToHashSet(); } public async Task> PreviewAsync(IBrowserFile browserFile) { var path = await browserFile.StorageLocal().ConfigureAwait(false); // 上传文件并获取文件路径 + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); try { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); // 获取 Excel 文件中所有工作表的名称 var sheetNames = MiniExcel.GetSheetNames(path); // 获取所有设备,并将设备名称作为键构建设备字典 - var deviceDicts = GetAll().ToDictionary(a => a.Name); + var deviceDicts = (await GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Name); // 获取所有通道,并将通道名称作为键构建通道字典 - var channelDicts = _channelService.GetAll().ToDictionary(a => a.Name); + var channelDicts = (await _channelService.GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Name); // 导入检验结果的预览字典,键为名称,值为导入预览对象 Dictionary ImportPreviews = new(); @@ -789,22 +666,31 @@ internal sealed class DeviceService : BaseService, IDeviceService } else { - // 获取驱动插件实例 - var driver = _pluginService.GetDriver(driverPluginType.FullName); - var type = driver.DriverProperties.GetType(); + try + { - propertys.Item1 = type; - propertys.Item2 = type.GetRuntimeProperties() - .Where(a => a.GetCustomAttribute() != null && a.CanWrite) - .ToDictionary(a => type.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); + // 获取驱动插件实例 + var driver = _pluginService.GetDriver(driverPluginType.FullName); + var type = driver.DriverProperties.GetType(); - // 获取目标类型的所有属性,并根据是否需要过滤 IgnoreExcelAttribute 进行筛选 - var properties = propertys.Item1.GetRuntimeProperties().Where(a => (a.GetCustomAttribute() == null) && a.CanWrite) - .ToDictionary(a => propertys.Item1.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); + propertys.Item1 = type; - propertys.Item3 = properties; - propertysDict.TryAdd(driverPluginType.FullName, propertys); + propertys.Item2 = type.GetRuntimeProperties() + .Where(a => a.GetCustomAttribute() != null && a.CanWrite) + .ToDictionary(a => type.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); + + // 获取目标类型的所有属性,并根据是否需要过滤 IgnoreExcelAttribute 进行筛选 + var properties = propertys.Item1.GetRuntimeProperties().Where(a => (a.GetCustomAttribute() == null) && a.CanWrite) + .ToDictionary(a => propertys.Item1.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); + + propertys.Item3 = properties; + propertysDict.TryAdd(driverPluginType.FullName, propertys); + } + catch + { + + } } // 遍历每一行数据 @@ -812,6 +698,13 @@ internal sealed class DeviceService : BaseService, IDeviceService { try { + if (propertys.Item1 == null) + { + importPreviewOutput.HasError = true; + importPreviewOutput.Results.Add((row++, false, Localizer["PluginNotNull"])); + continue; + } + // 获取设备名称 if (!item.TryGetValue(ExportString.DeviceName, out var deviceName)) { diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/Dto/DeviceInput.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/Dto/DeviceInput.cs index c21d8c0d8..8afe5a89c 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/Dto/DeviceInput.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/Dto/DeviceInput.cs @@ -14,26 +14,6 @@ using ThingsGateway.Extension.Generic; namespace ThingsGateway.Gateway.Application; -/// -/// 设备分页查询DTO -/// -public class DevicePageInput : BasePageInput -{ - /// - public long? ChannelId { get; set; } - - /// - public string? Name { get; set; } - - /// - public string? PluginName { get; set; } - - /// - public PluginTypeEnum PluginType { get; set; } -} - - - public class DeviceSearchInput : ITableSearchModel { /// @@ -42,17 +22,11 @@ public class DeviceSearchInput : ITableSearchModel /// public string? Name { get; set; } - /// - public string? PluginName { get; set; } - /// - public PluginTypeEnum PluginType { get; set; } - /// public IEnumerable GetSearches() { var ret = new List(); ret.AddIF(!string.IsNullOrEmpty(Name), () => new SearchFilterAction(nameof(Device.Name), Name)); - ret.AddIF(!string.IsNullOrEmpty(PluginName), () => new SearchFilterAction(nameof(Device.PluginName), PluginName)); ret.AddIF(ChannelId > 0, () => new SearchFilterAction(nameof(Device.ChannelId), ChannelId, FilterAction.Equal)); return ret; } @@ -61,7 +35,6 @@ public class DeviceSearchInput : ITableSearchModel public void Reset() { Name = null; - PluginName = null; ChannelId = null; } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/IDeviceRuntimeService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/IDeviceRuntimeService.cs new file mode 100644 index 000000000..5b9f4f36f --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/IDeviceRuntimeService.cs @@ -0,0 +1,27 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.AspNetCore.Components.Forms; + +namespace ThingsGateway.Gateway.Application +{ + public interface IDeviceRuntimeService + { + Task BatchEditAsync(IEnumerable models, Device oldModel, Device model, bool restart = true); + Task DeleteDeviceAsync(IEnumerable ids, bool restart = true); + Task> ExportDeviceAsync(ExportFilter exportFilter); + Task ExportMemoryStream(List data, string channelName); + Task ImportDeviceAsync(Dictionary input, bool restart = true); + Task> PreviewAsync(IBrowserFile browserFile); + Task SaveDeviceAsync(Device input, ItemChangedType type, bool restart = true); + } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/IDeviceService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/IDeviceService.cs index b124286b2..45f76fad3 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/IDeviceService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Device/IDeviceService.cs @@ -22,7 +22,7 @@ namespace ThingsGateway.Gateway.Application; /// /// 设备服务接口,定义了设备相关的操作。 /// -public interface IDeviceService +internal interface IDeviceService { /// /// 批量修改 @@ -33,19 +33,11 @@ public interface IDeviceService /// Task BatchEditAsync(IEnumerable models, Device oldModel, Device model); - /// - /// 清除指定类型的设备信息。 - /// - /// 插件类型 - /// 异步任务 - Task ClearDeviceAsync(PluginTypeEnum pluginType); - /// /// 根据通道ID异步删除设备信息。 /// /// 待删除设备的通道ID集合 /// 数据库连接 - /// 异步任务 Task DeleteByChannelIdAsync(IEnumerable ids, SqlSugarClient db); /// @@ -64,67 +56,42 @@ public interface IDeviceService /// 导出设备信息到文件流。 /// /// 导出的文件流 - Task> ExportDeviceAsync(QueryPageOptions options, PluginTypeEnum pluginType, FilterKeyValueAction filterKeyValueAction = null); + Task> ExportDeviceAsync(ExportFilter exportFilter); /// /// 导出设备信息到内存流。 /// /// 设备信息 - /// 设备类型 /// 通道名称(可选) /// 导出的内存流 - Task ExportMemoryStream(IEnumerable? data, PluginTypeEnum pluginType, string channelName = null); + Task ExportMemoryStream(IEnumerable? data, string channelName = null); /// /// 获取所有设备信息。 /// /// 所有设备信息 - List GetAll(); - Task> GetAllByOrgAsync(); - - /// - /// 获取业务设备的运行时信息。 - /// - /// 设备ID(可选) - /// 业务设备运行时信息 - Task> GetBusinessDeviceRuntimeAsync(long? devId = null); - - /// - /// 异步获取采集设备的运行时信息。 - /// - /// 设备ID(可选) - /// 采集设备运行时信息 - Task> GetCollectDeviceRuntimeAsync(long? devId = null); + Task> GetAllAsync(SqlSugarClient db = null); /// /// 根据ID获取设备信息。 /// /// 设备ID /// 设备信息 - Device? GetDeviceById(long id); + Task GetDeviceByIdAsync(long id); /// /// 导入设备信息。 /// /// 导入的数据 /// 异步任务 - Task ImportDeviceAsync(Dictionary input); + Task> ImportDeviceAsync(Dictionary input); /// /// 异步分页查询设备信息。 /// - /// 查询条件 - /// 查询条件 - /// 查询条件 + /// 查询条件 /// 查询结果 - Task> PageAsync(QueryPageOptions option, PluginTypeEnum pluginType, FilterKeyValueAction filterKeyValueAction = null); - - /// - /// API查询 - /// - /// - /// - Task> PageAsync(DevicePageInput input); + Task> PageAsync(ExportFilter exportFilter); /// /// 预览导入设备信息。 @@ -140,4 +107,11 @@ public interface IDeviceService /// 保存类型 /// 保存是否成功的异步任务 Task SaveDeviceAsync(Device input, ItemChangedType type); + + + /// + /// 保存是否输出日志和日志等级 + /// + Task UpdateLogAsync(long deviceId, bool logEnable, TouchSocket.Core.LogLevel logLevel); + } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayExport/GatewayExportService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayExport/GatewayExportService.cs index e49fb465f..611991bc9 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayExport/GatewayExportService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayExport/GatewayExportService.cs @@ -9,10 +9,6 @@ //------------------------------------------------------------------------------ #pragma warning disable CA2007 // 考虑对等待的任务调用 ConfigureAwait -using BootstrapBlazor.Components; - -using Mapster; - using Microsoft.JSInterop; using ThingsGateway.Extension; @@ -30,28 +26,28 @@ internal sealed class GatewayExportService : IGatewayExportService private IJSRuntime JSRuntime { get; set; } - public async Task OnChannelExport(QueryPageOptions dtoObject) + public async Task OnChannelExport(ExportFilter exportFilter) { await using var ajaxJS = await JSRuntime.InvokeAsync("import", $"/_content/ThingsGateway.Razor/js/downloadFile.js"); string url = "api/gatewayExport/channel"; - string fileName = DateTime.Now.ToFileDateTimeFormat(); - await ajaxJS.InvokeVoidAsync("postJson_downloadFile", url, fileName, new ExportDto() { FilterKeyValueAction = dtoObject.ToFilter(), QueryPageOptions = dtoObject.Adapt() }.ToJsonString()); + string fileName = $"{DateTime.Now.ToFileDateTimeFormat()}.xlsx"; + await ajaxJS.InvokeVoidAsync("postJson_downloadFile", url, fileName, exportFilter.ToJsonString()); } - public async Task OnDeviceExport(QueryPageOptions dtoObject, bool collect) + public async Task OnDeviceExport(ExportFilter exportFilter) { await using var ajaxJS = await JSRuntime.InvokeAsync("import", $"/_content/ThingsGateway.Razor/js/downloadFile.js"); - string url = collect ? "api/gatewayExport/collectdevice" : "api/gatewayExport/businessdevice"; - string fileName = DateTime.Now.ToFileDateTimeFormat(); - await ajaxJS.InvokeVoidAsync("postJson_downloadFile", url, fileName, new ExportDto() { FilterKeyValueAction = dtoObject.ToFilter(), QueryPageOptions = dtoObject.Adapt() }.ToJsonString()); + string url = "api/gatewayExport/device"; + string fileName = $"{DateTime.Now.ToFileDateTimeFormat()}.xlsx"; + await ajaxJS.InvokeVoidAsync("postJson_downloadFile", url, fileName, exportFilter.ToJsonString()); } - public async Task OnVariableExport(QueryPageOptions dtoObject) + public async Task OnVariableExport(ExportFilter exportFilter) { await using var ajaxJS = await JSRuntime.InvokeAsync("import", $"/_content/ThingsGateway.Razor/js/downloadFile.js"); string url = "api/gatewayExport/variable"; - string fileName = DateTime.Now.ToFileDateTimeFormat(); - await ajaxJS.InvokeVoidAsync("postJson_downloadFile", url, fileName, new ExportDto() { FilterKeyValueAction = dtoObject.ToFilter(), QueryPageOptions = dtoObject.Adapt() }.ToJsonString()); + string fileName = $"{DateTime.Now.ToFileDateTimeFormat()}.xlsx"; + await ajaxJS.InvokeVoidAsync("postJson_downloadFile", url, fileName, exportFilter.ToJsonString()); } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayExport/IGatewayExportService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayExport/IGatewayExportService.cs index e07850b1a..e40b7cba4 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayExport/IGatewayExportService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayExport/IGatewayExportService.cs @@ -8,13 +8,11 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using BootstrapBlazor.Components; - namespace ThingsGateway.Gateway.Application; public interface IGatewayExportService { - Task OnChannelExport(QueryPageOptions options); - Task OnDeviceExport(QueryPageOptions options, bool collect); - Task OnVariableExport(QueryPageOptions options); + Task OnChannelExport(ExportFilter exportFilter); + Task OnDeviceExport(ExportFilter exportFilter); + Task OnVariableExport(ExportFilter exportFilter); } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/HostService/AlarmHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/AlarmHostedService.cs similarity index 74% rename from src/Gateway/ThingsGateway.Gateway.Application/HostService/AlarmHostedService.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/AlarmHostedService.cs index 2fcbd7783..80885ae6a 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/HostService/AlarmHostedService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/AlarmHostedService.cs @@ -14,8 +14,6 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; -using System.Collections.Concurrent; - using ThingsGateway.Gateway.Application.Extensions; using ThingsGateway.NewLife.Extension; @@ -32,40 +30,15 @@ public delegate void VariableAlarmEventHandler(AlarmVariable alarmVariable); internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedService { private readonly ILogger _logger; - private readonly ICollectDeviceHostedService _collectDeviceHostedService; /// - public AlarmHostedService(ILogger logger, IStringLocalizer localizer, ICollectDeviceHostedService collectDeviceHostedService) + public AlarmHostedService(ILogger logger, IStringLocalizer localizer) { _logger = logger; Localizer = localizer; - _collectDeviceHostedService = collectDeviceHostedService; } - /// - /// 报警变化事件 - /// - public event VariableAlarmEventHandler OnAlarmChanged; - - /// - /// 实时报警列表 - /// - public IReadOnlyDictionary ReadOnlyRealAlarmVariables => RealAlarmVariables; - /// - /// 实时报警列表 - /// - internal ConcurrentDictionary RealAlarmVariables { get; } = new(); - - private static IEnumerable _deviceVariables => GlobalData.Variables.Select(a => a.Value).Where(a => a.IsOnline && a.AlarmEnable); - private IStringLocalizer Localizer { get; } - private DoTask RealAlarmTask { get; set; } - - /// - /// 重启锁 - /// - private WaitLock RestartLock { get; } = new(); - #region 核心实现 /// @@ -76,7 +49,7 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic /// 报警约束表达式 /// 报警文本 /// 报警类型枚举 - private static AlarmTypeEnum? GetBoolAlarmCode(VariableRunTime tag, out string limit, out string expressions, out string text) + private static AlarmTypeEnum? GetBoolAlarmCode(VariableRuntime tag, out string limit, out string expressions, out string text) { limit = string.Empty; // 初始化报警限制值为空字符串 expressions = string.Empty; // 初始化报警约束表达式为空字符串 @@ -114,7 +87,7 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic /// 报警约束表达式 /// 报警文本 /// 报警类型枚举 - private static AlarmTypeEnum? GetCustomAlarmDegree(VariableRunTime tag, out string limit, out string expressions, out string text) + private static AlarmTypeEnum? GetCustomAlarmDegree(VariableRuntime tag, out string limit, out string expressions, out string text) { limit = string.Empty; // 初始化报警限制值为空字符串 expressions = string.Empty; // 初始化报警约束表达式为空字符串 @@ -128,7 +101,7 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic if (tag.CustomAlarmEnable) // 检查是否启用了自定义报警功能 { // 调用变量的CustomAlarmCode属性的GetExpressionsResult方法,传入变量的值,获取报警表达式的计算结果 - var result = tag.CustomAlarmCode.GetExpressionsResult(tag.Value); + var result = tag.CustomAlarmCode.GetExpressionsResult(tag.Value, tag.DeviceRuntime?.Driver?.LogMessage); if (result is bool boolResult) // 检查计算结果是否为布尔类型 { @@ -153,7 +126,7 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic /// 报警约束表达式 /// 报警文本 /// 报警类型枚举 - private static AlarmTypeEnum? GetDecimalAlarmDegree(VariableRunTime tag, out string limit, out string expressions, out string text) + private static AlarmTypeEnum? GetDecimalAlarmDegree(VariableRuntime tag, out string limit, out string expressions, out string text) { limit = string.Empty; // 初始化报警限制值为空字符串 expressions = string.Empty; // 初始化报警约束表达式为空字符串 @@ -209,7 +182,7 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic /// 对变量进行报警分析,并根据需要触发相应的报警事件或恢复事件。 /// /// 要进行报警分析的变量 - private void AlarmAnalysis(VariableRunTime item) + private static void AlarmAnalysis(VariableRuntime item) { string limit; // 报警限制值 string ex; // 报警约束表达式 @@ -247,7 +220,7 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic if (!string.IsNullOrEmpty(ex)) { // 如果存在报警约束表达式,则计算表达式结果,以确定是否触发报警事件 - var data = ex.GetExpressionsResult(item.Value); + var data = ex.GetExpressionsResult(item.Value, item.DeviceRuntime?.Driver?.LogMessage); if (data is bool result) { if (result) @@ -274,14 +247,14 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic /// 报警事件类型枚举 /// 报警类型枚举 /// 报警延时 - private void AlarmChange(VariableRunTime item, object limit, string text, EventTypeEnum eventEnum, AlarmTypeEnum? alarmEnum, int delay) + private static void AlarmChange(VariableRuntime item, object limit, string text, EventTypeEnum eventEnum, AlarmTypeEnum? alarmEnum, int delay) { bool changed = false; if (eventEnum == EventTypeEnum.Finish) { // 如果是需恢复报警事件 // 如果实时报警列表中不存在该变量,则直接返回 - if (!RealAlarmVariables.ContainsKey(item.Name)) + if (!GlobalData.RealAlarmVariables.ContainsKey(item.Name)) { return; } @@ -290,7 +263,7 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic { // 如果是触发报警事件 // 在实时报警列表中查找该变量 - if (RealAlarmVariables.TryGetValue(item.Name, out var variable)) + if (GlobalData.RealAlarmVariables.TryGetValue(item.Name, out var variable)) { // 如果变量已经处于相同的报警类型,则直接返回 if (item.AlarmType == alarmEnum) @@ -374,7 +347,7 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic { // 如果是需恢复报警事件 // 获取旧的报警信息 - if (RealAlarmVariables.TryGetValue(item.Name, out var oldAlarm)) + if (GlobalData.RealAlarmVariables.TryGetValue(item.Name, out var oldAlarm)) { item.AlarmType = oldAlarm.AlarmType; item.EventType = eventEnum; @@ -392,141 +365,104 @@ internal sealed class AlarmHostedService : BackgroundService, IAlarmHostedServic if (item.EventType == EventTypeEnum.Alarm) { // 如果是触发报警事件 - //lock (RealAlarmVariables) + //lock (GlobalData. RealAlarmVariables) { // 从实时报警列表中移除旧的报警信息,并添加新的报警信息 - RealAlarmVariables.AddOrUpdate(item.Name, a => item, (a, b) => item); + GlobalData.RealAlarmVariables.AddOrUpdate(item.Name, a => item, (a, b) => item); } } else if (item.EventType == EventTypeEnum.Finish) { // 如果是需恢复报警事件,则从实时报警列表中移除该变量 - RealAlarmVariables.TryRemove(item.Name, out _); + GlobalData.RealAlarmVariables.TryRemove(item.Name, out _); } - OnAlarmChanged?.Invoke(item.Adapt()); + GlobalData.AlarmChange(item.Adapt()); } - } - public void ConfirmAlarm(VariableRunTime item) + public void ConfirmAlarm(VariableRuntime item) { // 如果是确认报警事件 item.EventType = EventTypeEnum.Confirm; item.EventTime = DateTime.Now; - OnAlarmChanged?.Invoke(item.Adapt()); + GlobalData.AlarmChange(item.Adapt()); } #endregion 核心实现 - #region 线程任务 - /// /// 执行工作任务,对设备变量进行报警分析。 /// /// 取消任务的 CancellationToken - private async ValueTask DoWork(CancellationToken cancellation) + private async Task DoWork(CancellationToken cancellation) { - // 延迟一段时间,避免过于频繁地执行任务 - await Task.Delay(500, cancellation).ConfigureAwait(false); - //Stopwatch stopwatch = Stopwatch.StartNew(); - // 遍历设备变量列表 - foreach (var item in _deviceVariables) + try { - // 如果取消请求已经被触发,则结束任务 - if (cancellation.IsCancellationRequested) + if (!GlobalData.StartCollectChannelEnable) return; - // 如果该变量的报警功能未启用,则跳过该变量 - if (!item.AlarmEnable) - continue; + //Stopwatch stopwatch = Stopwatch.StartNew(); + // 遍历设备变量列表 - // 如果该变量离线,则跳过该变量 - if (!item.IsOnline) - continue; - - // 对该变量进行报警分析 - AlarmAnalysis(item); - } - //stopwatch.Stop(); - //_logger.LogInformation("报警分析耗时:" + stopwatch.ElapsedMilliseconds + "ms"); - } - - #endregion 线程任务 - - #region worker服务 - - public override Task StartAsync(CancellationToken cancellationToken) - { - _collectDeviceHostedService.Started += CollectDeviceHostedService_Started; - _collectDeviceHostedService.Stoping += CollectDeviceHostedService_Stoping; - return base.StartAsync(cancellationToken); - } - - internal async Task StartAsync() - { - try - { - await RestartLock.WaitAsync().ConfigureAwait(false); // 等待获取锁,以确保只有一个线程可以执行以下代码 - - if (RealAlarmTask != null) + foreach (var kv in GlobalData.AlarmEnableVariables) { - await RealAlarmTask.StopAsync(TimeSpan.FromSeconds(30)).ConfigureAwait(false); // 停止现有任务,等待最多30秒钟 + // 如果取消请求已经被触发,则结束任务 + if (cancellation.IsCancellationRequested) + return; + + var item = kv.Value; + + // 如果该变量的报警功能未启用,则跳过该变量 + if (!item.AlarmEnable) + continue; + + // 如果该变量离线,则跳过该变量 + if (!item.IsOnline) + continue; + + // 对该变量进行报警分析 + AlarmAnalysis(item); + + } - RealAlarmTask = new DoTask(a => DoWork(a), _logger, Localizer["RealAlarmTask"]); // 创建新的任务 - RealAlarmTask.Start(); // 启动任务 - _logger.LogInformation(Localizer["RealAlarmTaskStart"]); + + foreach (var item in GlobalData.RealAlarmVariables) + { + if (!GlobalData.AlarmEnableVariables.ContainsKey(item.Key)) + { + if (GlobalData.RealAlarmVariables.TryRemove(item.Key, out var oldAlarm)) + { + oldAlarm.EventType = EventTypeEnum.Finish; + oldAlarm.EventTime = DateTime.Now; + GlobalData.AlarmChange(item.Adapt()); + } + } + } + + //stopwatch.Stop(); + //_logger.LogInformation("报警分析耗时:" + stopwatch.ElapsedMilliseconds + "ms"); } catch (Exception ex) { - _logger.LogError(ex, "Start"); // 记录错误日志 + _logger.LogWarning(ex, "Alarm analysis fail"); } finally { - RestartLock.Release(); // 释放锁 + // 延迟一段时间,避免过于频繁地执行任务 + await Task.Delay(500, cancellation).ConfigureAwait(false); } } - internal async Task StopAsync() + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - try + _logger.LogInformation(Localizer["RealAlarmTaskStart"]); + while (!stoppingToken.IsCancellationRequested) { - await RestartLock.WaitAsync().ConfigureAwait(false); // 等待获取锁,以确保只有一个线程可以执行以下代码 - - if (RealAlarmTask != null) - { - await RealAlarmTask.StopAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false); // 停止任务,等待最多10秒钟 - } - RealAlarmTask = null; - RealAlarmVariables.Clear(); // 清空任务相关的变量 - } - catch (Exception ex) - { - _logger.LogError(ex, "Stop"); // 记录错误日志 - } - finally - { - RestartLock.Release(); // 释放锁 + await DoWork(stoppingToken).ConfigureAwait(false); } } - protected override Task ExecuteAsync(CancellationToken stoppingToken) - { - return Task.CompletedTask; - } - - private async Task CollectDeviceHostedService_Started() - { - if (GlobalData.CollectDeviceHostedService.StartCollectDeviceEnable || GlobalData.BusinessDeviceHostedService.StartBusinessDeviceEnable) - await StartAsync().ConfigureAwait(false); - } - - private async Task CollectDeviceHostedService_Stoping() - { - if (!GlobalData.BusinessDeviceHostedService.StartBusinessDeviceEnable) - await StopAsync().ConfigureAwait(false); - } - - #endregion worker服务 } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/HostService/IAlarmHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/IAlarmHostedService.cs similarity index 73% rename from src/Gateway/ThingsGateway.Gateway.Application/HostService/IAlarmHostedService.cs rename to src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/IAlarmHostedService.cs index f8fbd9e49..63bf3bd28 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/HostService/IAlarmHostedService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/AlarmManage/IAlarmHostedService.cs @@ -14,19 +14,9 @@ namespace ThingsGateway.Gateway.Application; public interface IAlarmHostedService : IHostedService { - - /// - /// 报警改变事件 - /// - event VariableAlarmEventHandler OnAlarmChanged; - - /// - /// 实时报警列表 - /// - IReadOnlyDictionary ReadOnlyRealAlarmVariables { get; } /// /// 确认报警 /// /// - void ConfirmAlarm(VariableRunTime item); + void ConfirmAlarm(VariableRuntime item); } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/ChannelManage/ChannelThreadManage.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/ChannelManage/ChannelThreadManage.cs new file mode 100644 index 000000000..74bab2b18 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/ChannelManage/ChannelThreadManage.cs @@ -0,0 +1,195 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.Extensions.Logging; + +using System.Collections.Concurrent; + +namespace ThingsGateway.Gateway.Application; + +internal sealed class ChannelThreadManage : IChannelThreadManage +{ + private ILogger _logger; + private static IDispatchService channelRuntimeDispatchService; + private static IDispatchService ChannelRuntimeDispatchService + { + get + { + if (channelRuntimeDispatchService == null) + channelRuntimeDispatchService = App.GetService>(); + + return channelRuntimeDispatchService; + } + } + + public ChannelThreadManage() + { + _logger = App.RootServices.GetService().CreateLogger($"ChannelThreadService"); + } + + public ConcurrentDictionary DeviceThreadManages { get; } = new(); + + #region 设备管理 + + private WaitLock NewChannelLock = new(); + /// + /// 移除指定通道 + /// + /// 要移除的通道ID + private async Task PrivateRemoveChannelsAsync(IEnumerable channelIds) + { + + await channelIds.ParallelForEachAsync(async (channelId, token) => + { + try + { + if (!DeviceThreadManages.TryRemove(channelId, out var deviceThreadManage)) return; + + await deviceThreadManage.DisposeAsync().ConfigureAwait(false); + + } + catch (Exception ex) + { + _logger.LogWarning(ex, nameof(PrivateRemoveChannelsAsync)); + } + }, Environment.ProcessorCount).ConfigureAwait(false); + + } + + /// + /// 移除指定通道 + /// + /// 要移除的通道ID + public async Task RemoveChannelAsync(long channelId) + { + try + { + await NewChannelLock.WaitAsync().ConfigureAwait(false); + + await PrivateRemoveChannelsAsync(Enumerable.Repeat(channelId, 1)).ConfigureAwait(false); + ChannelRuntimeDispatchService.Dispatch(null); + } + finally + { + NewChannelLock.Release(); + + } + } + + /// + /// 移除指定通道 + /// + /// 要移除的通道ID + public async Task RemoveChannelAsync(IEnumerable channelIds) + { + try + { + await NewChannelLock.WaitAsync().ConfigureAwait(false); + + await PrivateRemoveChannelsAsync(channelIds).ConfigureAwait(false); + ChannelRuntimeDispatchService.Dispatch(null); + } + finally + { + NewChannelLock.Release(); + + } + } + + + + private async Task PrivateRestartChannelAsync(IEnumerable channelRuntimes) + { + await PrivateRemoveChannelsAsync(channelRuntimes.Select(a => a.Id)).ConfigureAwait(false); + + + await channelRuntimes.ParallelForEachAsync(async (channelRuntime, token) => + { + try + { + if (channelRuntime.IsCollect == true) + { + if (!GlobalData.StartCollectChannelEnable) + { + return; + } + } + else + { + if (!GlobalData.StartBusinessChannelEnable) + { + return; + } + } + + // 检查通道是否启用 + if (channelRuntime?.Enable != true) + return; + + // 创建新的通道线程,并将驱动程序添加到其中 + DeviceThreadManage deviceThreadManage = new DeviceThreadManage(channelRuntime); + + DeviceThreadManages.TryAdd(deviceThreadManage.ChannelId, deviceThreadManage); + + deviceThreadManage.ChannelThreadManage = this; + + await deviceThreadManage.RestartDeviceAsync(channelRuntime.DeviceRuntimes.Select(a => a.Value), false).ConfigureAwait(false); + + } + catch (Exception ex) + { + _logger.LogWarning(ex, nameof(PrivateRestartChannelAsync)); + } + + }, Environment.ProcessorCount).ConfigureAwait(false); + + + } + + /// + /// 添加通道 + /// + public async Task RestartChannelAsync(ChannelRuntime channelRuntime) + { + try + { + await NewChannelLock.WaitAsync().ConfigureAwait(false); + await PrivateRestartChannelAsync(Enumerable.Repeat(channelRuntime, 1)).ConfigureAwait(false); + ChannelRuntimeDispatchService.Dispatch(null); + } + finally + { + NewChannelLock.Release(); + } + } + + /// + /// 向当前通道添加设备 + /// + public async Task RestartChannelAsync(IEnumerable channelRuntimes) + { + + try + { + await NewChannelLock.WaitAsync().ConfigureAwait(false); + await PrivateRestartChannelAsync(channelRuntimes).ConfigureAwait(false); + ChannelRuntimeDispatchService.Dispatch(null); + } + finally + { + NewChannelLock.Release(); + } + } + #endregion + + +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/ChannelManage/IChannelThreadManage.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/ChannelManage/IChannelThreadManage.cs new file mode 100644 index 000000000..c68ba151b --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/ChannelManage/IChannelThreadManage.cs @@ -0,0 +1,24 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using System.Collections.Concurrent; + +namespace ThingsGateway.Gateway.Application; + +public interface IChannelThreadManage +{ + ConcurrentDictionary DeviceThreadManages { get; } + + Task RestartChannelAsync(ChannelRuntime channelRuntime); + Task RestartChannelAsync(IEnumerable channelRuntimes); + + Task RemoveChannelAsync(IEnumerable channelIds); + Task RemoveChannelAsync(long channelId); +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/DeviceManage/DeviceThreadManage.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/DeviceManage/DeviceThreadManage.cs new file mode 100644 index 000000000..af1df5e09 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/DeviceManage/DeviceThreadManage.cs @@ -0,0 +1,853 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Localization; + +using System.Collections.Concurrent; + +using TouchSocket.Core; + +namespace ThingsGateway.Gateway.Application; + +/// +/// 设备线程管理 +/// +internal sealed class DeviceThreadManage : IAsyncDisposable, IDeviceThreadManage +{ + #region 动态配置 + + /// + /// 线程最大等待间隔时间 + /// + public static volatile ChannelThreadOptions ChannelThreadOptions = App.GetOptions(); + + /// + /// 线程等待间隔时间 + /// + public static volatile int CycleInterval = ChannelThreadOptions.MaxCycleInterval; + + private static IDispatchService devicelRuntimeDispatchService; + private static IDispatchService DeviceRuntimeDispatchService + { + get + { + if (devicelRuntimeDispatchService == null) + devicelRuntimeDispatchService = App.GetService>(); + + return devicelRuntimeDispatchService; + } + } + static DeviceThreadManage() + { + Task.Factory.StartNew(async () => await SetCycleInterval().ConfigureAwait(false), TaskCreationOptions.LongRunning); + } + + private static async Task SetCycleInterval() + { + var appLifetime = App.RootServices!.GetService()!; + var hardwareJob = GlobalData.HardwareJob; + + List cpus = new(); + while (!appLifetime.ApplicationStopping.IsCancellationRequested) + { + try + { + if (hardwareJob?.HardwareInfo?.MachineInfo?.CpuRate == null) continue; + cpus.Add((float)(hardwareJob.HardwareInfo.MachineInfo.CpuRate * 100)); + if (cpus.Count == 1 || cpus.Count > 5) + { + var avg = cpus.Average(); + cpus.RemoveAt(0); + //Console.WriteLine($"CPU平均值:{avg}"); + if (avg > 80) + { + CycleInterval = Math.Max(CycleInterval, (int)(ChannelThreadOptions.MaxCycleInterval * avg / 100)); + } + else if (avg < 50) + { + CycleInterval = Math.Min(CycleInterval, ChannelThreadOptions.MinCycleInterval); + } + } + } + catch (OperationCanceledException) + { + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + finally + { + await Task.Delay(30000, appLifetime?.ApplicationStopping ?? default).ConfigureAwait(false); + } + } + } + + #endregion 动态配置 + + /// + /// 通道线程构造函数,用于初始化通道线程实例。 + /// + /// 通道表 + public DeviceThreadManage(ChannelRuntime channelRuntime) + { + Localizer = App.CreateLocalizerByType(typeof(DeviceThreadManage)); + + var config = new TouchSocketConfig(); + LogMessage = new LoggerGroup() { LogLevel = TouchSocket.Core.LogLevel.Warning };//不显示调试日志 + // 配置容器中注册日志记录器实例 + config.ConfigureContainer(a => a.RegisterSingleton(LogMessage)); + + // 设置通道信息 + CurrentChannel = channelRuntime; + + var logger = App.RootServices.GetService().CreateLogger($"DeviceThreadManage[{channelRuntime.Name}]"); + // 添加默认日志记录器 + LogMessage.AddLogger(new EasyLogger(logger.Log_Out) { LogLevel = TouchSocket.Core.LogLevel.Trace }); + + var ichannel = config.GetChannel(channelRuntime); + + // 根据配置获取通道实例 + Channel = ichannel; + + //初始设置输出文本日志 + SetLog(CurrentChannel.LogEnable, CurrentChannel.LogLevel); + + channelRuntime.DeviceThreadManage = this; + + GlobalData.DeviceStatusChangeEvent += GlobalData_DeviceStatusChangeEvent; + + LogMessage?.LogInformation(Localizer["ChannelCreate", channelRuntime.Name]); + _ = Task.Run(CheckThreadAsync); + _ = Task.Run(CheckRedundantAsync); + } + + #region 日志 + + private WaitLock SetLogLock = new(); + public async Task SetLogAsync(bool enable, LogLevel? logLevel = null, bool upDataBase = true) + { + try + { + await SetLogLock.WaitAsync().ConfigureAwait(false); + bool up = false; + + if (upDataBase && (CurrentChannel.LogEnable != enable || (logLevel != null && CurrentChannel.LogLevel != logLevel))) + { + up = true; + } + + CurrentChannel.LogEnable = enable; + if (logLevel != null) + CurrentChannel.LogLevel = logLevel.Value; + if (up) + { + //更新数据库 + await GlobalData.ChannelService.UpdateLogAsync(CurrentChannel.Id, CurrentChannel.LogEnable, CurrentChannel.LogLevel).ConfigureAwait(false); + } + + SetLog(CurrentChannel.LogEnable, CurrentChannel.LogLevel); + + } + catch (Exception ex) + { + LogMessage?.LogWarning(ex); + } + finally + { + SetLogLock.Release(); + } + } + private void SetLog(bool enable, LogLevel? logLevel = null) + { + // 如果日志使能状态为 true + if (enable) + { + + LogMessage.LogLevel = logLevel ?? TouchSocket.Core.LogLevel.Trace; + // 移除旧的文件日志记录器并释放资源 + if (TextLogger != null) + { + LogMessage.RemoveLogger(TextLogger); + TextLogger?.Dispose(); + } + + // 创建新的文件日志记录器,并设置日志级别为 Trace + TextLogger = TextFileLogger.GetMultipleFileLogger(LogPath); + TextLogger.LogLevel = logLevel ?? TouchSocket.Core.LogLevel.Trace; + // 将文件日志记录器添加到日志消息组中 + LogMessage.AddLogger(TextLogger); + } + else + { + if (logLevel != null) + LogMessage.LogLevel = logLevel.Value; + //LogMessage.LogLevel = TouchSocket.Core.LogLevel.Warning; + // 如果日志使能状态为 false,移除文件日志记录器并释放资源 + if (TextLogger != null) + { + LogMessage.RemoveLogger(TextLogger); + TextLogger?.Dispose(); + } + } + } + + private TextFileLogger? TextLogger; + + public LoggerGroup LogMessage { get; private set; } + + public string LogPath => CurrentChannel?.LogPath; + + + #endregion + + #region 属性 + + /// + /// 是否采集通道 + /// + public bool? IsCollectChannel => CurrentChannel.IsCollect; + + public long ChannelId => CurrentChannel.Id; + + internal IChannel? Channel { get; } + + public ChannelRuntime CurrentChannel { get; } + + /// + /// 任务 + /// + internal ConcurrentDictionary DriverTasks { get; set; } = new(); + + /// + /// 取消令箭列表 + /// + private ConcurrentDictionary CancellationTokenSources { get; set; } = new(); + + /// + /// 插件列表 + /// + private ConcurrentDictionary Drivers { get; set; } = new(); + + private IStringLocalizer Localizer { get; } + public IChannelThreadManage ChannelThreadManage { get; internal set; } + + #endregion + + #region 设备管理 + + private WaitLock NewDeviceLock = new(); + + /// + /// 向当前通道添加设备 + /// + public async Task RestartDeviceAsync(DeviceRuntime deviceRuntime, bool deleteCache) + { + try + { + await NewDeviceLock.WaitAsync().ConfigureAwait(false); + await PrivateRestartDeviceAsync(Enumerable.Repeat(deviceRuntime, 1), deleteCache).ConfigureAwait(false); + DeviceRuntimeDispatchService.Dispatch(null); + } + finally + { + NewDeviceLock.Release(); + } + } + + /// + /// 向当前通道添加设备 + /// + public async Task RestartDeviceAsync(IEnumerable deviceRuntimes, bool deleteCache) + { + + try + { + await NewDeviceLock.WaitAsync().ConfigureAwait(false); + await PrivateRestartDeviceAsync(deviceRuntimes, deleteCache).ConfigureAwait(false); + DeviceRuntimeDispatchService.Dispatch(null); + } + finally + { + NewDeviceLock.Release(); + } + } + + private async Task PrivateRestartDeviceAsync(IEnumerable deviceRuntimes, bool deleteCache) + { + try + { + + await PrivateRemoveDevicesAsync(deviceRuntimes.Select(a => a.Id)).ConfigureAwait(false); + + if (Disposed) + { + return; + } + + if (deleteCache) + { + await Task.Delay(1000).ConfigureAwait(false); + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + await Task.Delay(1000).ConfigureAwait(false); + var basePath = CacheDBUtil.GetFileBasePath(); + + + var strings = deviceRuntimes.Select(a => a.Id.ToString()).ToHashSet(); + var dirs = Directory.GetDirectories(basePath).Where(a => strings.Contains(Path.GetFileName(a))); + foreach (var dir in dirs) + { + //删除文件夹 + try + { + Directory.Delete(dir, true); + } + catch { } + } + + } + + + var idSet = GlobalData.GetRedundantDeviceIds(); + + await deviceRuntimes.ParallelForEachAsync(async (deviceRuntime, cancellationToken) => + { + if (deviceRuntime.IsCollect == true) + { + if (!GlobalData.StartCollectChannelEnable) + { + return; + } + } + else + { + if (!GlobalData.StartBusinessChannelEnable) + { + return; + } + } + + if (!deviceRuntime.Enable) return; + if (Disposed) return; + if (idSet.Contains(deviceRuntime.Id)) return; + DriverBase driver = null; + try + { + driver = CreateDriver(deviceRuntime); + + + Drivers.TryRemove(deviceRuntime.Id, out _); + + // 将驱动程序对象添加到驱动程序集合中 + Drivers.TryAdd(driver.DeviceId, driver); + + // 将当前通道线程分配给驱动程序对象 + driver.DeviceThreadManage = this; + + + // 初始化驱动程序对象,并加载源读取 + driver.InitChannel(Channel); + + if (Channel != null && Drivers.Count <= 1) + { + await Channel.SetupAsync(Channel.Config.Clone()).ConfigureAwait(false); + } + } + catch (Exception ex) + { + // 如果初始化过程中发生异常,设置初始化状态为失败,并记录警告日志 + if (driver != null) + driver.IsInitSuccess = false; + LogMessage?.LogWarning(ex, Localizer["InitFail", CurrentChannel.PluginName, driver?.DeviceName]); + } + + // 创建令牌并与驱动程序对象的设备ID关联,用于取消操作 + var cts = new CancellationTokenSource(); + var token = cts.Token; + + if (!CancellationTokenSources.TryAdd(driver.DeviceId, cts)) + { + try + { + cts.Cancel(); + cts.SafeDispose(); + } + catch + { + } + } + + // 初始化业务线程 + var driverTask = new DoTask((t => DoWork(driver, t)), driver.LogMessage, null); + DriverTasks.TryAdd(driver.DeviceId, driverTask); + + + driverTask.Start(token); + + + }, Environment.ProcessorCount).ConfigureAwait(false); + + ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletionPortThreads); + var taskCount = GlobalData.Devices.Count; + if (taskCount * 3 > maxWorkerThreads) + { + var result = ThreadPool.SetMaxThreads(taskCount + maxWorkerThreads, taskCount + maxCompletionPortThreads); + } + + } + catch (Exception ex) + { + LogMessage?.LogWarning(ex); + } + } + + /// + /// 移除指定设备 + /// + /// 要移除的设备ID + public async Task RemoveDeviceAsync(long deviceId) + { + try + { + await NewDeviceLock.WaitAsync().ConfigureAwait(false); + + await PrivateRemoveDevicesAsync(Enumerable.Repeat(deviceId, 1)).ConfigureAwait(false); + DeviceRuntimeDispatchService.Dispatch(null); + } + finally + { + NewDeviceLock.Release(); + + } + } + + /// + /// 移除指定设备 + /// + /// 要移除的设备ID + public async Task RemoveDeviceAsync(IEnumerable deviceIds) + { + try + { + await NewDeviceLock.WaitAsync().ConfigureAwait(false); + + await PrivateRemoveDevicesAsync(deviceIds).ConfigureAwait(false); + DeviceRuntimeDispatchService.Dispatch(null); + } + finally + { + NewDeviceLock.Release(); + + } + } + + /// + /// 移除指定设备 + /// + /// 要移除的设备ID + private async Task PrivateRemoveDevicesAsync(IEnumerable deviceIds) + { + try + { + ConcurrentList saveDevices = new(); + await deviceIds.ParallelForEachAsync(async (deviceId, cancellationToken) => + { + // 查找具有指定设备ID的驱动程序对象 + if (!Drivers.TryRemove(deviceId, out var driver)) return; + if (!DriverTasks.TryRemove(deviceId, out var task)) return; + + if (IsCollectChannel == true) + { + saveDevices.AddRange(driver.VariableRuntimes.Where(a => a.Value.SaveValue).Select(a => a.Value)); + } + + // 取消驱动程序的操作 + if (CancellationTokenSources.TryRemove(deviceId, out var token)) + { + if (token != null) + { + token.Cancel(); + token.Dispose(); + } + } + driver.Stop(); + await task.StopAsync().ConfigureAwait(false); + }, Environment.ProcessorCount).ConfigureAwait(false); + + + await Task.Delay(100).ConfigureAwait(false); + + // 如果是采集通道,更新变量初始值 + if (IsCollectChannel == true) + { + try + { + //添加保存数据变量读取操作 + var saveVariable = saveDevices.Select(a => (Variable)a).ToList(); + + await GlobalData.VariableService.UpdateInitValueAsync(saveVariable).ConfigureAwait(false); + } + catch (Exception ex) + { + LogMessage.LogWarning(ex, "SaveValue"); + } + } + } + catch (Exception ex) + { + LogMessage?.LogWarning(ex); + } + } + + /// + /// 创建插件实例,并根据设备属性设置实例 + /// + /// 当前设备 + /// 插件实例 + private static DriverBase CreateDriver(DeviceRuntime deviceRuntime) + { + var pluginService = GlobalData.PluginService; + var driver = pluginService.GetDriver(deviceRuntime.PluginName); + + // 初始化插件配置项 + driver.InitDevice(deviceRuntime); + + // 设置设备属性到插件实例 + pluginService.SetDriverProperties(driver, deviceRuntime.DevicePropertys); + + return driver; + } + + + private async ValueTask DoWork(DriverBase driver, CancellationToken token) + { + try + { + if (token.IsCancellationRequested) + { + driver.Stop(); + return; + } + + // 只有当驱动成功初始化后才执行操作 + if (driver.IsInitSuccess) + { + if (!driver.IsStarted) + await driver.StartAsync(token).ConfigureAwait(false); // 调用驱动的启动前异步方法,如果已经执行,会直接返回 + + var result = await driver.ExecuteAsync(token).ConfigureAwait(false); // 执行驱动的异步执行操作 + + // 根据执行结果进行不同的处理 + if (result == ThreadRunReturnTypeEnum.None) + { + // 如果驱动处于离线状态且为采集驱动,则根据配置的间隔时间进行延迟 + if (driver.CurrentDevice.DeviceStatus == DeviceStatusEnum.OffLine && IsCollectChannel == true) + { + driver.CurrentDevice.CheckEnable = false; + await Task.Delay(Math.Max(Math.Min(((CollectBase)driver).CollectProperties.ReIntervalTime, ChannelThreadOptions.CheckInterval / 2) - CycleInterval, 3000), token).ConfigureAwait(false); + driver.CurrentDevice.CheckEnable = true; + } + else + { + await Task.Delay(CycleInterval, token).ConfigureAwait(false); // 默认延迟一段时间后再继续执行 + } + } + else if (result == ThreadRunReturnTypeEnum.Continue) + { + await Task.Delay(1000, token).ConfigureAwait(false); // 如果执行结果为继续,则延迟一段较短的时间后再继续执行 + } + else if (result == ThreadRunReturnTypeEnum.Break && token.IsCancellationRequested) + { + driver.Stop(); // 执行驱动的释放操作 + return; // 结束当前循环 + } + } + else + { + await Task.Delay(60000, token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + if (token.IsCancellationRequested) + driver.Stop(); + return; + } + catch (ObjectDisposedException) + { + if (token.IsCancellationRequested) + driver.Stop(); + return; + } + + + } + + + #endregion + + #region 设备冗余切换 + + + private void GlobalData_DeviceStatusChangeEvent(DeviceRuntime deviceRuntime, DeviceBasicData deviceData) + { + if (deviceRuntime.ChannelId != ChannelId) return; + + if (deviceRuntime.RedundantEnable && deviceRuntime.Driver != null) + { + if (deviceRuntime.RedundantSwitchType == RedundantSwitchTypeEnum.OffLine) + { + _ = Task.Run(async () => + { + if (deviceRuntime.Driver != null) + { + if (deviceRuntime.DeviceStatus == DeviceStatusEnum.OffLine && (deviceRuntime.Driver?.IsInitSuccess == false || deviceRuntime.Driver?.IsStarted == true) && deviceRuntime.Driver?.DisposedValue != true) + { + await Task.Delay(deviceRuntime.RedundantScanIntervalTime).ConfigureAwait(false);//10s后再次检测 + if (deviceRuntime.DeviceStatus == DeviceStatusEnum.OffLine && (deviceRuntime.Driver?.IsInitSuccess == false || deviceRuntime.Driver?.IsStarted == true) && deviceRuntime.Driver?.DisposedValue != true) + { + //冗余切换 + if (deviceRuntime.RedundantEnable) + { + await DeviceRedundantThreadAsync(deviceRuntime.Id).ConfigureAwait(false); + } + } + } + } + }); + } + + } + } + + private static void SetRedundantDevice(DeviceRuntime? dev, Device? newDev) + { + dev.DevicePropertys = newDev.DevicePropertys; + dev.Description = newDev.Description; + dev.ChannelId = newDev.ChannelId; + dev.IntervalTime = newDev.IntervalTime; + dev.Name = newDev.Name; + } + + /// + public async Task DeviceRedundantThreadAsync(long deviceId) + { + try + { + + if (!CurrentChannel.DeviceRuntimes.TryGetValue(deviceId, out var deviceRuntime)) return; + + //实际上DevicerRuntime是不变的,一直都是主设备对象,只是获取备用设备,改变设备插件属性 + //这里先停止采集,操作会使线程取消,需要重新恢复线程 + await RemoveDeviceAsync(deviceRuntime.Id).ConfigureAwait(false); + if (deviceRuntime.RedundantType == RedundantTypeEnum.Standby) + { + var newDev = await GlobalData.DeviceService.GetDeviceByIdAsync(deviceRuntime.Id).ConfigureAwait(false);//获取设备属性 + if (newDev == null) + { + LogMessage?.LogWarning($"Device with id {deviceRuntime.Id} not found"); + } + else + { + //冗余切换时,改变全部属性,但不改变变量信息 + SetRedundantDevice(deviceRuntime, newDev); + deviceRuntime.RedundantType = RedundantTypeEnum.Primary; + LogMessage?.LogInformation($"Device {deviceRuntime.Name} switched to primary channel"); + } + } + else + { + try + { + var newDev = await GlobalData.DeviceService.GetDeviceByIdAsync(deviceRuntime.RedundantDeviceId ?? 0).ConfigureAwait(false); + if (newDev == null) + { + LogMessage?.LogWarning($"Failed to update device thread, device with id {deviceRuntime.RedundantDeviceId} does not exist"); + } + else + { + SetRedundantDevice(deviceRuntime, newDev); + deviceRuntime.RedundantType = RedundantTypeEnum.Standby; + LogMessage?.LogInformation($"Device {deviceRuntime.Name} switched to standby channel"); + } + } + catch + { + } + } + + //找出新的通道,添加设备线程 + + + if (!GlobalData.Channels.TryGetValue(deviceRuntime.ChannelId, out var channelRuntime)) + LogMessage?.LogWarning($"device {deviceRuntime.Name} cannot found channel with id{deviceRuntime.ChannelId}"); + + + deviceRuntime.Init(channelRuntime); + await channelRuntime.DeviceThreadManage.RestartDeviceAsync(deviceRuntime, false).ConfigureAwait(false); + + } + catch (Exception ex) + { + LogMessage.LogWarning(ex); + } + } + + /// + private async Task CheckRedundantAsync() + { + while (!Disposed) + { + try + { + //检测设备线程假死 + await Task.Delay(1000).ConfigureAwait(false); + foreach (var kv in Drivers) + { + if (Disposed) return; + var deviceRuntime = kv.Value.CurrentDevice; + if (deviceRuntime.RedundantEnable && deviceRuntime.Driver != null && deviceRuntime.RedundantSwitchType == RedundantSwitchTypeEnum.Script) + { + _ = Task.Run(async () => + { + if (deviceRuntime.Driver != null) + { + if ((deviceRuntime.Driver?.IsInitSuccess == false || deviceRuntime.Driver?.IsStarted == true) && deviceRuntime.Driver?.DisposedValue != true) + { + await Task.Delay(deviceRuntime.RedundantScanIntervalTime).ConfigureAwait(false);//10s后再次检测 + if (Disposed) return; + if ((deviceRuntime.Driver?.IsInitSuccess == false || deviceRuntime.Driver?.IsStarted == true) && deviceRuntime.Driver?.DisposedValue != true) + { + //冗余切换 + if (deviceRuntime.RedundantEnable) + { + await DeviceRedundantThreadAsync(deviceRuntime.Id).ConfigureAwait(false); + } + } + } + } + }); + } + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + LogMessage.LogError(ex, nameof(CheckRedundantAsync)); + } + } + } + + #endregion + + #region 假死检测 + + /// + private async Task CheckThreadAsync() + { + while (!Disposed) + { + try + { + //检测设备线程假死 + await Task.Delay(ChannelThreadOptions.CheckInterval).ConfigureAwait(false); + if (Disposed) return; + + var num = Drivers.Count; + foreach (var driver in Drivers.Select(a => a.Value).ToList()) + { + try + { + if (Disposed) return; + if (driver.CurrentDevice != null) + { + //线程卡死/初始化失败检测 + if (((driver.IsStarted && driver.CurrentDevice.ActiveTime != DateTime.UnixEpoch.ToLocalTime() && driver.CurrentDevice.ActiveTime.AddMinutes(ChannelThreadOptions.CheckInterval) <= DateTime.Now) + || (driver.IsInitSuccess == false)) && !driver.DisposedValue) + { + //如果线程处于暂停状态,跳过 + if (driver.CurrentDevice.DeviceStatus == DeviceStatusEnum.Pause) + continue; + //如果初始化失败 + if (!driver.IsInitSuccess) + LogMessage?.LogWarning($"Device {driver.CurrentDevice.Name} initialization failed, restarting thread"); + else + LogMessage?.LogWarning($"Device {driver.CurrentDevice.Name} thread died, restarting thread"); + //重启线程 + await RestartDeviceAsync(driver.CurrentDevice, false).ConfigureAwait(false); + break; + } + } + } + catch (Exception ex) + { + LogMessage.LogError(ex, nameof(CheckThreadAsync)); + } + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + catch (Exception ex) + { + LogMessage.LogError(ex, nameof(CheckThreadAsync)); + } + } + } + + #endregion + + #region 外部获取 + + internal IDriver? GetDriver(long deviceId) + { + return Drivers.TryGetValue(deviceId, out var driver) ? driver : null; + } + + internal bool Has(long deviceId) + { + return Drivers.ContainsKey(deviceId); + } + bool Disposed; + public async ValueTask DisposeAsync() + { + Disposed = true; + try + { + Channel?.SafeDispose(); + + LogMessage?.LogInformation(Localizer["ChannelDispose", CurrentChannel?.Name ?? string.Empty]); + + await NewDeviceLock.WaitAsync().ConfigureAwait(false); + + await PrivateRemoveDevicesAsync(Drivers.Keys).ConfigureAwait(false); + } + finally + { + NewDeviceLock.Release(); + } + } + + + + #endregion 外部获取 + +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/DeviceManage/IDeviceThreadManage.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/DeviceManage/IDeviceThreadManage.cs new file mode 100644 index 000000000..2495c1056 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/DeviceManage/IDeviceThreadManage.cs @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using TouchSocket.Core; + +namespace ThingsGateway.Gateway.Application; + +public interface IDeviceThreadManage : IAsyncDisposable +{ + long ChannelId { get; } + bool? IsCollectChannel { get; } + ChannelRuntime CurrentChannel { get; } + LoggerGroup LogMessage { get; } + string LogPath { get; } + IChannelThreadManage ChannelThreadManage { get; } + + Task SetLogAsync(bool enable, LogLevel? logLevel = null, bool upDataBase = true); + Task RestartDeviceAsync(DeviceRuntime deviceRuntime, bool deleteCache); + Task RestartDeviceAsync(IEnumerable deviceRuntimes, bool deleteCache); + + Task RemoveDeviceAsync(IEnumerable deviceIds); + Task RemoveDeviceAsync(long deviceId); + Task DeviceRedundantThreadAsync(long deviceId); +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/GatewayMonitorHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/GatewayMonitorHostedService.cs new file mode 100644 index 000000000..850bc1584 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/GatewayMonitorHostedService.cs @@ -0,0 +1,95 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Mapster; + +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; + +namespace ThingsGateway.Gateway.Application; + +/// +/// 通道后台服务 +/// +internal sealed class GatewayMonitorHostedService : BackgroundService, IGatewayMonitorHostedService +{ + private readonly ILogger _logger; + /// + public GatewayMonitorHostedService(ILogger logger, IStringLocalizer localizer, IChannelThreadManage channelThreadManage) + { + _logger = logger; + Localizer = localizer; + ChannelThreadManage = channelThreadManage; + } + + private IStringLocalizer Localizer { get; } + + + private IChannelThreadManage ChannelThreadManage { get; } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await Task.Yield(); + try + { + + //网关启动时,获取所有通道 + var channelRuntimes = (await GlobalData.ChannelService.GetAllAsync().ConfigureAwait(false)).Adapt>(); + var deviceRuntimes = (await GlobalData.DeviceService.GetAllAsync().ConfigureAwait(false)).Adapt>(); + var variableRuntimes = (await GlobalData.VariableService.GetAllAsync().ConfigureAwait(false)).Adapt>(); + foreach (var channelRuntime in channelRuntimes) + { + try + { + channelRuntime.Init(); + var devRuntimes = deviceRuntimes.Where(x => x.ChannelId == channelRuntime.Id); + foreach (var item in devRuntimes) + { + item.Init(channelRuntime); + + var varRuntimes = variableRuntimes.Where(x => x.DeviceId == item.Id); + + varRuntimes.ParallelForEach(varItem => + { + varItem.Init(item); + }); + + } + + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Init Channel"); + } + } + + var startCollectChannelEnable = GlobalData.StartCollectChannelEnable; + var startBusinessChannelEnable = GlobalData.StartBusinessChannelEnable; + + var collectChannelRuntimes = channelRuntimes.Where(x => (x.Enable && x.IsCollect == true && startCollectChannelEnable)); + + var businessChannelRuntimes = channelRuntimes.Where(x => (x.Enable && x.IsCollect == false && startBusinessChannelEnable)); + + //根据初始冗余属性,筛选启动 + await ChannelThreadManage.RestartChannelAsync(businessChannelRuntimes).ConfigureAwait(false); + await ChannelThreadManage.RestartChannelAsync(collectChannelRuntimes).ConfigureAwait(false); + + + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Start error"); + } + + + } + +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/IGatewayMonitorHostedService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/IGatewayMonitorHostedService.cs new file mode 100644 index 000000000..999e43152 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/IGatewayMonitorHostedService.cs @@ -0,0 +1,17 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using Microsoft.Extensions.Hosting; + +namespace ThingsGateway.Gateway.Application; + +public interface IGatewayMonitorHostedService : IHostedService +{ +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/IGatewayRedundantSerivce.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/IGatewayRedundantSerivce.cs new file mode 100644 index 000000000..265a3adb0 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/GatewayMonitor/IGatewayRedundantSerivce.cs @@ -0,0 +1,28 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Gateway.Application; + +/// +/// 网关冗余服务 +/// +public interface IGatewayRedundantSerivce +{ + /// + /// 采集通道是否可用 + /// + public bool StartCollectChannelEnable { get; } + + /// + /// 业务通道是否可用 + /// + public bool StartBusinessChannelEnable { get; } + +} diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/IPluginService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/IPluginService.cs index ec5b050ec..3f96e4d5b 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/IPluginService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/IPluginService.cs @@ -17,6 +17,9 @@ namespace ThingsGateway.Gateway.Application; /// public interface IPluginService { + Type GetDebugUI(string pluginName); + Type GetAddressUI(string pluginName); + /// /// 根据插件全名称构建插件实例 /// @@ -27,22 +30,23 @@ public interface IPluginService /// /// 获取插件动态注册的方法 /// - List GetDriverMethodInfos(string pluginName, DriverBase? driverBase = null); + List GetDriverMethodInfos(string pluginName, IDriver? driver = null); /// /// 获取插件属性 /// /// - /// + /// /// - (IEnumerable EditorItems, object Model, Type PropertyUIType) GetDriverPropertyTypes(string pluginName, DriverBase? driverBase = null); + (IEnumerable EditorItems, object Model, Type PropertyUIType) GetDriverPropertyTypes(string pluginName, IDriver? driver = null); /// /// 根据插件类型获取信息 /// /// /// - List GetList(PluginTypeEnum? pluginType = null); + List GetList(PluginTypeEnum? pluginType = null); + /// /// 获取变量属性 @@ -55,7 +59,7 @@ public interface IPluginService /// /// 分页显示插件 /// - public QueryData Page(QueryPageOptions options, PluginTypeEnum? pluginTypeEnum = null); + public QueryData Page(QueryPageOptions options, PluginTypeEnum? pluginTypeEnum = null); /// /// 重载插件 @@ -74,5 +78,6 @@ public interface IPluginService /// /// /// - void SetDriverProperties(DriverBase driver, Dictionary deviceProperties); + void SetDriverProperties(IDriver driver, Dictionary deviceProperties); + } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/PluginService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/PluginService.cs index ada06bdfc..de555ac7f 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/PluginService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/PluginService.cs @@ -40,21 +40,17 @@ internal sealed class PluginService : IPluginService /// public const string DirName = "GatewayPlugins"; - private const string _cacheKeyGetPluginOutputs = $"{ThingsGatewayCacheConst.Cache_Prefix}{nameof(PluginService)}{nameof(GetList)}"; + private const string CacheKeyGetPluginOutputs = $"{ThingsGatewayCacheConst.Cache_Prefix}{nameof(PluginService)}{nameof(GetList)}"; private const string SaveEx = ".save"; private const string DelEx = ".del"; - private readonly IDispatchService _dispatchService; + private readonly IDispatchService _dispatchService; private readonly WaitLock _locker = new(); - - /// - /// 驱动服务日志 - /// private readonly ILogger _logger; private IStringLocalizer Localizer; - public PluginService(ILogger logger, IStringLocalizer localizer, IDispatchService dispatchService) + public PluginService(ILogger logger, IStringLocalizer localizer, IDispatchService dispatchService) { Localizer = localizer; _logger = logger; @@ -89,6 +85,18 @@ internal sealed class PluginService : IPluginService #region public + public Type GetDebugUI(string pluginName) + { + using var driver = GetDriver(pluginName); + return driver?.DriverDebugUIType; + } + public Type GetAddressUI(string pluginName) + { + using var driver = GetDriver(pluginName); + return driver?.DriverVariableAddressUIType; + } + + /// /// 根据插件名称获取对应的驱动程序。 /// @@ -108,8 +116,6 @@ internal sealed class PluginService : IPluginService if (_defaultDriverBaseDict.TryGetValue(pluginName, out var type)) { var driver = (DriverBase)Activator.CreateInstance(type); - if (!type.Assembly.Location.IsNullOrEmpty()) - driver.Directory = Path.GetDirectoryName(type.Assembly.Location); return driver; } @@ -121,7 +127,6 @@ internal sealed class PluginService : IPluginService if (_driverBaseDict.TryGetValue(pluginName, out var value)) { var driver = (DriverBase)Activator.CreateInstance(value); - driver.Directory = dir; return driver; } @@ -142,7 +147,6 @@ internal sealed class PluginService : IPluginService var driver = (DriverBase)Activator.CreateInstance(driverType); _logger?.LogInformation(Localizer[$"LoadTypeSuccess", pluginName]); _driverBaseDict.TryAdd(pluginName, driverType); - driver.Directory = dir; return driver; } // 抛出异常,插件类型不存在 @@ -165,17 +169,16 @@ internal sealed class PluginService : IPluginService /// 获取指定插件的特殊方法。 /// /// 插件名称。 - /// 可选参数,插件的驱动基类对象,如果未提供,则会尝试从缓存中获取。 + /// 可选参数,插件的驱动基类对象,如果未提供,则会尝试从缓存中获取。 /// 返回列表 - public List GetDriverMethodInfos(string pluginName, DriverBase? driverBase = null) + public List GetDriverMethodInfos(string pluginName, IDriver? driver = null) { - // 线程安全地执行方法 - lock (this) + //lock (this) { string cacheKey = $"{nameof(PluginService)}_{nameof(GetDriverMethodInfos)}_{CultureInfo.CurrentUICulture.Name}"; // 如果未提供驱动基类对象,则尝试根据插件名称获取驱动对象 - var dispose = driverBase == null; // 标记是否需要释放驱动对象 - driverBase ??= GetDriver(pluginName); // 如果未提供驱动对象,则根据插件名称获取驱动对象 + var dispose = driver == null; // 标记是否需要释放驱动对象 + driver ??= GetDriver(pluginName); // 如果未提供驱动对象,则根据插件名称获取驱动对象 // 检查插件名称是否为空或null if (!pluginName.IsNullOrEmpty()) @@ -190,13 +193,13 @@ internal sealed class PluginService : IPluginService } // 如果未从缓存中获取到指定插件的属性信息,则尝试从驱动基类对象中获取 - return SetDriverMethodInfosCache(driverBase, pluginName, cacheKey, dispose); // 获取并设置属性信息缓存 + return SetDriverMethodInfosCache(driver, pluginName, cacheKey, dispose); // 获取并设置属性信息缓存 // 用于设置驱动方法信息缓存的内部方法 - List SetDriverMethodInfosCache(DriverBase driverBase, string pluginName, string cacheKey, bool dispose) + List SetDriverMethodInfosCache(IDriver driver, string pluginName, string cacheKey, bool dispose) { // 获取驱动对象的方法信息,并筛选出带有 DynamicMethodAttribute 特性的方法 - var dependencyPropertyWithInfos = driverBase.GetType().GetMethods()?.SelectMany(it => + var dependencyPropertyWithInfos = driver.GetType().GetMethods()?.SelectMany(it => new[] { new { memberInfo = it, attribute = it.GetCustomAttribute() } }) .Where(x => x.attribute != null).ToList() .SelectMany(it => new[] @@ -215,7 +218,7 @@ internal sealed class PluginService : IPluginService // 如果是通过方法内部创建的驱动对象,则在方法执行完成后释放该驱动对象 if (dispose) - driverBase.SafeDispose(); + driver.SafeDispose(); // 返回获取到的属性信息字典 return result; @@ -223,21 +226,22 @@ internal sealed class PluginService : IPluginService } } + + /// /// 获取指定插件的属性类型及其信息,将其缓存在内存中 /// /// 插件名称 - /// 驱动基类实例,可选参数 + /// 驱动基类实例,可选参数 /// 返回包含属性名称及其信息的字典 - public (IEnumerable EditorItems, object Model, Type PropertyUIType) GetDriverPropertyTypes(string pluginName, DriverBase? driverBase = null) + public (IEnumerable EditorItems, object Model, Type PropertyUIType) GetDriverPropertyTypes(string pluginName, IDriver? driver = null) { - // 使用锁确保线程安全 - lock (this) + //lock (this) { string cacheKey = $"{nameof(PluginService)}_{nameof(GetDriverPropertyTypes)}_{CultureInfo.CurrentUICulture.Name}"; - var dispose = driverBase == null; - driverBase ??= GetDriver(pluginName); // 如果 driverBase 为 null, 获取驱动实例 + var dispose = driver == null; + driver ??= GetDriver(pluginName); // 如果 driver 为 null, 获取驱动实例 // 检查插件名称是否为空或空字符串 if (!pluginName.IsNullOrEmpty()) { @@ -248,22 +252,22 @@ internal sealed class PluginService : IPluginService { // 返回缓存中存储的属性类型数据 var editorItems = data[pluginName]; - return (editorItems, driverBase.DriverProperties, driverBase.DriverPropertyUIType); + return (editorItems, driver.DriverProperties, driver.DriverPropertyUIType); } } // 如果缓存中不存在该插件的数据,则重新获取并缓存 - return (SetCache(driverBase, pluginName, cacheKey, dispose), driverBase.DriverProperties, driverBase.DriverPropertyUIType); // 调用 SetCache 方法进行缓存并返回结果 + return (SetCache(driver, pluginName, cacheKey, dispose), driver.DriverProperties, driver.DriverPropertyUIType); // 调用 SetCache 方法进行缓存并返回结果 // 定义 SetCache 方法,用于设置缓存并返回 - IEnumerable SetCache(DriverBase driverBase, string pluginName, string cacheKey, bool dispose) + IEnumerable SetCache(IDriver driver, string pluginName, string cacheKey, bool dispose) { - var editorItems = driverBase.PluginPropertyEditorItems; + var editorItems = PluginServiceUtil.GetEditorItems(driver.DriverProperties?.GetType()).ToList(); // 将结果存入缓存中,键为插件名称 App.CacheService.HashAdd(cacheKey, pluginName, editorItems); - // 如果 dispose 参数为 true,则释放 driverBase 对象 + // 如果 dispose 参数为 true,则释放 driver 对象 if (dispose) - driverBase.SafeDispose(); + driver.SafeDispose(); return editorItems; } } @@ -274,15 +278,15 @@ internal sealed class PluginService : IPluginService /// /// 要筛选的插件类型,可选参数 /// 符合条件的插件列表 - public List GetList(PluginTypeEnum? pluginType = null) + public List GetList(PluginTypeEnum? pluginType = null) { // 获取完整的插件列表 - var pluginList = GetList(); + var pluginList = PrivateGetList(); if (pluginType == null) { // 如果未指定插件类型,则返回完整的插件列表 - return pluginList; + return pluginList.ToList(); } // 筛选出指定类型的插件 @@ -296,11 +300,11 @@ internal sealed class PluginService : IPluginService /// public (IEnumerable EditorItems, object Model, Type VariablePropertyUIType) GetVariablePropertyTypes(string pluginName, BusinessBase? businessBase = null) { - lock (this) + //lock (this) { string cacheKey = $"{nameof(PluginService)}_{nameof(GetVariablePropertyTypes)}_{CultureInfo.CurrentUICulture.Name}"; var dispose = businessBase == null; - businessBase ??= (BusinessBase)GetDriver(pluginName); // 如果 driverBase 为 null, 获取驱动实例 + businessBase ??= (BusinessBase)GetDriver(pluginName); // 如果 driver 为 null, 获取驱动实例 var data = App.CacheService.HashGetAll>(cacheKey); if (data?.ContainsKey(pluginName) == true) @@ -316,7 +320,7 @@ internal sealed class PluginService : IPluginService var editorItems = businessBase.PluginVariablePropertyEditorItems; // 将结果存入缓存中,键为插件名称 App.CacheService.HashAdd(cacheKey, pluginName, editorItems); - // 如果 dispose 参数为 true,则释放 driverBase 对象 + // 如果 dispose 参数为 true,则释放 driver 对象 if (dispose) businessBase.SafeDispose(); return editorItems; @@ -324,13 +328,14 @@ internal sealed class PluginService : IPluginService } } + /// /// 分页显示插件 /// - public QueryData Page(QueryPageOptions options, PluginTypeEnum? pluginTypeEnum = null) + public QueryData Page(QueryPageOptions options, PluginTypeEnum? pluginType = null) { //指定关键词搜索为插件FullName - var query = GetList(pluginTypeEnum).WhereIf(!options.SearchText.IsNullOrWhiteSpace(), a => a.FullName.Contains(options.SearchText)).GetQueryData(options); + var query = GetList(pluginType).WhereIf(!options.SearchText.IsNullOrWhiteSpace(), a => a.FullName.Contains(options.SearchText)).GetQueryData(options); return query; } @@ -543,7 +548,7 @@ internal sealed class PluginService : IPluginService /// /// 插件实例。 /// 插件属性,检索相同名称的属性后写入。 - public void SetDriverProperties(DriverBase driver, Dictionary deviceProperties) + public void SetDriverProperties(IDriver driver, Dictionary deviceProperties) { // 获取插件的属性信息列表 var pluginProperties = driver.DriverProperties?.GetType().GetRuntimeProperties() @@ -570,9 +575,9 @@ internal sealed class PluginService : IPluginService /// private void ClearCache() { - lock (this) + //lock (this) { - App.CacheService.Remove(_cacheKeyGetPluginOutputs); + App.CacheService.Remove(CacheKeyGetPluginOutputs); App.CacheService.DelByPattern($"{nameof(PluginService)}_"); //多语言缓存清理 @@ -585,12 +590,12 @@ internal sealed class PluginService : IPluginService foreach (var item in _assemblyLoadContextDict) { // 移除特定键 - dictionary.RemoveWhere(a => item.Value.Assembly.ExportedTypes.Select(b => b.AssemblyQualifiedName).Contains(a.Key)); + dictionary.RemoveWhere(a => item.Value.Assembly.ExportedTypes.Select(b => b.AssemblyQualifiedName).ToHashSet().Contains(a.Key)); } } - catch + catch (Exception ex) { - + NewLife.Log.XTrace.WriteException(ex); } _ = Task.Run(() => { @@ -626,31 +631,40 @@ internal sealed class PluginService : IPluginService /// private Assembly GetAssembly(string path, string fileName) { - Assembly assembly = null; - _logger?.LogInformation(Localizer["AddPluginFile", path]); - //全部程序集路径 - List paths = new(); - Directory.GetFiles(Path.GetDirectoryName(path), "*.dll").ToList().ForEach(a => paths.Add(a)); + try + { - if (_assemblyLoadContextDict.TryGetValue(fileName, out (AssemblyLoadContext AssemblyLoadContext, Assembly Assembly) value)) - { - assembly = value.Assembly; - } - else - { - //新建插件域,并注明可卸载 - var assemblyLoadContext = new AssemblyLoadContext(fileName, true); - //获取插件程序集 - assembly = GetAssembly(path, paths, assemblyLoadContext); - if (assembly == null) + Assembly assembly = null; + //全部程序集路径 + List paths = new(); + Directory.GetFiles(Path.GetDirectoryName(path), "*.dll").ToList().ForEach(a => paths.Add(a)); + + if (_assemblyLoadContextDict.TryGetValue(fileName, out (AssemblyLoadContext AssemblyLoadContext, Assembly Assembly) value)) { - assemblyLoadContext.Unload(); - return null; + assembly = value.Assembly; } - //添加到全局对象 - _assemblyLoadContextDict.TryAdd(fileName, (assemblyLoadContext, assembly)); + else + { + //新建插件域,并注明可卸载 + var assemblyLoadContext = new AssemblyLoadContext(fileName, true); + //获取插件程序集 + assembly = GetAssembly(path, paths, assemblyLoadContext); + if (assembly == null) + { + assemblyLoadContext.Unload(); + return null; + } + //添加到全局对象 + _assemblyLoadContextDict.TryAdd(fileName, (assemblyLoadContext, assembly)); + } + _logger?.LogInformation(Localizer["AddPluginFile", path]); + return assembly; + + } + catch + { + return null; } - return assembly; } /// @@ -703,27 +717,21 @@ internal sealed class PluginService : IPluginService /// 获取全部插件信息 /// /// - private List GetList() + private IEnumerable PrivateGetList() { try { - // 等待锁可用,确保在多线程环境下不会出现并发访问问题 _locker.Wait(); // 从缓存中获取插件列表数据 - var data = App.CacheService.Get>(_cacheKeyGetPluginOutputs); + var data = App.CacheService.Get>(CacheKeyGetPluginOutputs); // 如果缓存中没有数据,则调用 GetPluginOutputs 方法获取数据,并将其存入缓存 if (data == null) { - var pluginOutputs = GetPluginOutputs(); - App.CacheService.Set(_cacheKeyGetPluginOutputs, pluginOutputs); - return pluginOutputs; - } - var devices = App.GetService().GetAll(); - foreach (var pluginOutput in data) - { - pluginOutput.DeviceCount = devices.Count(a => a.PluginName == pluginOutput.FullName);//关联设备数量 + var pluginInfos = GetPluginOutputs(); + App.CacheService.Set(CacheKeyGetPluginOutputs, pluginInfos); + return pluginInfos; } // 如果缓存中有数据,则直接返回 @@ -736,9 +744,9 @@ internal sealed class PluginService : IPluginService } // 获取插件列表数据的私有方法 - List GetPluginOutputs() + IEnumerable GetPluginOutputs() { - List plugins = new List(); + var plugins = new List(); // 主程序上下文 // 遍历程序集上下文默认驱动字典,生成默认驱动插件信息 @@ -749,7 +757,7 @@ internal sealed class PluginService : IPluginService FileInfo fileInfo = new FileInfo(item.Value.Assembly.Location); //文件信息 DateTime lastWriteTime = fileInfo.LastWriteTime;//作为编译时间 - var pluginOutput = new PluginOutput() + var pluginInfo = new PluginInfo() { Name = item.Value.Name,//插件名称 FileName = Path.GetFileNameWithoutExtension(fileInfo.Name),//插件文件名称(分类) @@ -757,11 +765,12 @@ internal sealed class PluginService : IPluginService EducationPlugin = PluginServiceUtil.IsEducation(item.Value), Version = item.Value.Assembly.GetName().Version.ToString(), //插件版本 - LastWriteTime = lastWriteTime, //编译时间 }; - pluginOutput.DeviceCount = App.GetService().GetAll().Count(a => a.PluginName == pluginOutput.FullName);//关联设备数量 - plugins.Add(pluginOutput); + if (!item.Value.Assembly.Location.IsNullOrEmpty()) + pluginInfo.Directory = Path.GetDirectoryName(item.Value.Assembly.Location); + + plugins.Add(pluginInfo); } } @@ -781,7 +790,9 @@ internal sealed class PluginService : IPluginService // 加载插件程序集并获取其中的驱动类型信息 var assembly = GetAssembly(folderPath.CombinePathWithOs($"{driverMainName}.dll"), driverMainName); - var driverTypes = assembly.GetTypes().Where(x => (typeof(CollectBase).IsAssignableFrom(x) || typeof(BusinessBase).IsAssignableFrom(x)) && x.IsClass && !x.IsAbstract); + + if (assembly == null) continue; + var driverTypes = assembly?.GetTypes().Where(x => (typeof(CollectBase).IsAssignableFrom(x) || typeof(BusinessBase).IsAssignableFrom(x)) && x.IsClass && !x.IsAbstract); // 遍历驱动类型,生成插件信息,并将其添加到插件列表中 foreach (var type in driverTypes) @@ -795,17 +806,18 @@ internal sealed class PluginService : IPluginService _driverBaseDict.TryAdd($"{driverMainName}.{type.Name}", type); _logger?.LogInformation(Localizer[$"LoadTypeSuccess", PluginServiceUtil.GetFullName(driverMainName, type.Name)]); } - plugins.Add( - new PluginOutput() - { - Name = type.Name, //类型名称 - FileName = $"{driverMainName}", //主程序集名称 - PluginType = (typeof(CollectBase).IsAssignableFrom(type)) ? PluginTypeEnum.Collect : PluginTypeEnum.Business,//插件类型 - Version = assembly.GetName().Version.ToString(),//插件版本 - LastWriteTime = lastWriteTime, //编译时间 - EducationPlugin = PluginServiceUtil.IsEducation(type), - } - ); + var plugin = new PluginInfo() + { + Name = type.Name, //类型名称 + FileName = $"{driverMainName}", //主程序集名称 + PluginType = (typeof(CollectBase).IsAssignableFrom(type)) ? PluginTypeEnum.Collect : PluginTypeEnum.Business,//插件类型 + Version = assembly.GetName().Version.ToString(),//插件版本 + LastWriteTime = lastWriteTime, //编译时间 + EducationPlugin = PluginServiceUtil.IsEducation(type), + }; + plugin.Directory = folderPath; + + plugins.Add(plugin); } } } @@ -815,7 +827,7 @@ internal sealed class PluginService : IPluginService _logger?.LogWarning(ex, Localizer[$"LoadPluginFail", Path.GetRelativePath(AppContext.BaseDirectory.CombinePathWithOs(DirName), folderPath)]); } } - return plugins.OrderBy(a => a.EducationPlugin).ThenByDescending(a => a.DeviceCount).ToList(); + return plugins.DistinctBy(a => a.FullName).OrderBy(a => a.EducationPlugin); } } } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/PluginServiceUtil.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/PluginServiceUtil.cs index 456be61ba..2381258bf 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/PluginServiceUtil.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Plugin/PluginServiceUtil.cs @@ -17,98 +17,23 @@ using System.Reflection; using System.Runtime.InteropServices; using ThingsGateway.NewLife.Extension; +using ThingsGateway.Razor; namespace ThingsGateway.Gateway.Application; [ThingsGateway.DependencyInjection.SuppressSniffer] public static class PluginServiceUtil { - /// - /// 属性赋值方法 - /// - /// - /// - public static void CopyValue(this IEditorItem dest, IEditorItem source) - { - if (source.ComponentType != null) dest.ComponentType = source.ComponentType; - if (source.ComponentParameters != null) dest.ComponentParameters = source.ComponentParameters; - if (source.Ignore.HasValue) dest.Ignore = source.Ignore; - if (source.EditTemplate != null) dest.EditTemplate = source.EditTemplate; - if (source.Items != null) dest.Items = source.Items; - if (source.Lookup != null) dest.Lookup = source.Lookup; - if (source.ShowSearchWhenSelect) dest.ShowSearchWhenSelect = source.ShowSearchWhenSelect; - if (source.IsPopover) dest.IsPopover = source.IsPopover; - if (source.LookupStringComparison != StringComparison.OrdinalIgnoreCase) dest.LookupStringComparison = source.LookupStringComparison; - if (source.LookupServiceKey != null) dest.LookupServiceKey = source.LookupServiceKey; - if (source.LookupServiceData != null) dest.LookupServiceData = source.LookupServiceData; - if (source.Readonly.HasValue) dest.Readonly = source.Readonly; - if (source.Rows > 0) dest.Rows = source.Rows; - if (source.SkipValidate) dest.SkipValidate = source.SkipValidate; - if (!string.IsNullOrEmpty(source.Text)) dest.Text = source.Text; - if (source.ValidateRules != null) dest.ValidateRules = source.ValidateRules; - if (source.ShowLabelTooltip != null) dest.ShowLabelTooltip = source.ShowLabelTooltip; - if (!string.IsNullOrEmpty(source.GroupName)) dest.GroupName = source.GroupName; - if (source.GroupOrder != 0) dest.GroupOrder = source.GroupOrder; - if (!string.IsNullOrEmpty(source.PlaceHolder)) dest.PlaceHolder = source.PlaceHolder; - if (!string.IsNullOrEmpty(source.Step)) dest.Step = source.Step; - if (source.Order != 0) dest.Order = source.Order; - if (source.Required.HasValue) dest.Required = source.Required; - if (!string.IsNullOrEmpty(source.RequiredErrorMessage)) dest.RequiredErrorMessage = source.RequiredErrorMessage; - - if (source is ITableColumn source1 && dest is ITableColumn dest1) - { - CopyValue(dest1, source1); - } - } - private static void CopyValue(this ITableColumn dest, ITableColumn source) - { - if (source.Align.HasValue) dest.Align = source.Align; - if (source.TextWrap.HasValue) dest.TextWrap = source.TextWrap; - if (!string.IsNullOrEmpty(source.CssClass)) dest.CssClass = source.CssClass; - if (source.DefaultSort) dest.DefaultSort = source.DefaultSort; - if (source.DefaultSortOrder != SortOrder.Unset) dest.DefaultSortOrder = source.DefaultSortOrder; - if (source.Filter != null) dest.Filter = source.Filter; - if (source.Filterable.HasValue) dest.Filterable = source.Filterable; - if (source.FilterTemplate != null) dest.FilterTemplate = source.FilterTemplate; - if (source.Fixed) dest.Fixed = source.Fixed; - if (source.FormatString != null) dest.FormatString = source.FormatString; - if (source.Formatter != null) dest.Formatter = source.Formatter; - if (source.HeaderTemplate != null) dest.HeaderTemplate = source.HeaderTemplate; - if (source.OnCellRender != null) dest.OnCellRender = source.OnCellRender; - if (source.Searchable.HasValue) dest.Searchable = source.Searchable; - if (source.SearchTemplate != null) dest.SearchTemplate = source.SearchTemplate; - if (source.ShownWithBreakPoint != BreakPoint.None) dest.ShownWithBreakPoint = source.ShownWithBreakPoint; - if (source.ShowTips.HasValue) dest.ShowTips = source.ShowTips = true; - if (source.Sortable.HasValue) dest.Sortable = source.Sortable; - if (source.Template != null) dest.Template = source.Template; - if (source.TextEllipsis.HasValue) dest.TextEllipsis = source.TextEllipsis; - if (!source.Visible.HasValue) dest.Visible = source.Visible; - if (source.Width != null) dest.Width = source.Width; - if (source.ShowCopyColumn.HasValue) dest.ShowCopyColumn = source.ShowCopyColumn; - if (source.HeaderTextWrap) dest.HeaderTextWrap = source.HeaderTextWrap; - if (!string.IsNullOrEmpty(source.HeaderTextTooltip)) dest.HeaderTextTooltip = source.HeaderTextTooltip; - if (source.ShowHeaderTooltip) dest.ShowHeaderTooltip = source.ShowHeaderTooltip; - if (source.HeaderTextEllipsis) dest.HeaderTextEllipsis = source.HeaderTextEllipsis; - if (source.IsMarkupString) dest.IsMarkupString = source.IsMarkupString; - if (source.Visible.HasValue) dest.Visible = source.Visible; - if (source.IsVisibleWhenAdd.HasValue) dest.IsVisibleWhenAdd = source.IsVisibleWhenAdd; - if (source.IsVisibleWhenEdit.HasValue) dest.IsVisibleWhenEdit = source.IsVisibleWhenEdit; - if (source.IsReadonlyWhenAdd.HasValue) dest.IsReadonlyWhenAdd = source.IsReadonlyWhenAdd; - if (source.IsReadonlyWhenEdit.HasValue) dest.IsReadonlyWhenEdit = source.IsReadonlyWhenEdit; - if (source.GetTooltipTextCallback != null) dest.GetTooltipTextCallback = source.GetTooltipTextCallback; - if (source.CustomSearch != null) dest.CustomSearch = source.CustomSearch; - if (source.ToolboxTemplate != null) dest.ToolboxTemplate = source.ToolboxTemplate; - if (source.IsRequiredWhenAdd.HasValue) dest.IsRequiredWhenAdd = source.IsRequiredWhenAdd; - if (source.IsRequiredWhenEdit.HasValue) dest.IsRequiredWhenEdit = source.IsRequiredWhenEdit; - } - /// /// 通过特定类型模型获取模型属性集合 /// /// 绑定模型类型 /// - public static IEnumerable GetEditorItems(Type type) + public static List GetEditorItems(Type type) { + if (type == null) + return new(); + var cols = new List(50); //获取属性 var props = type.GetProperties().Where(p => !p.IsStatic()); @@ -122,7 +47,7 @@ public static class PluginServiceUtil var displayName = classAttribute.Description ?? BootstrapBlazor.Components.Utility.GetDisplayName(type, prop.Name); InternalTableColumn tc = new InternalTableColumn(prop.Name, prop.PropertyType, displayName); if (autoGenerateColumnAttribute != null) - CopyValue(tc, autoGenerateColumnAttribute); + IEditItemExtensions.CopyValue(tc, autoGenerateColumnAttribute); tc.ComponentParameters ??= new Dictionary(); if (classAttribute.Remark != null) { @@ -134,7 +59,10 @@ public static class PluginServiceUtil new("title", classAttribute.Remark) ); } - + if (!classAttribute.GroupName.IsNullOrEmpty()) + { + tc.GroupName = classAttribute.GroupName; + } cols.Add(tc); } return cols; diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Rpc/RpcService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Rpc/RpcService.cs index 409f96d9b..946ff4308 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Rpc/RpcService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Rpc/RpcService.cs @@ -15,11 +15,9 @@ using Newtonsoft.Json.Linq; using System.Collections.Concurrent; -using ThingsGateway.Core.Json.Extension; using ThingsGateway.Extension; using ThingsGateway.Extension.Generic; - -using TouchSocket.Core; +using ThingsGateway.NewLife.Json.Extension; namespace ThingsGateway.Gateway.Application; @@ -29,12 +27,14 @@ namespace ThingsGateway.Gateway.Application; internal sealed class RpcService : IRpcService { private readonly ConcurrentQueue _logQueues = new(); - + private readonly RpcLogOptions? _rpcLogOptions; /// public RpcService(IStringLocalizer localizer) { Localizer = localizer; - Task.Factory.StartNew(RpcLogInsertAsync, TaskCreationOptions.LongRunning); + Task.Factory.StartNew(async () => await RpcLogInsertAsync().ConfigureAwait(false), TaskCreationOptions.LongRunning); + _rpcLogOptions = App.GetOptions(); + } private IStringLocalizer Localizer { get; } @@ -43,8 +43,8 @@ internal sealed class RpcService : IRpcService public async Task> InvokeDeviceMethodAsync(string sourceDes, Dictionary items, CancellationToken cancellationToken = default) { // 初始化用于存储将要写入的变量和方法的字典 - Dictionary> WriteVariables = new(); - Dictionary> WriteMethods = new(); + Dictionary> writeVariables = new(); + Dictionary> writeMethods = new(); // 用于存储结果的并发字典 ConcurrentDictionary results = new(); var dict = GlobalData.Variables; @@ -53,13 +53,12 @@ internal sealed class RpcService : IRpcService foreach (var item in items) { // 查找变量是否存在 - if (!dict.ContainsKey(item.Key)) + if (!dict.TryGetValue(item.Key, out var tag)) { // 如果变量不存在,则添加错误信息到结果中并继续下一个变量的处理 results.TryAdd(item.Key, new OperResult(Localizer["VariableNotNull", item.Key])); continue; } - var tag = dict[item.Key]; // 检查变量的保护类型和远程写入权限 if (tag.ProtectType == ProtectTypeEnum.ReadOnly) @@ -74,56 +73,37 @@ internal sealed class RpcService : IRpcService } // 查找变量对应的设备 - var dev = (CollectBase)GlobalData.CollectDeviceHostedService.DriverBases.FirstOrDefault(it => it.DeviceId == tag.DeviceId); - if (dev == null) + var collect = tag.DeviceRuntime.Driver as CollectBase; + if (collect == null) { // 如果设备不存在,则添加错误信息到结果中并继续下一个变量的处理 results.TryAdd(item.Key, new OperResult(Localizer["DriverNotNull"])); continue; } // 检查设备状态,如果设备处于暂停状态,则添加相应的错误信息到结果中并继续下一个变量的处理 - if (dev.CurrentDevice.DeviceStatus == DeviceStatusEnum.Pause) + if (collect.CurrentDevice.DeviceStatus == DeviceStatusEnum.Pause) { - results.TryAdd(item.Key, new OperResult(Localizer["DevicePause", dev.CurrentDevice.Name])); + results.TryAdd(item.Key, new OperResult(Localizer["DevicePause", collect.CurrentDevice.Name])); continue; } - // 将变量添加到写入变量字典或执行方法字典中 - if (!results.ContainsKey(item.Key)) + JToken tagValue = JTokenUtil.GetJTokenFromString(item.Value); + bool isOtherMethodEmpty = string.IsNullOrEmpty(tag.OtherMethod); + var collection = isOtherMethodEmpty ? writeVariables : writeMethods; + if (collection.TryGetValue(collect, out var value)) { - if (string.IsNullOrEmpty(tag.OtherMethod)) - { - // 写入变量值 - JToken tagValue = JTokenUtil.GetJTokenFromString(item.Value); - if (WriteVariables.TryGetValue(dev, out var value)) - { - value.Add(tag, tagValue); - } - else - { - WriteVariables.Add(dev, new()); - WriteVariables[dev].Add(tag, tagValue); - } - } - else - { - JToken tagValue = JTokenUtil.GetJTokenFromString(item.Value); - // 执行方法 - if (WriteMethods.TryGetValue(dev, out var value)) - { - value.Add(tag, tagValue); - } - else - { - WriteMethods.Add(dev, new()); - WriteMethods[dev].Add(tag, tagValue); - } - } + value.Add(tag, tagValue); } + else + { + collection.Add(collect, new()); + collection[collect].Add(tag, tagValue); + } + } // 使用并行方式写入变量 - await WriteVariables.ParallelForEachAsync(async (item, cancellationToken) => + await writeVariables.ParallelForEachAsync(async (item, cancellationToken) => { try { @@ -133,31 +113,25 @@ internal sealed class RpcService : IRpcService // 写入日志 foreach (var resultItem in result) { - string operObj; - string parJson; - if (resultItem.Key.IsNullOrEmpty()) - { - operObj = items.Select(x => x.Key).ToJsonNetString(); - parJson = items.Select(x => x.Value).ToJsonNetString(); - } - else - { - operObj = resultItem.Key; - parJson = items[resultItem.Key]; - } - _logQueues.Enqueue( - new RpcLog() - { - LogTime = DateTime.Now, - OperateMessage = resultItem.Value.IsSuccess ? null : resultItem.Value.ToString(), - IsSuccess = resultItem.Value.IsSuccess, - OperateMethod = Localizer["WriteVariable"], - OperateObject = operObj, - OperateSource = sourceDes, - ParamJson = parJson, - ResultJson = null - } - ); + var empty = string.IsNullOrEmpty(resultItem.Key); + string operObj = empty ? items.Select(x => x.Key).ToJsonNetString() : resultItem.Key; + + string parJson = empty ? items.Select(x => x.Value).ToJsonNetString() : items[resultItem.Key]; + + if (_rpcLogOptions.SuccessLog) + _logQueues.Enqueue( + new RpcLog() + { + LogTime = DateTime.Now, + OperateMessage = resultItem.Value.IsSuccess ? null : resultItem.Value.ToString(), + IsSuccess = resultItem.Value.IsSuccess, + OperateMethod = Localizer["WriteVariable"], + OperateObject = operObj, + OperateSource = sourceDes, + ParamJson = parJson, + ResultJson = null + } + ); // 不返回详细错误 if (!resultItem.Value.IsSuccess) @@ -174,15 +148,15 @@ internal sealed class RpcService : IRpcService catch (Exception ex) { // 将异常信息添加到结果字典中 - results.AddRange(item.Value.Select((KeyValuePair a) => + results.AddRange(item.Value.Select((KeyValuePair a) => { return new KeyValuePair(a.Key.Name, new OperResult(ex)); })); } - }, Environment.ProcessorCount / 2, cancellationToken).ConfigureAwait(false); + }, Environment.ProcessorCount, cancellationToken).ConfigureAwait(false); // 使用并行方式执行方法 - await WriteMethods.ParallelForEachAsync(async (item, cancellationToken) => + await writeMethods.ParallelForEachAsync(async (item, cancellationToken) => { try { @@ -195,19 +169,20 @@ internal sealed class RpcService : IRpcService foreach (var resultItem in result) { // 写入日志 - _logQueues.Enqueue( - new RpcLog() - { - LogTime = DateTime.Now, - OperateMessage = resultItem.Value.IsSuccess ? null : resultItem.Value.ToString(), - IsSuccess = resultItem.Value.IsSuccess, - OperateMethod = operateMethods[resultItem.Key], - OperateObject = resultItem.Key, - OperateSource = sourceDes, - ParamJson = items[resultItem.Key]?.ToString(), - ResultJson = resultItem.Value.Content?.ToString() - } - ); + if (_rpcLogOptions.SuccessLog) + _logQueues.Enqueue( + new RpcLog() + { + LogTime = DateTime.Now, + OperateMessage = resultItem.Value.IsSuccess ? null : resultItem.Value.ToString(), + IsSuccess = resultItem.Value.IsSuccess, + OperateMethod = operateMethods[resultItem.Key], + OperateObject = resultItem.Key, + OperateSource = sourceDes, + ParamJson = items[resultItem.Key]?.ToString(), + ResultJson = resultItem.Value.Content?.ToString() + } + ); // 不返回详细错误 if (!resultItem.Value.IsSuccess) @@ -221,12 +196,13 @@ internal sealed class RpcService : IRpcService catch (Exception ex) { // 将异常信息添加到结果字典中 - results.AddRange(item.Value.Select((KeyValuePair a) => + results.AddRange(item.Value.Select((KeyValuePair a) => { return new KeyValuePair(a.Key.Name, new OperResult(ex)); })); } - }, Environment.ProcessorCount / 2, cancellationToken).ConfigureAwait(false); + }, Environment.ProcessorCount, cancellationToken).ConfigureAwait(false); + // 返回结果字典 return new(results); } @@ -238,8 +214,7 @@ internal sealed class RpcService : IRpcService { var db = DbContext.Db.GetConnectionScopeWithAttr().CopyNew(); // 创建一个新的数据库上下文实例 var appLifetime = App.RootServices!.GetService()!; - // 在应用程序未停止的情况下循环执行日志插入操作 - while (!((appLifetime?.ApplicationStopping ?? default).IsCancellationRequested || (appLifetime?.ApplicationStopped ?? default).IsCancellationRequested)) + while (!appLifetime.ApplicationStopping.IsCancellationRequested) { try { @@ -252,7 +227,7 @@ internal sealed class RpcService : IRpcService } catch (Exception ex) { - Console.WriteLine(ex); + NewLife.Log.XTrace.WriteException(ex); } finally { diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/Dto/VariableInput.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/Dto/VariableInput.cs deleted file mode 100644 index 47428aa0c..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/Dto/VariableInput.cs +++ /dev/null @@ -1,68 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using BootstrapBlazor.Components; - -using ThingsGateway.Extension.Generic; - -namespace ThingsGateway.Gateway.Application; - -/// -/// 变量分页查询参数 -/// -public class VariablePageInput : BasePageInput -{ - /// - public long? BusinessDeviceId { get; set; } - - /// - public long? DeviceId { get; set; } - - /// - public string Name { get; set; } - - /// - public string RegisterAddress { get; set; } -} - -public class VariableSearchInput : ITableSearchModel -{ - /// - public long? DeviceId { get; set; } - - /// - public long? BusinessDeviceId { get; set; } - - /// - public string Name { get; set; } - - /// - public string RegisterAddress { get; set; } - - /// - public IEnumerable GetSearches() - { - var ret = new List(); - ret.AddIF(!string.IsNullOrEmpty(Name), () => new SearchFilterAction(nameof(Variable.Name), Name)); - ret.AddIF(!string.IsNullOrEmpty(RegisterAddress), () => new SearchFilterAction(nameof(Variable.RegisterAddress), RegisterAddress)); - ret.AddIF(DeviceId > 0, () => new SearchFilterAction(nameof(Variable.DeviceId), DeviceId, FilterAction.Equal)); - return ret; - } - - /// - public void Reset() - { - Name = null; - RegisterAddress = null; - DeviceId = null; - BusinessDeviceId = null; - } -} - diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/IVariableRuntimeService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/IVariableRuntimeService.cs new file mode 100644 index 000000000..4ba95c38d --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/IVariableRuntimeService.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Microsoft.AspNetCore.Components.Forms; + +namespace ThingsGateway.Gateway.Application +{ + public interface IVariableRuntimeService + { + Task BatchEditAsync(IEnumerable models, Variable oldModel, Variable model); + Task DeleteVariableAsync(IEnumerable ids); + Task> ExportVariableAsync(ExportFilter exportFilter); + + Task ImportVariableAsync(Dictionary input); + Task InsertTestDataAsync(int testVariableCount, int testDeviceCount, string slaveUrl, bool restart = true); + + + Task AddBatchAsync(List input); + + Task> PreviewAsync(IBrowserFile browserFile); + + Task SaveVariableAsync(Variable input, ItemChangedType type); + void PreheatCache(); + + Task ExportMemoryStream(List data, string devName); + } +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/IVariableService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/IVariableService.cs index d5ce28835..c1d32a7b4 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/IVariableService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/IVariableService.cs @@ -19,7 +19,7 @@ namespace ThingsGateway.Gateway.Application; /// /// 定义了变量相关的服务接口 /// -public interface IVariableService +internal interface IVariableService { /// /// 异步插入变量信息。 @@ -36,11 +36,6 @@ public interface IVariableService /// Task BatchEditAsync(IEnumerable models, Variable oldModel, Variable model); - /// - /// 异步清除变量数据。 - /// - Task ClearVariableAsync(SqlSugarClient db = null); - /// /// 根据设备ID异步删除变量数据。 /// @@ -64,39 +59,31 @@ public interface IVariableService /// /// 异步导出变量数据到文件流中。 /// - Task> ExportVariableAsync(QueryPageOptions options, FilterKeyValueAction filterKeyValueAction = null); + Task> ExportVariableAsync(ExportFilter exportFilter); /// - /// 异步获取变量的运行时信息。 + /// 异步获取变量。 /// /// 设备ID(可选)。 - Task> GetVariableRuntimeAsync(long? devId = null); + Task> GetAllAsync(long? devId = null); /// /// 异步导入变量数据。 /// /// 要导入的数据。 - Task ImportVariableAsync(Dictionary input); + Task> ImportVariableAsync(Dictionary input); /// /// 创建n个modbus变量 /// - Task InsertTestDataAsync(int variableCount, int deviceCount, string slaveUrl = "127.0.0.1:502"); + Task<(List, List, List)> InsertTestDataAsync(int variableCount, int deviceCount, string slaveUrl = "127.0.0.1:502"); /// /// 表格查询 /// - /// 查询分页选项 - /// 业务设备id - Task> PageAsync(QueryPageOptions option, long? businessDeviceId); + /// 查询分页选项 + Task> PageAsync(ExportFilter exportFilter); - - /// - /// API查询 - /// - /// - /// - Task> PageAsync(VariablePageInput input); Task PreheatCache(); /// @@ -111,4 +98,11 @@ public interface IVariableService /// 要保存的设备信息。 /// 变量变化类型。 Task SaveVariableAsync(Variable input, ItemChangedType type); + + + + /// + /// 保存初始值 + /// + Task UpdateInitValueAsync(List variables); } diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/VariableRuntimeService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/VariableRuntimeService.cs new file mode 100644 index 000000000..f5348c02f --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/VariableRuntimeService.cs @@ -0,0 +1,482 @@ +// ------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +// ------------------------------------------------------------------------------ + +using BootstrapBlazor.Components; + +using Mapster; + +using Microsoft.AspNetCore.Components.Forms; + +using ThingsGateway.Extension.Generic; + +namespace ThingsGateway.Gateway.Application; + +public class VariableRuntimeService : IVariableRuntimeService +{ + private WaitLock WaitLock { get; set; } = new WaitLock(); + + public async Task AddBatchAsync(List input) + { + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + await GlobalData.VariableService.AddBatchAsync(input).ConfigureAwait(false); + + var newVariableRuntimes = input.Adapt>(); + + //获取变量,先找到原插件线程,然后修改插件线程内的字典,再改动全局字典,最后刷新插件 + var data = GlobalData.IdVariables.Where(a => newVariableRuntimes.Select(a => a.Id).ToHashSet().Contains(a.Key)).GroupBy(a => a.Value.DeviceRuntime); + + HashSet changedDriver = new(); + foreach (var group in data) + { + //这里改动的可能是旧绑定设备 + //需要改动DeviceRuntim的变量字典 + foreach (var item in group) + { + //需要重启业务线程 + var deviceRuntimes = GlobalData.Devices.Where(a => item.Value.VariablePropertys.ContainsKey(a.Key)).Select(a => a.Value); + foreach (var deviceRuntime in deviceRuntimes) + { + if (deviceRuntime.Driver != null) + { + changedDriver.Add(deviceRuntime.Driver); + } + } + + item.Value.Dispose(); + } + if (group.Key != null) + { + if (group.Key.Driver != null) + { + changedDriver.Add(group.Key.Driver); + } + } + } + + //批量修改之后,需要重新加载 + foreach (var newVariableRuntime in newVariableRuntimes) + { + if (GlobalData.Devices.TryGetValue(newVariableRuntime.DeviceId, out var deviceRuntime)) + { + newVariableRuntime.Init(deviceRuntime); + + if (deviceRuntime.Driver != null && !changedDriver.Contains(deviceRuntime.Driver)) + { + changedDriver.Add(deviceRuntime.Driver); + } + } + } + + //根据条件重启通道线程 + foreach (var driver in changedDriver) + { + driver.AfterVariablesChanged(); + } + + } + finally + { + WaitLock.Release(); + } + } + + public async Task BatchEditAsync(IEnumerable models, Variable oldModel, Variable model) + { + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + models = models.Adapt>(); + oldModel = oldModel.Adapt(); + model = model.Adapt(); + + var result = await GlobalData.VariableService.BatchEditAsync(models, oldModel, model).ConfigureAwait(false); + + using var db = DbContext.GetDB(); + var newVariableRuntimes = (await db.Queryable().Where(a => models.Select(a => a.Id).ToHashSet().Contains(a.Id)).ToListAsync().ConfigureAwait(false)).Adapt>(); + + //获取变量,先找到原插件线程,然后修改插件线程内的字典,再改动全局字典,最后刷新插件 + var data = GlobalData.IdVariables.Where(a => newVariableRuntimes.Select(a => a.Id).ToHashSet().Contains(a.Key)).GroupBy(a => a.Value.DeviceRuntime); + + HashSet changedDriver = new(); + foreach (var group in data) + { + //这里改动的可能是旧绑定设备 + //需要改动DeviceRuntim的变量字典 + foreach (var item in group) + { + //需要重启业务线程 + var deviceRuntimes = GlobalData.Devices.Where(a => item.Value.VariablePropertys.ContainsKey(a.Key)).Select(a => a.Value); + foreach (var deviceRuntime in deviceRuntimes) + { + if (deviceRuntime.Driver != null) + { + changedDriver.Add(deviceRuntime.Driver); + } + } + + item.Value.Dispose(); + } + if (group.Key != null) + { + if (group.Key.Driver != null) + { + changedDriver.Add(group.Key.Driver); + } + } + } + + //批量修改之后,需要重新加载 + foreach (var newVariableRuntime in newVariableRuntimes) + { + if (GlobalData.Devices.TryGetValue(newVariableRuntime.DeviceId, out var deviceRuntime)) + { + newVariableRuntime.Init(deviceRuntime); + + if (deviceRuntime.Driver != null && !changedDriver.Contains(deviceRuntime.Driver)) + { + changedDriver.Add(deviceRuntime.Driver); + } + } + } + + //根据条件重启通道线程 + foreach (var driver in changedDriver) + { + driver.AfterVariablesChanged(); + } + + return true; + + } + finally + { + WaitLock.Release(); + } + } + + public async Task DeleteVariableAsync(IEnumerable ids) + { + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + + ids = ids.ToHashSet(); + + var result = await GlobalData.VariableService.DeleteVariableAsync(ids).ConfigureAwait(false); + + var variableRuntimes = GlobalData.IdVariables.Where(a => ids.Contains(a.Key)).Select(a => a.Value).ToList(); + + foreach (var variableRuntime in variableRuntimes) + { + variableRuntime.Dispose(); + } + var data = variableRuntimes.Where(a => a.DeviceRuntime?.Driver != null).GroupBy(a => a.DeviceRuntime); + + HashSet changedDriver = new(); + foreach (var group in data) + { + //这里改动的可能是旧绑定设备 + //需要改动DeviceRuntim的变量字典 + foreach (var item in group) + { + //需要重启业务线程 + var deviceRuntimes = GlobalData.Devices.Where(a => item.VariablePropertys.ContainsKey(a.Key)).Select(a => a.Value); + foreach (var deviceRuntime in deviceRuntimes) + { + if (deviceRuntime.Driver != null) + { + changedDriver.Add(deviceRuntime.Driver); + } + } + + item.Dispose(); + } + if (group.Key != null) + { + if (group.Key.Driver != null) + { + changedDriver.Add(group.Key.Driver); + } + } + } + + foreach (var driver in changedDriver) + { + driver.AfterVariablesChanged(); + } + + + + return true; + } + finally + { + WaitLock.Release(); + } + + + } + public Task> ExportVariableAsync(ExportFilter exportFilter) => GlobalData.VariableService.ExportVariableAsync(exportFilter); + + public async Task ImportVariableAsync(Dictionary input) + { + + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + var result = await GlobalData.VariableService.ImportVariableAsync(input).ConfigureAwait(false); + + + using var db = DbContext.GetDB(); + var newVariableRuntimes = (await db.Queryable().Where(a => result.Contains(a.Id)).ToListAsync().ConfigureAwait(false)).Adapt>(); + + //先找出线程管理器,停止 + var data = GlobalData.IdVariables.Where(a => newVariableRuntimes.Select(a => a.Id).ToHashSet().Contains(a.Key)).GroupBy(a => a.Value.DeviceRuntime); + + HashSet changedDriver = new(); + foreach (var group in data) + { + //这里改动的可能是旧绑定设备 + //需要改动DeviceRuntim的变量字典 + foreach (var item in group) + { + //需要重启业务线程 + var deviceRuntimes = GlobalData.Devices.Where(a => item.Value.VariablePropertys.ContainsKey(a.Key)).Select(a => a.Value); + foreach (var deviceRuntime in deviceRuntimes) + { + if (deviceRuntime.Driver != null) + { + changedDriver.Add(deviceRuntime.Driver); + } + } + + item.Value.Dispose(); + } + if (group.Key != null) + { + if (group.Key.Driver != null) + { + changedDriver.Add(group.Key.Driver); + } + } + } + + //批量修改之后,需要重新加载 + foreach (var newVariableRuntime in newVariableRuntimes) + { + if (GlobalData.Devices.TryGetValue(newVariableRuntime.DeviceId, out var deviceRuntime)) + { + newVariableRuntime.Init(deviceRuntime); + //添加新变量所在任务 + if (deviceRuntime.Driver != null && !changedDriver.Contains(deviceRuntime.Driver)) + { + changedDriver.Add(deviceRuntime.Driver); + } + } + } + + //根据条件重启通道线程 + + foreach (var driver in changedDriver) + { + driver.AfterVariablesChanged(); + } + + + + } + finally + { + WaitLock.Release(); + } + + } + + public async Task InsertTestDataAsync(int testVariableCount, int testDeviceCount, string slaveUrl, bool restart = true) + { + try + { + await WaitLock.WaitAsync().ConfigureAwait(false); + + + + var datas = await GlobalData.VariableService.InsertTestDataAsync(testVariableCount, testDeviceCount, slaveUrl).ConfigureAwait(false); + + { + var newChannelRuntimes = (datas.Item1).Adapt>(); + + //批量修改之后,需要重新加载通道 + foreach (var newChannelRuntime in newChannelRuntimes) + { + if (GlobalData.Channels.TryGetValue(newChannelRuntime.Id, out var channelRuntime)) + { + channelRuntime.Dispose(); + newChannelRuntime.Init(); + newChannelRuntime.DeviceRuntimes.AddRange(channelRuntime.DeviceRuntimes); + } + else + { + newChannelRuntime.Init(); + + } + } + + { + + var newDeviceRuntimes = (datas.Item2).Adapt>(); + + //批量修改之后,需要重新加载通道 + foreach (var newDeviceRuntime in newDeviceRuntimes) + { + if (GlobalData.Devices.TryGetValue(newDeviceRuntime.Id, out var deviceRuntime)) + { + deviceRuntime.Dispose(); + } + if (GlobalData.Channels.TryGetValue(newDeviceRuntime.ChannelId, out var channelRuntime)) + { + newDeviceRuntime.Init(channelRuntime); + } + if (deviceRuntime != null) + { + newDeviceRuntime.VariableRuntimes.AddRange(deviceRuntime.VariableRuntimes); + } + } + + + } + { + var newVariableRuntimes = (datas.Item3).Adapt>(); + //获取变量,先找到原插件线程,然后修改插件线程内的字典,再改动全局字典,最后刷新插件 + var data = GlobalData.IdVariables.Where(a => newVariableRuntimes.Select(a => a.Id).ToHashSet().Contains(a.Key)).GroupBy(a => a.Value.DeviceRuntime); + + foreach (var group in data) + { + //这里改动的可能是旧绑定设备 + //需要改动DeviceRuntim的变量字典 + foreach (var item in group) + { + item.Value.Dispose(); + } + } + + //批量修改之后,需要重新加载 + foreach (var newVariableRuntime in newVariableRuntimes) + { + if (GlobalData.Devices.TryGetValue(newVariableRuntime.DeviceId, out var deviceRuntime)) + { + newVariableRuntime.Init(deviceRuntime); + } + } + + } + //根据条件重启通道线程 + + if (restart) + await GlobalData.ChannelThreadManage.RestartChannelAsync(newChannelRuntimes).ConfigureAwait(false); + + + App.GetService>().Dispatch(null); + } + } + finally + { + WaitLock.Release(); + } + + } + + public Task> PreviewAsync(IBrowserFile browserFile) + { + return GlobalData.VariableService.PreviewAsync(browserFile); + } + + public async Task SaveVariableAsync(Variable input, ItemChangedType type) + { + try + { + input = input.Adapt(); + await WaitLock.WaitAsync().ConfigureAwait(false); + + + + var result = await GlobalData.VariableService.SaveVariableAsync(input, type).ConfigureAwait(false); + + + using var db = DbContext.GetDB(); + var newVariableRuntime = (await db.Queryable().Where(a => input.Id == a.Id).FirstAsync().ConfigureAwait(false)).Adapt(); + + if (newVariableRuntime == null) return false; + + HashSet changedDriver = new(); + + + + //这里改动的可能是旧绑定设备 + //需要改动DeviceRuntim的变量字典 + + if (GlobalData.IdVariables.TryGetValue(newVariableRuntime.Id, out var variableRuntime)) + { + if (variableRuntime.DeviceRuntime?.Driver != null) + { + changedDriver.Add(variableRuntime.DeviceRuntime.Driver); + } + variableRuntime.Dispose(); + } + + //需要重启业务线程 + var deviceRuntimes = GlobalData.Devices.Where(a => newVariableRuntime.VariablePropertys.ContainsKey(a.Key)).Select(a => a.Value); + foreach (var businessDeviceRuntime in deviceRuntimes) + { + if (businessDeviceRuntime.Driver != null) + { + changedDriver.Add(businessDeviceRuntime.Driver); + } + } + + //批量修改之后,需要重新加载 + + if (GlobalData.Devices.TryGetValue(newVariableRuntime.DeviceId, out var deviceRuntime)) + { + newVariableRuntime.Init(deviceRuntime); + + if (deviceRuntime.Driver != null && !changedDriver.Contains(deviceRuntime.Driver)) + { + changedDriver.Add(deviceRuntime.Driver); + } + + } + + //根据条件重启通道线程 + foreach (var driver in changedDriver) + { + driver.AfterVariablesChanged(); + } + + + return true; + } + finally + { + WaitLock.Release(); + } + } + + public void PreheatCache() => GlobalData.VariableService.PreheatCache(); + + + public Task ExportMemoryStream(List data, string deviceName) => GlobalData.VariableService.ExportMemoryStream(data, deviceName); + +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/VariableService.cs b/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/VariableService.cs index 175fb519c..f8beb1ce4 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/VariableService.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Services/Variable/VariableService.cs @@ -24,10 +24,8 @@ using System.Dynamic; using System.Reflection; using System.Text; -using ThingsGateway.ClayObject.Extensions; using ThingsGateway.Extension.Generic; using ThingsGateway.Foundation.Extension.Dynamic; -using ThingsGateway.FriendlyException; using TouchSocket.Core; @@ -40,18 +38,7 @@ internal sealed class VariableService : BaseService, IVariableService private readonly IPluginService _pluginService; private readonly IDispatchService _allDispatchService; private readonly IDispatchService _dispatchService; - private ISysUserService _sysUserService; - private ISysUserService SysUserService - { - get - { - if (_sysUserService == null) - { - _sysUserService = App.GetService(); - } - return _sysUserService; - } - } + /// public VariableService( IDispatchService dispatchService, @@ -67,7 +54,7 @@ internal sealed class VariableService : BaseService, IVariableService #region 测试 - public async Task InsertTestDataAsync(int variableCount, int deviceCount, string slaveUrl = "127.0.0.1:502") + public async Task<(List, List, List)> InsertTestDataAsync(int variableCount, int deviceCount, string slaveUrl = "127.0.0.1:502") { if (slaveUrl.IsNullOrWhiteSpace()) slaveUrl = "127.0.0.1:502"; if (deviceCount > variableCount) variableCount = deviceCount; @@ -75,49 +62,51 @@ internal sealed class VariableService : BaseService, IVariableService List newDevices = new(); List newVariables = new(); var addressNum = 1; + // 计算每个设备分配的默认变量数 var groupVariableCount = (int)Math.Ceiling((decimal)variableCount / deviceCount); + for (int i = 0; i < deviceCount; i++) { Channel channel = new Channel(); Device device = new Device(); { var id = CommonUtils.GetSingleId(); - var name = $"testChannel{id}"; + var name = $"modbusChannel{id}"; channel.ChannelType = ChannelTypeEnum.TcpClient; channel.Name = name; channel.Id = id; channel.CreateUserId = UserManager.UserId; channel.CreateOrgId = UserManager.OrgId; channel.RemoteUrl = slaveUrl; + channel.PluginName = "ThingsGateway.Plugin.Modbus.ModbusMaster"; //动态插件属性默认 newChannels.Add(channel); } { var id = CommonUtils.GetSingleId(); - var name = $"testDevice{id}"; + var name = $"modbusDevice{id}"; device.Name = name; device.Id = id; - device.PluginType = PluginTypeEnum.Collect; device.ChannelId = channel.Id; - device.IntervalTime = "1000"; device.CreateUserId = UserManager.UserId; device.CreateOrgId = UserManager.OrgId; - device.PluginName = "ThingsGateway.Plugin.Modbus.ModbusMaster"; + device.IntervalTime = "1000"; //动态插件属性默认 newDevices.Add(device); } - if (i != 0 && i == deviceCount - 1) + + // 计算当前设备应该分配的变量数量 + int currentGroupVariableCount = (i == deviceCount - 1) + ? variableCount - (deviceCount - 1) * groupVariableCount // 最后一个设备分配剩余的变量 + : groupVariableCount; + + for (int i1 = 0; i1 < currentGroupVariableCount; i1++) { - groupVariableCount = variableCount - deviceCount * (groupVariableCount - 1); - } - for (int i1 = 0; i1 < groupVariableCount; i1++) - { - if (addressNum >= 65500) - addressNum = 1; + if (addressNum > 65535) addressNum = 1; var address = $"4{addressNum}"; addressNum++; var id = CommonUtils.GetSingleId(); - var name = $"testVariable{id}"; + var name = $"modbusVariable{address}_{id}"; Variable variable = new Variable(); variable.DataType = DataTypeEnum.Int16; variable.Name = name; @@ -135,27 +124,26 @@ internal sealed class VariableService : BaseService, IVariableService { var id = CommonUtils.GetSingleId(); - var name = $"testChannel{id}"; + var name = $"modbusSlaveChannel{id}"; serviceChannel.ChannelType = ChannelTypeEnum.TcpService; serviceChannel.Name = name; serviceChannel.Enable = true; + serviceChannel.Id = id; serviceChannel.CreateUserId = UserManager.UserId; serviceChannel.CreateOrgId = UserManager.OrgId; - serviceChannel.Id = id; serviceChannel.BindUrl = "127.0.0.1:502"; + serviceChannel.PluginName = "ThingsGateway.Plugin.Modbus.ModbusSlave"; newChannels.Add(serviceChannel); } { var id = CommonUtils.GetSingleId(); - var name = $"testDevice{id}"; + var name = $"modbusSlaveDevice{id}"; serviceDevice.Name = name; - serviceDevice.PluginType = PluginTypeEnum.Business; serviceDevice.Id = id; serviceDevice.CreateUserId = UserManager.UserId; serviceDevice.CreateOrgId = UserManager.OrgId; serviceDevice.ChannelId = serviceChannel.Id; serviceDevice.IntervalTime = "1000"; - serviceDevice.PluginName = "ThingsGateway.Plugin.Modbus.ModbusSlave"; newDevices.Add(serviceDevice); } @@ -164,25 +152,24 @@ internal sealed class VariableService : BaseService, IVariableService { var id = CommonUtils.GetSingleId(); - var name = $"testChannel{id}"; + var name = $"mqttChannel{id}"; mqttChannel.ChannelType = ChannelTypeEnum.Other; mqttChannel.Name = name; + mqttChannel.Id = id; mqttChannel.CreateUserId = UserManager.UserId; mqttChannel.CreateOrgId = UserManager.OrgId; - mqttChannel.Id = id; + mqttChannel.PluginName = "ThingsGateway.Plugin.Mqtt.MqttServer"; newChannels.Add(mqttChannel); } { var id = CommonUtils.GetSingleId(); - var name = $"testDevice{id}"; + var name = $"mqttDevice{id}"; mqttDevice.Name = name; - mqttDevice.PluginType = PluginTypeEnum.Business; mqttDevice.Id = id; mqttDevice.CreateUserId = UserManager.UserId; mqttDevice.CreateOrgId = UserManager.OrgId; mqttDevice.ChannelId = mqttChannel.Id; mqttDevice.IntervalTime = "1000"; - mqttDevice.PluginName = "ThingsGateway.Plugin.Mqtt.MqttServer"; mqttDevice.DevicePropertys = new Dictionary { {"IsAllVariable", "true"} @@ -208,10 +195,23 @@ internal sealed class VariableService : BaseService, IVariableService { throw new(result.ErrorMessage, result.ErrorException); } + return (newChannels, newDevices, newVariables); } #endregion 测试 + /// + /// 保存初始值 + /// + public async Task UpdateInitValueAsync(List variables) + { + if (variables.Count > 0) + { + using var db = GetDB(); + var result = await db.Updateable(variables).UpdateColumns(a => a.Value).ExecuteCommandAsync().ConfigureAwait(false); + } + } + /// [OperDesc("SaveVariable", isRecordPar: false, localizerType: typeof(Variable))] public async Task AddBatchAsync(List input) @@ -233,8 +233,13 @@ internal sealed class VariableService : BaseService, IVariableService if (differences?.Count > 0) { using var db = GetDB(); + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + var data = models + .WhereIf(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + .WhereIf(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ToList(); - var result = (await db.Updateable(models.ToList()).UpdateColumns(differences.Select(a => a.Key).ToArray()).ExecuteCommandAsync().ConfigureAwait(false)) > 0; + var result = (await db.Updateable(data).UpdateColumns(differences.Select(a => a.Key).ToArray()).ExecuteCommandAsync().ConfigureAwait(false)) > 0; _dispatchService.Dispatch(new()); if (result) DeleteCache(); @@ -246,27 +251,15 @@ internal sealed class VariableService : BaseService, IVariableService } } - /// - [OperDesc("ClearVariable", localizerType: typeof(Variable), isRecordPar: false)] - public async Task ClearVariableAsync(SqlSugarClient db = null) - { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - db ??= GetDB(); - var result = await db.Deleteable() - .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 - .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) - .ExecuteCommandAsync().ConfigureAwait(false); - - if (result > 0) - DeleteCache(); - _dispatchService.Dispatch(new()); - } - [OperDesc("DeleteVariable", isRecordPar: false, localizerType: typeof(Variable))] public async Task DeleteByDeviceIdAsync(IEnumerable input, SqlSugarClient db) { + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); var ids = input.ToList(); - var result = await db.Deleteable().Where(a => ids.Contains(a.DeviceId.Value)).ExecuteCommandAsync().ConfigureAwait(false); + var result = await db.Deleteable().Where(a => ids.Contains(a.DeviceId)) + .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ExecuteCommandAsync().ConfigureAwait(false); if (result > 0) DeleteCache(); @@ -277,8 +270,12 @@ internal sealed class VariableService : BaseService, IVariableService public async Task DeleteVariableAsync(IEnumerable input) { using var db = GetDB(); + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); var ids = input.ToList(); - var result = (await db.Deleteable().Where(a => ids.Contains(a.Id)).ExecuteCommandAsync().ConfigureAwait(false)) > 0; + var result = (await db.Deleteable().Where(a => ids.Contains(a.Id)) + .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + .ExecuteCommandAsync().ConfigureAwait(false)) > 0; _dispatchService.Dispatch(new()); if (result) @@ -286,43 +283,49 @@ internal sealed class VariableService : BaseService, IVariableService return result; } - public async Task> GetVariableRuntimeAsync(long? devId = null) + public async Task> GetAllAsync(long? devId = null) { - try + using var db = GetDB(); + if (devId == null) { - using var db = GetDB(); - if (devId == null) - { - var deviceVariables = await db.Queryable().Where(a => a.DeviceId > 0 && a.Enable).ToListAsync().ConfigureAwait(false); - var runtime = deviceVariables.Adapt>(); - return runtime; - } - else - { - var deviceVariables = await db.Queryable().Where(a => a.DeviceId == devId && a.Enable).ToListAsync().ConfigureAwait(false); - var runtime = deviceVariables.Adapt>(); - return runtime; - } + var deviceVariables = await db.Queryable().Where(a => a.DeviceId > 0).ToListAsync().ConfigureAwait(false); + return deviceVariables; } - finally + else { - GC.Collect(); + var deviceVariables = await db.Queryable().Where(a => a.DeviceId == devId).ToListAsync().ConfigureAwait(false); + return deviceVariables; } } /// /// 报表查询 /// - /// 查询条件 - /// 业务设备id - public async Task> PageAsync(QueryPageOptions option, long? businessDeviceId) + /// 查询条件 + public async Task> PageAsync(ExportFilter exportFilter) { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - return await QueryAsync(option, a => a - .WhereIF(!option.SearchText.IsNullOrWhiteSpace(), a => a.Name.Contains(option.SearchText!)) - .WhereIF(businessDeviceId > 0, u => SqlFunc.JsonLike(u.VariablePropertys, businessDeviceId.ToString())) - .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); + HashSet? deviceId = null; + if (!exportFilter.PluginName.IsNullOrWhiteSpace()) + { + var channel = (await _channelService.GetAllAsync().ConfigureAwait(false)).Where(a => a.PluginName == exportFilter.PluginName).Select(a => a.Id).ToHashSet(); + deviceId = (await _deviceService.GetAllAsync().ConfigureAwait(false)).Where(a => channel.Contains(a.ChannelId)).Select(a => a.Id).ToHashSet(); + } + else if (exportFilter.ChannelId != null) + { + deviceId = (await _deviceService.GetAllAsync().ConfigureAwait(false)).Where(a => a.ChannelId == exportFilter.ChannelId).Select(a => a.Id).ToHashSet(); + } + return await QueryAsync(exportFilter.QueryPageOptions, a => a + .WhereIF(!exportFilter.QueryPageOptions.SearchText.IsNullOrWhiteSpace(), a => a.Name.Contains(exportFilter.QueryPageOptions.SearchText!)) + .WhereIF(exportFilter.PluginType == PluginTypeEnum.Collect, a => a.DeviceId == exportFilter.DeviceId) + .WhereIF(deviceId != null, a => deviceId.Contains(a.DeviceId)) + + .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) + + + .WhereIF(exportFilter.PluginType == PluginTypeEnum.Business, u => SqlFunc.JsonLike(u.VariablePropertys, exportFilter.DeviceId.ToString())) + ).ConfigureAwait(false); } @@ -334,10 +337,8 @@ internal sealed class VariableService : BaseService, IVariableService [OperDesc("SaveVariable", localizerType: typeof(Variable))] public async Task SaveVariableAsync(Variable input, ItemChangedType type) { - CheckInput(input); - if (type == ItemChangedType.Update) - await SysUserService.CheckApiDataScopeAsync(input.CreateOrgId, input.CreateUserId).ConfigureAwait(false); + await GlobalData.SysUserService.CheckApiDataScopeAsync(input.CreateOrgId, input.CreateUserId).ConfigureAwait(false); if (await base.SaveAsync(input, type).ConfigureAwait(false)) { @@ -353,47 +354,6 @@ internal sealed class VariableService : BaseService, IVariableService App.CacheService.Remove(ThingsGatewayCacheConst.Cache_Variable); } - private void CheckInput(Variable input) - { - - if (string.IsNullOrEmpty(input.RegisterAddress) && string.IsNullOrEmpty(input.OtherMethod)) - throw Oops.Bah(Localizer["AddressOrOtherMethodNotNull"]); - } - - #region API查询 - - public async Task> PageAsync(VariablePageInput input) - { - using var db = GetDB(); - var query = await GetPageAsync(db, input).ConfigureAwait(false); - return await query.ToPagedListAsync(input.Current, input.Size).ConfigureAwait(false);//分页 - - } - - /// - private async Task> GetPageAsync(SqlSugarClient db, VariablePageInput input) - { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); - ISugarQueryable query = db.Queryable() - .WhereIF(!string.IsNullOrEmpty(input.Name), u => u.Name.Contains(input.Name)) - .WhereIF(!string.IsNullOrEmpty(input.RegisterAddress), u => u.RegisterAddress.Contains(input.RegisterAddress)) - .WhereIF(dataScope != null && dataScope?.Count > 0, u => dataScope.Contains(u.CreateOrgId))//在指定机构列表查询 - .WhereIF(dataScope?.Count == 0, u => u.CreateUserId == UserManager.UserId) - - .WhereIF(input.DeviceId > 0, u => u.DeviceId == input.DeviceId) - .WhereIF(input.BusinessDeviceId > 0, u => SqlFunc.JsonLike(u.VariablePropertys, input.BusinessDeviceId.ToString())); - - for (int i = input.SortField.Count - 1; i >= 0; i--) - { - query = query.OrderByIF(!string.IsNullOrEmpty(input.SortField[i]), $"{input.SortField[i]} {(input.SortDesc[i] ? "desc" : "asc")}"); - } - query = query.OrderBy(it => it.Id, OrderByType.Desc);//排序 - - return query; - } - - #endregion API查询 - #region 导出 /// @@ -402,7 +362,7 @@ internal sealed class VariableService : BaseService, IVariableService [OperDesc("ExportVariable", isRecordPar: false, localizerType: typeof(Variable))] public async Task ExportMemoryStream(IEnumerable data, string deviceName = null) { - Dictionary sheets = ExportCore(data, deviceName); + Dictionary sheets = await ExportCoreAsync(data, deviceName).ConfigureAwait(false); var memoryStream = new MemoryStream(); await memoryStream.SaveAsAsync(sheets).ConfigureAwait(false); @@ -414,20 +374,21 @@ internal sealed class VariableService : BaseService, IVariableService /// 导出文件 /// [OperDesc("ExportVariable", isRecordPar: false, localizerType: typeof(Variable))] - public async Task> ExportVariableAsync(QueryPageOptions options, FilterKeyValueAction filterKeyValueAction = null) + public async Task> ExportVariableAsync(ExportFilter exportFilter) { - var data = (await QueryAsync(options, null, filterKeyValueAction).ConfigureAwait(false)); - Dictionary sheets = ExportCore(data.Items); + var data = (await PageAsync(exportFilter).ConfigureAwait(false)); + var sheets = await ExportCoreAsync(data.Items).ConfigureAwait(false); return sheets; } - private Dictionary ExportCore(IEnumerable data, string deviceName = null) + private async Task> ExportCoreAsync(IEnumerable data, string deviceName = null) { if (data == null || !data.Any()) { data = new List(); } - var deviceDicts = _deviceService.GetAll().ToDictionary(a => a.Id); + var deviceDicts = (await _deviceService.GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Id); + var channelDicts = (await _channelService.GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Id); var driverPluginDicts = _pluginService.GetList(PluginTypeEnum.Business).ToDictionary(a => a.FullName); //总数据 Dictionary sheets = new(); @@ -463,7 +424,7 @@ internal sealed class VariableService : BaseService, IVariableService data.ParallelForEach((variable, state, index) => { Dictionary varExport = new(); - deviceDicts.TryGetValue(variable.DeviceId.Value, out var device); + deviceDicts.TryGetValue(variable.DeviceId, out var device); //设备实体没有包含设备名称,手动插入 varExport.TryAdd(ExportString.DeviceName, device?.Name ?? deviceName); foreach (var item in propertyInfos) @@ -491,26 +452,41 @@ internal sealed class VariableService : BaseService, IVariableService var has = deviceDicts.TryGetValue(item.Key, out var businessDevice); if (!has) continue; + + channelDicts.TryGetValue(businessDevice.ChannelId, out var channel); + //没有包含设备名称,手动插入 driverInfo.TryAdd(ExportString.DeviceName, businessDevice.Name); driverInfo.TryAdd(ExportString.VariableName, variable.Name); var propDict = item.Value; - if (propertysDict.TryGetValue(businessDevice.PluginName, out var propertys)) + if (propertysDict.TryGetValue(channel.PluginName, out var propertys)) { } else { - var variableProperty = ((BusinessBase)_pluginService.GetDriver(businessDevice.PluginName)).VariablePropertys; - propertys.Item1 = variableProperty; - var variablePropertyType = variableProperty.GetType(); - propertys.Item2 = variablePropertyType.GetRuntimeProperties() - .Where(a => a.GetCustomAttribute() != null) - .ToDictionary(a => variablePropertyType.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); - propertysDict.TryAdd(businessDevice.PluginName, propertys); - } + try + { + var variableProperty = ((BusinessBase)_pluginService.GetDriver(channel.PluginName))?.VariablePropertys; + propertys.Item1 = variableProperty; + var variablePropertyType = variableProperty.GetType(); + propertys.Item2 = variablePropertyType.GetRuntimeProperties() + .Where(a => a.GetCustomAttribute() != null) + .ToDictionary(a => variablePropertyType.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); + propertysDict.TryAdd(channel.PluginName, propertys); + + } + catch + { + + } + } + if (propertys.Item2?.Count == null) + { + continue; + } //根据插件的配置属性项生成列,从数据库中获取值或者获取属性默认值 foreach (var item1 in propertys.Item2) { @@ -525,10 +501,10 @@ internal sealed class VariableService : BaseService, IVariableService } } - if (!driverPluginDicts.ContainsKey(businessDevice.PluginName)) + if (!driverPluginDicts.ContainsKey(channel.PluginName)) continue; - var pluginName = PluginServiceUtil.GetFileNameAndTypeName(businessDevice.PluginName); + var pluginName = PluginServiceUtil.GetFileNameAndTypeName(channel.PluginName); //lock (devicePropertys) { if (devicePropertys.ContainsKey(pluginName.Item2)) @@ -595,7 +571,7 @@ internal sealed class VariableService : BaseService, IVariableService /// [OperDesc("ImportVariable", isRecordPar: false, localizerType: typeof(Variable))] - public async Task ImportVariableAsync(Dictionary input) + public async Task> ImportVariableAsync(Dictionary input) { var variables = new List(); foreach (var item in input) @@ -614,7 +590,7 @@ internal sealed class VariableService : BaseService, IVariableService await db.Fastest().PageSize(100000).BulkUpdateAsync(upData).ConfigureAwait(false); _dispatchService.Dispatch(new()); DeleteCache(); - + return variables.Select(a => a.Id).ToHashSet(); } private static readonly WaitLock _cacheLock = new(); @@ -652,9 +628,9 @@ internal sealed class VariableService : BaseService, IVariableService return datas; } - public async Task PreheatCache() + public Task PreheatCache() { - await GetVariableImportData().ConfigureAwait(false); + return GetVariableImportData(); } private sealed class VariableImportData @@ -668,15 +644,15 @@ internal sealed class VariableService : BaseService, IVariableService { // 上传文件并获取文件路径 var path = await browserFile.StorageLocal().ConfigureAwait(false); + var dataScope = await GlobalData.SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); try { - var dataScope = await SysUserService.GetCurrentUserDataScopeAsync().ConfigureAwait(false); // 获取Excel文件中所有工作表的名称 var sheetNames = MiniExcel.GetSheetNames(path); // 获取所有设备的字典,以设备名称作为键 - var deviceDicts = _deviceService.GetAll().ToDictionary(a => a.Name); + var deviceDicts = (await _deviceService.GetAllAsync().ConfigureAwait(false)).ToDictionary(a => a.Name); // 存储导入检验结果的字典 Dictionary ImportPreviews = new(); @@ -827,25 +803,41 @@ internal sealed class VariableService : BaseService, IVariableService } else { - var variableProperty = ((BusinessBase)_pluginService.GetDriver(driverPluginType.FullName)).VariablePropertys; - var variablePropertyType = variableProperty.GetType(); - propertys.Item1 = variablePropertyType; - propertys.Item2 = variablePropertyType.GetRuntimeProperties() - .Where(a => a.GetCustomAttribute() != null) - .ToDictionary(a => variablePropertyType.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); + try + { - // 获取目标类型的所有属性,并根据是否需要过滤 IgnoreExcelAttribute 进行筛选 - var properties = propertys.Item1.GetRuntimeProperties().Where(a => (a.GetCustomAttribute() == null) && a.CanWrite) - .ToDictionary(a => propertys.Item1.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); - propertys.Item3 = properties; - propertysDict.TryAdd(driverPluginType.FullName, propertys); + var variableProperty = ((BusinessBase)_pluginService.GetDriver(driverPluginType.FullName)).VariablePropertys; + var variablePropertyType = variableProperty.GetType(); + propertys.Item1 = variablePropertyType; + propertys.Item2 = variablePropertyType.GetRuntimeProperties() + .Where(a => a.GetCustomAttribute() != null) + .ToDictionary(a => variablePropertyType.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); + + // 获取目标类型的所有属性,并根据是否需要过滤 IgnoreExcelAttribute 进行筛选 + var properties = propertys.Item1.GetRuntimeProperties().Where(a => (a.GetCustomAttribute() == null) && a.CanWrite) + .ToDictionary(a => propertys.Item1.GetPropertyDisplayName(a.Name, a => a.GetCustomAttribute(true)?.Description)); + + propertys.Item3 = properties; + propertysDict.TryAdd(driverPluginType.FullName, propertys); + } + catch (Exception) + { + + } } rows.ParallelForEach(item => { try { + if (propertys.Item3?.Count == null || propertys.Item1 == null) + { + importPreviewOutput.HasError = true; + importPreviewOutput.Results.Add((Interlocked.Add(ref row, 1), false, Localizer["ImportNullError"])); + return; + } + // 尝试将导入的项转换为对象 var pluginProp = (item as ExpandoObject)?.ConvertToEntity(propertys.Item1, propertys.Item3); @@ -917,7 +909,7 @@ internal sealed class VariableService : BaseService, IVariableService if (has) { deviceVariable.VariablePropertys ??= new(); - deviceVariable.VariablePropertys?.AddOrUpdate(businessDevice.Id, a => dependencyProperties, (a, b) => dependencyProperties); + deviceVariable.VariablePropertys?.AddOrUpdate(businessDevice.Id, dependencyProperties); importPreviewOutput.Results.Add((Interlocked.Add(ref row, 1), true, null)); } else diff --git a/src/Gateway/ThingsGateway.Gateway.Application/Startup.cs b/src/Gateway/ThingsGateway.Gateway.Application/Startup.cs index 5eaec1a62..2d7901796 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/Startup.cs +++ b/src/Gateway/ThingsGateway.Gateway.Application/Startup.cs @@ -19,22 +19,9 @@ public class Startup : AppStartup { public void ConfigureAdminApp(IServiceCollection services) { - - var tempDir = Path.Combine(AppContext.BaseDirectory, "CSSCRIPT"); - if (Directory.Exists(tempDir)) - { - try - { - Directory.Delete(tempDir); - } - catch - { - - } - } - - Directory.CreateDirectory(tempDir);//重新创建,防止缓存的一些目录信息错误 - Environment.SetEnvironmentVariable("CSS_CUSTOM_TEMPDIR", tempDir); //传入变量 + services.AddConfigurableOptions(); + services.AddConfigurableOptions(); + services.AddConfigurableOptions(); //底层多语言配置 //Foundation.LocalizerUtil.SetLocalizerFactory((a) => App.CreateLocalizerByType(a)); @@ -58,19 +45,21 @@ public class Startup : AppStartup }; }); - + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddScoped(); - services.AddGatewayHostedService(); - services.AddGatewayHostedService(); services.AddGatewayHostedService(); + services.AddGatewayHostedService(); } public void UseAdminCore(IServiceProvider serviceProvider) @@ -86,7 +75,9 @@ public class Startup : AppStartup DbContext.DbConfigs?.ForEach(it => { var connection = DbContext.Db.GetConnection(it.ConfigId);//获取数据库连接对象 - connection.DbMaintenance.CreateDatabase();//创建数据库,如果存在则不创建 + + if (it.InitTable == true) + connection.DbMaintenance.CreateDatabase();//创建数据库,如果存在则不创建 }); var fullName = Assembly.GetExecutingAssembly().FullName;//获取程序集全名 CodeFirstUtils.CodeFirst(fullName!);//CodeFirst diff --git a/src/Gateway/ThingsGateway.Gateway.Application/ThingsGateway.Gateway.Application.csproj b/src/Gateway/ThingsGateway.Gateway.Application/ThingsGateway.Gateway.Application.csproj index 8ee896450..a2bfb7a63 100644 --- a/src/Gateway/ThingsGateway.Gateway.Application/ThingsGateway.Gateway.Application.csproj +++ b/src/Gateway/ThingsGateway.Gateway.Application/ThingsGateway.Gateway.Application.csproj @@ -1,27 +1,18 @@ - + - + + net8.0; + - - + + - - - - - - - - Never - - - @@ -29,8 +20,17 @@ + + + + Never + + + + + diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor index 18bf988fc..8cec7ab23 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor @@ -10,6 +10,12 @@
@HeaderText
+
+ +
+ AutoRestartThread ) Value="AutoRestartThread" ValueChanged="OnAutoRestartThreadChanged" Items="AutoRestartThreadBoolItems" /> +
+
+ OnConfirm="Restart" IsDisabled=@(!AuthorizeButton("重启")) /> - @* - *@ diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.cs index 171e59d7a..0bc49c146 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.cs +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.cs @@ -51,23 +51,47 @@ public partial class QuickActions ReloadServiceText ??= Localizer[nameof(ReloadServiceText)]; ReloadPluginConfirmText ??= Localizer[nameof(ReloadPluginConfirmText)]; ReloadServiceConfirmText ??= Localizer[nameof(ReloadServiceConfirmText)]; + + AutoRestartThreadBoolItems = LocalizerUtil.GetBoolItems(GetType(), nameof(AutoRestartThread)); base.OnInitialized(); } - private static async Task OnReloadService() + #region 配置 + + + [Parameter] + public bool AutoRestartThread { get; set; } = true; + [Parameter] + public EventCallback AutoRestartThreadChanged { get; set; } + + private async Task OnAutoRestartThreadChanged(bool autoRestartThread) { - try - { - await Task.Run(async () => - { - await GlobalData.CollectDeviceHostedService.RestartAsync(); - }); - } - finally - { - } + AutoRestartThread = autoRestartThread; + if (Module != null) + await Module!.InvokeVoidAsync("saveAutoRestartThread", autoRestartThread); + if (AutoRestartThreadChanged.HasDelegate) + await AutoRestartThreadChanged.InvokeAsync(autoRestartThread); } + private List AutoRestartThreadBoolItems; + + + private static async Task Restart() + { + await Task.Run(async () => + { + var data = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + await GlobalData.ChannelThreadManage.RestartChannelAsync(data.Select(a => a.Value)); + }); + } + + protected override async Task InvokeInitAsync() + { + await base.InvokeInitAsync(); + var autoRestartThread = await Module!.InvokeAsync("getAutoRestartThread"); + await OnAutoRestartThreadChanged(autoRestartThread); + } + #endregion private async Task ToggleOpen() { await Module!.InvokeVoidAsync("toggle", Id); diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.css b/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.css index f12e01500..844f09443 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.css +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.css @@ -38,7 +38,7 @@ position: fixed; z-index: 100; bottom: 12rem; - right: 1rem; + left: calc(var(--bb-layout-sidebar-width) + 20px); width: 250px; /*//初始高度为0*/ height: 0; @@ -67,7 +67,7 @@ --bs-btn-active-bg: var(--tg-quickactions-button-active-bg); /*阴影样式*/ box-shadow: var(--tg-quickactions-button-shadow); - right: 1rem; + left: calc(var(--bb-layout-sidebar-width) + 20px); bottom: 9rem; --bb-button-circle-width: 40px; --bb-button-circle-height: 40px; diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.js b/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.js index 800a4a191..002541076 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.js +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Components/QuickActions.razor.js @@ -6,4 +6,20 @@ const themeList = el.querySelector('.quickactions-list') //切换高度 themeList.classList.toggle('is-open') +} +export function getAutoRestartThread() { + return JSON.parse(localStorage.getItem('autoRestartThread'))??true; +} +export function getShowType() { + return JSON.parse(localStorage.getItem('showType'))??0; +} +export function saveShowType(showType) { + if (localStorage) { + localStorage.setItem('showType', JSON.stringify(showType)); + } +} +export function saveAutoRestartThread(autoRestartThread) { + if (localStorage) { + localStorage.setItem('autoRestartThread', JSON.stringify(autoRestartThread)); + } } \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Components/ThingsGatewayModuleComponentBase.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Components/ThingsGatewayModuleComponentBase.cs index 22f017e43..8e53b1d5e 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Components/ThingsGatewayModuleComponentBase.cs +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Components/ThingsGatewayModuleComponentBase.cs @@ -8,7 +8,7 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -namespace ThingsGateway.Razor; +namespace ThingsGateway.Gateway.Razor; public abstract class ThingsGatewayModuleComponentBase : BootstrapModuleComponentBase { diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Locales/en-US.json b/src/Gateway/ThingsGateway.Gateway.Razor/Locales/en-US.json index 87b5e4a4c..b62f67d5b 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Locales/en-US.json +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Locales/en-US.json @@ -11,32 +11,7 @@ "LastSentTime": "LastSentTime" }, - "ThingsGateway.Gateway.Razor.DeviceStatus2": { - "GatewayDeviceShowDriverUI": "DriverUI", - "DeviceRedundantThread": "RedundantSwitch", - "DeleteCache": "DeleteCache", - "GatewayDeviceLog": "ChannelLog", - "RelationVariable": "RelationVariable", - "GatewayDevicePause": "DevicePause", - "GatewayDeviceRestart": "DeviceRestart" - }, - "ThingsGateway.Gateway.Razor.DeviceStatus": { - "LogConsole": "ChannelLog" - }, - "ThingsGateway.Gateway.Razor.DeviceStatusPage": { - "CollectDevice": "CollectDevice", - "BusinessDevice": "BusinessDevice" - }, - "ThingsGateway.Gateway.Razor.VariableRuntimePage": { - "WriteVariable": "Write", - "WriteValue": "Value" - }, - "ThingsGateway.Gateway.Razor.DriverDebugPage": { - "PluginUINotNull": "This plugin does not implement a debug page", - "New": "New", - "NewWinbox": "NewWinbox" - }, "ThingsGateway.Gateway.Razor.Index": { "CollectDevice": "Collect Device", "BusinessDevice": "Business Device", @@ -54,14 +29,10 @@ "Data": "Data", "HistoryHardwareInfo": "Historical Chart" }, - "ThingsGateway.Gateway.Razor.QuickActions": { - "TooltipText": "Quick Actions", - "HeaderText": "Quick Actions", - "RestartText": "Restart", - "ReloadServiceText": "Restart Runtime", - "ReloadPluginConfirmText": "Are you sure you want to reload the plugin?", - "ReloadServiceConfirmText": "Are you sure you want to restart the runtime?" + "ThingsGateway.Gateway.Razor.ChannelDeviceTree": { + "ShowType": "ShowType" }, + "ThingsGateway.Gateway.Razor.SavePlugin": { "SavePlugin": "Note: Plugins with the same file name will be overwritten", "SavePlugin1": "Plugin changes may take effect after restarting the software" @@ -77,50 +48,96 @@ "Date": "Date", "Count": "Count" }, - "ThingsGateway.Gateway.Razor.ChannelPage": { - "ImportExcel": "Import Channel", - "Clear": "Clear" - }, - "ThingsGateway.Gateway.Razor.CollectDevicePage": { - "ImportExcel": "Import Collect Device", - "Clear": "Clear", - "RelationVariable": "RelationVariable" - }, - "ThingsGateway.Gateway.Razor.BusinessDevicePage": { - "ImportExcel": "Import Business Device", - "Clear": "Clear", - "RelationVariable": "RelationVariable" - }, - "ThingsGateway.Gateway.Razor.VariablePage": { - "ImportExcel": "Import Variable", - "Clear": "Clear", + + + + "ThingsGateway.Gateway.Razor.VariableRuntimeInfo": { + "WriteVariable": "WriteVariable", + "WriteValue": "WriteValue", + "ImportExcel": "ImportExcel", "TestVariableCount": "TestVariableCount", "TestDeviceCount": "TestDeviceCount", - "SlaveUrl": "SlaveUrl", - "Test": "AddTestVariable" + "SlaveUrl": "SlaveUrlUrl", + "Test": "Addition of test variables" }, - "ThingsGateway.Gateway.Razor.DeviceEditComponent": { + + "ThingsGateway.Gateway.Razor.QuickActions": { + "TooltipText": "Quick Operation", + "HeaderText": "Quick Operation", + "RestartText": "Restart", + "ReloadServiceText": "Reload Service", + "ReloadPluginConfirmText": "Confirm the overloaded plugin?", + "ReloadServiceConfirmText": "Confirm when restarting the runtime?", + + "AutoRestartThread": "AutoRestartThread" + + }, + + "ThingsGateway.Gateway.Razor.ShowTypeEnum": { + "Variable": "Variable", + "LogInfo": "Info" + }, + + "ThingsGateway.Gateway.Razor.VariableEditComponent": { + + "ChoiceBusinessDeviceId": "BusinessDevice" + }, + + + "ThingsGateway.Gateway.Razor._Imports": { + + "GatewayDeviceShowDriverUI": "Show driverUI", + "DeviceRedundantThread": "Switching redundancy", + "DeleteCache": "Delete cache", + "GatewayDeviceLog": "Channel log", + "RelationVariable": "Associated variables", + "GatewayDevicePause": "Pause/Run", + "GatewayDeviceRestart": "Restart device", + "GatewayChannelRestart": "Restart channel", + + "BasicInformation": "Basic Information", + "Connection": "Connection", "DeviceInformation": "Device Information", "PluginInformation": "Plugin Information", - "BasicInformation": "Basic Information", - "Connection": "Connection", "Redundant": "Redundant", "Remark": "Remark", - "Check": "Check" - }, - "ThingsGateway.Gateway.Razor.VariableEditComponent": { + "Check": "Check", "VariableInformation": "Variable Information", "AlarmInformation": "Alarm Information", - "PluginInformation": "Business Information", - "BasicInformation": "Basic Information", - "Connection": "Connection", - "Remark": "Remark", + "RefreshBusinessProperty": "Add/Refresh Business Property", - "RefreshBusinessPropertyError": "You need to select a business device before adding business properties", - "ChoiceBusinessDeviceId": "Business Device" - }, - "ThingsGateway.Gateway.Razor.ChannelEditComponent": { - "BasicInformation": "Basic Information", - "Connection": "Connection" + "RefreshBusinessPropertyError": "Select business equipment and add business attributes", + "Runtime": "Runtime", + + "DeviceList": "Device List", + "AddChannel": "Add Channel", + "BatchEditChannel": "Batch Edit Channel", + "UpdateChannel": "Update Channel", + "DeleteCurrentChannel": "Delete Current Channel", + "DeleteAllChannel": "Delete All Channel", + "ExportCurrentChannel": "Export Current Channel", + "ExportAllChannel": "Export All Channel", + "ImportChannel": "Import Channel", + "AddDevice": "Add Device", + "BatchEditDevice": "Batch Edit Device", + "UpdateDevice": "Update Device", + "DeleteCurrentDevice": "Delete Current Device", + "DeleteAllDevice": "Delete All Device", + "ExportCurrentDevice": "Export Current Device", + "ExportAllDevice": "Export All Device", + "ImportDevice": "Import Device", + + "DeleteConfirmTitle": "Delete Confirm", + "AllChannel": "All Channel", + "AllDevice": "All Device", + "Collect": "Collect", + "Business": "Business", + "Unknown": "Unknown", + "AddVariable": "Add Variable", + + "Script": "Script", + "Input": "Input", + "Output": "Output" } + } diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Locales/zh-CN.json b/src/Gateway/ThingsGateway.Gateway.Razor/Locales/zh-CN.json index e8554fec6..c200a3edd 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Locales/zh-CN.json +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Locales/zh-CN.json @@ -9,25 +9,16 @@ "LastReceivedTime": "最近接收时间", "LastSentTime": "最近发送时间" }, - "ThingsGateway.Gateway.Razor.DeviceStatus2": { - "GatewayDeviceShowDriverUI": "插件UI", - "DeviceRedundantThread": "切换冗余", - "DeleteCache": "删除缓存", - "GatewayDeviceLog": "通道日志", - "RelationVariable": "关联变量", - "GatewayDevicePause": "暂停/运行", - "GatewayDeviceRestart": "重启设备" - }, - "ThingsGateway.Gateway.Razor.DeviceStatus": { - "LogConsole": "通道日志" - }, - "ThingsGateway.Gateway.Razor.DeviceStatusPage": { - "CollectDevice": "采集设备", - "BusinessDevice": "业务设备" - }, - "ThingsGateway.Gateway.Razor.VariableRuntimePage": { + + + "ThingsGateway.Gateway.Razor.VariableRuntimeInfo": { "WriteVariable": "写入", - "WriteValue": "写入值" + "WriteValue": "写入值", + "ImportExcel": "导入变量", + "TestVariableCount": "变量数量", + "TestDeviceCount": "采集设备数量", + "SlaveUrl": "服务端Url", + "Test": "一键添加测试变量" }, "ThingsGateway.Gateway.Razor.DriverDebugPage": { @@ -59,8 +50,20 @@ "RestartText": "重启", "ReloadServiceText": "重启运行时", "ReloadPluginConfirmText": "确定重载插件?", - "ReloadServiceConfirmText": "确定重启运行时?" + "ReloadServiceConfirmText": "确定重启运行时?", + + "AutoRestartThread": "自动重启线程" + }, + "ThingsGateway.Gateway.Razor.ChannelDeviceTree": { + "ShowType": "显示类型" + }, + + "ThingsGateway.Gateway.Razor.ShowTypeEnum": { + "Variable": "变量页面", + "LogInfo": "日志页面" + }, + "ThingsGateway.Gateway.Razor.SavePlugin": { "SavePlugin": "注意:文件名称相同的插件将被覆盖", "SavePlugin1": "插件变动可能需重启软件后生效" @@ -76,55 +79,65 @@ "Date": "日期", "Count": "数量" }, - "ThingsGateway.Gateway.Razor.ChannelPage": { - "ImportExcel": "导入通道", - "Clear": "清空" - }, - "ThingsGateway.Gateway.Razor.CollectDevicePage": { - "ImportExcel": "导入采集设备", - "Clear": "清空", - "RelationVariable": "关联变量" - }, - "ThingsGateway.Gateway.Razor.BusinessDevicePage": { - "ImportExcel": "导入业务设备", - "Clear": "清空", - "RelationVariable": "关联变量" - }, - "ThingsGateway.Gateway.Razor.VariablePage": { - "ImportExcel": "导入变量", - "Clear": "清空", - "TestVariableCount": "变量数量", - "TestDeviceCount": "采集设备数量", - "SlaveUrl": "服务端Url", - "Test": "一键添加测试变量" - }, - "ThingsGateway.Gateway.Razor.DeviceEditComponent": { - "DeviceInformation": "设备信息", - "PluginInformation": "插件信息", - "BasicInformation": "基础信息", - "Connection": "连接", - "Redundant": "冗余", - "Remark": "备用", - "Check": "检查" - }, "ThingsGateway.Gateway.Razor.VariableEditComponent": { - "VariableInformation": "变量信息", - "AlarmInformation": "报警信息", - "PluginInformation": "业务信息", - - "BasicInformation": "基础信息", - "Connection": "连接", - "Remark": "备用", - - "RefreshBusinessProperty": "添加/刷新业务属性", - "RefreshBusinessPropertyError": "需选择业务设备,再添加业务属性", "ChoiceBusinessDeviceId": "业务设备" }, - "ThingsGateway.Gateway.Razor.ChannelEditComponent": { + "ThingsGateway.Gateway.Razor._Imports": { + + "GatewayDeviceShowDriverUI": "插件UI", + "DeviceRedundantThread": "切换冗余", + "DeleteCache": "删除缓存", + "GatewayDeviceLog": "通道日志", + "RelationVariable": "关联变量", + "GatewayDevicePause": "暂停/运行", + "GatewayDeviceRestart": "重启设备", + "GatewayChannelRestart": "重启通道", + "BasicInformation": "基础信息", - "Connection": "连接" + "Connection": "连接", + "DeviceInformation": "设备信息", + "PluginInformation": "插件信息", + "Redundant": "冗余", + "Remark": "备用", + "Check": "检查", + "VariableInformation": "变量信息", + "AlarmInformation": "报警信息", + + "RefreshBusinessProperty": "添加/刷新业务属性", + "RefreshBusinessPropertyError": "需选择业务设备,再添加业务属性", + "Runtime": "运行信息", + + "DeviceList": "设备节点", + "AddChannel": "添加通道", + "BatchEditChannel": "批量编辑通道", + "UpdateChannel": "更新通道", + "DeleteCurrentChannel": "删除当前节点下的通道", + "DeleteAllChannel": "删除全部通道", + "ExportCurrentChannel": "导出当前节点下的通道", + "ExportAllChannel": "导出全部通道", + "ImportChannel": "导入通道", + "AddDevice": "添加设备", + "BatchEditDevice": "批量编辑设备", + "UpdateDevice": "更新设备", + "DeleteCurrentDevice": "删除当前节点下的设备", + "DeleteAllDevice": "删除全部设备", + "ExportCurrentDevice": "导出当前节点下的设备", + "ExportAllDevice": "导出全部设备", + "ImportDevice": "导入设备", + + "DeleteConfirmTitle": "删除确认", + "AllChannel": "全部通道", + "AllDevice": "全部设备", + "Collect": "采集", + "Business": "业务", + "Unknown": "未知", + "AddVariable": "添加变量", + + "Script": "脚本", + "Input": "输入", + "Output": "输出" } diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Locales/zh-TW.json b/src/Gateway/ThingsGateway.Gateway.Razor/Locales/zh-TW.json deleted file mode 100644 index f05444843..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Locales/zh-TW.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "ThingsGateway.Gateway.Razor.PluginPage": { - "Reload": "重載" - }, - "ThingsGateway.Gateway.Razor.TcpSessionClientDto": { - "Id": "Id", - "IP": "IP", - "Port": "端口", - "LastReceivedTime": "最近接收時間", - "LastSentTime": "最近發送時間" - }, - "ThingsGateway.Gateway.Razor.DeviceStatus2": { - "GatewayDeviceShowDriverUI": "插件UI", - "DeviceRedundantThread": "切換冗餘", - "DeleteCache": "刪除緩存", - "GatewayDeviceLog": "通道日誌", - "RelationVariable": "關聯變量", - "GatewayDevicePause": "暫停/運行", - "GatewayDeviceRestart": "重启設備" - }, - "ThingsGateway.Gateway.Razor.DeviceStatus": { - "LogConsole": "通道日誌" - }, - "ThingsGateway.Gateway.Razor.DeviceStatusPage": { - "CollectDevice": "採集設備", - "BusinessDevice": "業務設備" - }, - "ThingsGateway.Gateway.Razor.VariableRuntimePage": { - "WriteVariable": "寫入", - "WriteValue": "寫入值" - }, - "ThingsGateway.Gateway.Razor.DriverDebugPage": { - "PluginUINotNull": "此插件未實現調試頁面", - "New": "新建", - "NewWinbox": "新建窗口" - }, - "ThingsGateway.Gateway.Razor.Index": { - "CollectDevice": "採集設備", - "BusinessDevice": "業務設備", - "Variable": "變量", - "Alarm": "實時警報", - "AlarmCount": "警報數量", - "OnLine": "在線", - "OffLine": "離線", - "Shortcuts": "快捷方式", - "OperLog": "最近操作", - "BackendLog": "網關後台日誌", - "RpcLog": "網關RPC日誌", - "HardwareInfoChart": "硬體資訊歷史曲線", - "DateTime": "時間", - "Data": "數據", - "HistoryHardwareInfo": "歷史曲線" - }, - "ThingsGateway.Gateway.Razor.QuickActions": { - "TooltipText": "快捷操作", - "HeaderText": "快捷操作", - "RestartText": "重啟", - "ReloadServiceText": "重启運行時", - "ReloadPluginConfirmText": "確定重載插件?", - "ReloadServiceConfirmText": "確定重启運行時?" - }, - "ThingsGateway.Gateway.Razor.SavePlugin": { - "SavePlugin": "注意:文件名稱相同的插件將被覆蓋", - "SavePlugin1": "挿件變動可能需重啓軟件後生效" - }, - "ThingsGateway.Gateway.Razor.BackendLogPage": { - "BackendLog": "網關後台日誌", - "Date": "日期", - "Count": "數量" - }, - "ThingsGateway.Gateway.Razor.RpcLogPage": { - "RpcLog": "網關RPC日誌", - "Date": "日期", - "Count": "數量" - }, - "ThingsGateway.Gateway.Razor.ChannelPage": { - "ImportExcel": "導入通道", - "Clear": "清空" - }, - "ThingsGateway.Gateway.Razor.CollectDevicePage": { - "ImportExcel": "導入採集設備", - "Clear": "清空", - "RelationVariable": "關聯變量" - }, - "ThingsGateway.Gateway.Razor.BusinessDevicePage": { - "ImportExcel": "導入業務設備", - "Clear": "清空", - "RelationVariable": "關聯變量" - }, - "ThingsGateway.Gateway.Razor.VariablePage": { - "ImportExcel": "導入變量", - "Clear": "清空", - "TestVariableCount": "變量數量", - "TestDeviceCount": "採集設備數量", - "SlaveUrl": "服務端Url", - "Test": "一鍵添加測試變量" - }, - "ThingsGateway.Gateway.Razor.DeviceEditComponent": { - "DeviceInformation": "設備資訊", - "PluginInformation": "插件資訊", - "BasicInformation": "基礎資訊", - "Connection": "連接", - "Redundant": "冗餘", - "Remark": "備用", - "Check": "檢查" - }, - "ThingsGateway.Gateway.Razor.VariableEditComponent": { - "VariableInformation": "變量資訊", - "AlarmInformation": "警報資訊", - "PluginInformation": "業務資訊", - "BasicInformation": "基礎資訊", - "Connection": "連接", - "Remark": "備用", - "RefreshBusinessProperty": "添加/刷新業務屬性", - "RefreshBusinessPropertyError": "需選擇業務設備,再添加業務屬性", - "ChoiceBusinessDeviceId": "業務設備" - }, - "ThingsGateway.Gateway.Razor.ChannelEditComponent": { - "BasicInformation": "基礎資訊", - "Connection": "連接" - } -} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelEditComponent.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelEditComponent.razor deleted file mode 100644 index cbab00e6d..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelEditComponent.razor +++ /dev/null @@ -1,115 +0,0 @@ -@namespace ThingsGateway.Gateway.Razor -@using ThingsGateway.Admin.Application -@using ThingsGateway.Admin.Razor -@using ThingsGateway.Foundation -@using ThingsGateway.Gateway.Application -@inherits ComponentDefault - -@if (ValidateEnable) -{ - - - - - - - - -
-
@Localizer["BasicInformation"]
-
-
-
- - - - -
- InvokeAsync(StateHasChanged)) /> -
-
- -
- - - - - -
-
@Localizer["Connection"]
-
-
-
- - - - - - - - - - - - -
- -
- -} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelEditComponent.razor.css b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelEditComponent.razor.css deleted file mode 100644 index 785ca2710..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelEditComponent.razor.css +++ /dev/null @@ -1,4 +0,0 @@ -h6 { - font-size: 1rem; - font-weight: bold; -} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelPage.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelPage.razor deleted file mode 100644 index 7b2c88fd4..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelPage.razor +++ /dev/null @@ -1,63 +0,0 @@ -@page "/gateway/channel" -@namespace ThingsGateway.Gateway.Razor -@using ThingsGateway.Admin.Application -@using ThingsGateway.Admin.Razor -@using ThingsGateway.Gateway.Application -@attribute [Authorize] -@attribute [RolePermission] -@inherits ComponentDefault - - -
- - - - - - - - - - - - - - - - - - - - - -
- -@code { - AdminTable table; -} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelPage.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelPage.razor.cs deleted file mode 100644 index 71b04ccd9..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Channel/ChannelPage.razor.cs +++ /dev/null @@ -1,221 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using Mapster; - -using Microsoft.AspNetCore.Components.Forms; - -using System.Data; - -using ThingsGateway.Gateway.Application; - -namespace ThingsGateway.Gateway.Razor; - -public partial class ChannelPage : IDisposable -{ - - [Inject] - [NotNull] - private IChannelService? ChannelService { get; set; } - - [Inject] - [NotNull] - private IDispatchService? DispatchService { get; set; } - - private Channel? SearchModel { get; set; } = new(); - - public void Dispose() - { - DispatchService.UnSubscribe(Notify); - } - - private ExecutionContext? context; - protected override Task OnInitializedAsync() - { - context = ExecutionContext.Capture(); - DispatchService.Subscribe(Notify); - return base.OnInitializedAsync(); - } - - private async Task Notify(DispatchEntry entry) - { - var current = ExecutionContext.Capture(); - try - { - ExecutionContext.Restore(context); - await InvokeAsync(table.QueryAsync); - await InvokeAsync(StateHasChanged); - } - finally - { - ExecutionContext.Restore(current); - } - - } - - - #region 查询 - - private async Task> OnQueryAsync(QueryPageOptions options) - { - return await Task.Run(async () => - { - var data = await ChannelService.PageAsync(options); - return data; - }); - } - - #endregion 查询 - - #region 修改 - - private async Task BatchEdit(IEnumerable channels) - { - var op = new DialogOption() - { - IsScrolling = true, - ShowMaximizeButton = true, - Size = Size.ExtraLarge, - Title = RazorLocalizer["BatchEdit"], - ShowFooter = false, - ShowCloseButton = false, - }; - var oldmodel = channels.FirstOrDefault();//默认值显示第一个 - var model = channels.FirstOrDefault().Adapt();//默认值显示第一个 - op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary - { - {nameof(ChannelEditComponent.OnValidSubmit), async () => - { - await ChannelService.BatchEditAsync(channels,oldmodel,model); - - await InvokeAsync(async ()=> - { - await InvokeAsync(table.QueryAsync); - }); - }}, - {nameof(ChannelEditComponent.Model),model }, - {nameof(ChannelEditComponent.ValidateEnable),true }, - {nameof(ChannelEditComponent.BatchEditEnable),true }, - }); - await DialogService.Show(op); - } - - private async Task Delete(IEnumerable channels) - { - try - { - return await Task.Run(async () => - { - var result = await ChannelService.DeleteChannelAsync(channels.Select(a => a.Id)); - return result; - }); - - } - catch (Exception ex) - { - await InvokeAsync(async () => - { - await ToastService.Warning(null, $"{ex.Message}"); - }); - return false; - } - } - - - private async Task Save(Channel channel, ItemChangedType itemChangedType) - { - try - { - var result = await ChannelService.SaveChannelAsync(channel, itemChangedType); - return result; - } - catch (Exception ex) - { - await InvokeAsync(async () => - { - await ToastService.Warning(null, $"{ex.Message}"); - }); - return false; - } - } - - #endregion 修改 - - #region 导出 - - [Inject] - [NotNull] - private IGatewayExportService? GatewayExportService { get; set; } - - private async Task ExcelExportAsync(ITableExportContext tableExportContext) - { - await GatewayExportService.OnChannelExport(tableExportContext.BuildQueryPageOptions()); - // 返回 true 时自动弹出提示框 - await ToastService.Default(); - } - - private async Task ExcelImportAsync(ITableExportContext tableExportContext) - { - var op = new DialogOption() - { - IsScrolling = true, - ShowMaximizeButton = true, - Size = Size.ExtraLarge, - Title = Localizer["ImportExcel"], - ShowFooter = false, - ShowCloseButton = false, - OnCloseAsync = async () => - { - await InvokeAsync(table.QueryAsync); - }, - }; - - Func>> preview = (a => ChannelService.PreviewAsync(a)); - Func, Task> import = (value => ChannelService.ImportChannelAsync(value)); - op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary - { - {nameof(ImportExcel.Import),import }, - {nameof(ImportExcel.Preview),preview }, - }); - await DialogService.Show(op); - - await InvokeAsync(table.QueryAsync); - } - - #endregion 导出 - - #region 清空 - - private async Task ClearChannelAsync() - { - try - { - await Task.Run(async () => - { - - await ChannelService.ClearChannelAsync(); - await InvokeAsync(async () => - { - await ToastService.Default(); - await InvokeAsync(table.QueryAsync); - }); - }); - } - catch (Exception ex) - { - await InvokeAsync(async () => - { - await ToastService.Warning(null, $"{ex.Message}"); - }); - } - - } - #endregion -} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/BusinessPropertyWithCacheIntervalScriptRazor.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/BusinessPropertyWithCacheIntervalScriptRazor.razor deleted file mode 100644 index f0feaed3e..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/BusinessPropertyWithCacheIntervalScriptRazor.razor +++ /dev/null @@ -1,64 +0,0 @@ -@using BootstrapBlazor.Components -@using ThingsGateway.Extension -@using ThingsGateway.Foundation -@using ThingsGateway.Admin.Application -@using ThingsGateway.Admin.Razor -@using ThingsGateway.Gateway.Application -@namespace ThingsGateway.Gateway.Razor - - - - - - - - @if (Model.PluginPropertyModel.Value is BusinessPropertyWithCacheIntervalScript businessProperty) - { - context) Field=@(context)> - - -
- - -
- -
-
-
- - - -
- -
-
-
- - - -
- -
-
- -
-
- } -
-
-
- - - diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/DeviceEditComponent.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/DeviceEditComponent.razor deleted file mode 100644 index 68935bf5f..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/DeviceEditComponent.razor +++ /dev/null @@ -1,275 +0,0 @@ -@namespace ThingsGateway.Gateway.Razor -@using ThingsGateway.Admin.Application -@using ThingsGateway.Admin.Razor -@using ThingsGateway.Gateway.Application -@inherits ComponentDefault - -@if (ValidateEnable) -{ - - - - - - - -
-
@Localizer["BasicInformation"]
-
-
-
- - - - - - -
-
@Localizer["Connection"]
-
-
-
- - - -
- - - - @if (PluginDcit.TryGetValue(name.Value, out var pluginOutput)) - { - if (pluginOutput.EducationPlugin) - { -
- @name.Text -
- PRO -
- } - else - { - @name.Value - } - } - else - { - @name.Value - } -
- -
-
-
- - - - -
-
@Localizer["Redundant"]
-
-
-
- - - -
- -
-
- -
-
-
- - - -
-
@Localizer["Remark"]
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - @if (context is DeviceSearchInput model) - { - @Render(model) - } - - - - -
- -@code { - AdminTable table; -} -@code { - RenderFragment Render(DeviceSearchInput model) => - @
-
- -
- - - - - - - - - -
- -
-
-
- - - - - - - -
- -
-
- -
-
- @if (Model.VariablePropertyModels != null) - { - @foreach (var a in Model.VariablePropertyModels) - { - - var item = a; - - var custom = VariablePropertyRenderFragments.TryGetValue(item.Key, out var renderFragment); - - if (!custom) - { - var has = VariablePropertyEditors.TryGetValue(item.Key, out var items); - if (has) - { - - - - - @{ - BusinessDeviceDict.TryGetValue(item.Key, out var items); - } -
- @($"{items.Name} - {PluginServiceUtil.GetFileNameAndTypeName(items?.PluginName).TypeName}") -
- -
- - - -} - - - - diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Variable/VariableEditComponent.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Variable/VariableEditComponent.razor.cs deleted file mode 100644 index 35818e06f..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Variable/VariableEditComponent.razor.cs +++ /dev/null @@ -1,148 +0,0 @@ -//------------------------------------------------------------------------------ -// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 -// 此代码版权(除特别声明外的代码)归作者本人Diego所有 -// 源代码使用协议遵循本仓库的开源协议及附加协议 -// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway -// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway -// 使用文档:https://thingsgateway.cn/ -// QQ群:605534569 -//------------------------------------------------------------------------------ - -using Microsoft.AspNetCore.Components.Forms; - -using System.Collections.Concurrent; - -using ThingsGateway.Gateway.Application; -using ThingsGateway.NewLife.Extension; - -using TouchSocket.Core; - -namespace ThingsGateway.Gateway.Razor; - -public partial class VariableEditComponent -{ - public long ChoiceBusinessDeviceId; - - [Parameter] - public bool BatchEditEnable { get; set; } - - [Parameter] - [EditorRequired] - [NotNull] - public Dictionary BusinessDeviceDict { get; set; } - - [Parameter] - [EditorRequired] - [NotNull] - public IEnumerable BusinessDevices { get; set; } - - [Parameter] - [EditorRequired] - [NotNull] - public Dictionary CollectDeviceDict { get; set; } - - [Parameter] - [EditorRequired] - [NotNull] - public IEnumerable CollectDevices { get; set; } - - [Parameter] - [EditorRequired] - [NotNull] - public Variable? Model { get; set; } - - [Parameter] - public Func OnValidSubmit { get; set; } - - public IEnumerable OtherMethods { get; set; } - - [Parameter] - public bool ValidateEnable { get; set; } - - [CascadingParameter] - private Func? OnCloseAsync { get; set; } - - [Inject] - [NotNull] - private IPluginService PluginService { get; set; } - - private ConcurrentDictionary>? VariablePropertyEditors { get; set; } = new(); - private ConcurrentDictionary? VariablePropertyRenderFragments { get; set; } = new(); - - public async Task ValidSubmit(EditContext editContext) - { - try - { - if (OnValidSubmit != null) - await OnValidSubmit.Invoke(); - if (OnCloseAsync != null) - await OnCloseAsync(); - await ToastService.Default(); - } - catch (Exception ex) - { - await ToastService.Warning(ex.Message); - } - } - - protected override async Task OnInitializedAsync() - { - await base.OnInitializedAsync(); - - Model.VariablePropertys ??= new(); - foreach (var item in Model.VariablePropertys) - { - await RefreshBusinessPropertyClickAsync(item.Key); - } - } - - private Task OnDeviceSelectedItemChanged(SelectedItem selectedItem) - { - try - { - if (CollectDeviceDict.TryGetValue(selectedItem.Value.ToLong(), out var device)) - { - var data = PluginService.GetDriverMethodInfos(device.PluginName); - OtherMethods = new List() { new SelectedItem(string.Empty, "none") }.Concat(data.Select(a => new SelectedItem(a.Name, a.Description))); - } - } - catch (Exception ex) - { - System.Console.WriteLine(ex); - } - return Task.CompletedTask; - } - - private async Task RefreshBusinessPropertyClickAsync(long id) - { - if (id > 0) - { - if (BusinessDeviceDict.TryGetValue(id, out var device)) - { - var data = PluginService.GetVariablePropertyTypes(device.PluginName); - Model.VariablePropertyModels ??= new(); - Model.VariablePropertyModels.AddOrUpdate(id, (a) => new ModelValueValidateForm() { Value = data.Model }, (a, b) => new ModelValueValidateForm() { Value = data.Model }); - VariablePropertyEditors.TryAdd(id, data.EditorItems); - - if (data.VariablePropertyUIType != null) - { - var component = new ThingsGatewayDynamicComponent(data.VariablePropertyUIType, new Dictionary - { - [nameof(VariableEditComponent.Model)] = Model, - [nameof(DeviceEditComponent.PluginPropertyEditorItems)] = data.EditorItems, - }); - VariablePropertyRenderFragments.AddOrUpdate(id, component.Render()); - } - - if (Model.VariablePropertys.TryGetValue(id, out var dict)) - { - PluginServiceUtil.SetModel(data.Model, dict); - } - } - } - else - { - await ToastService.Warning(null, Localizer["RefreshBusinessPropertyError"]); - } - } -} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Variable/VariableEditComponent.razor.css b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Variable/VariableEditComponent.razor.css deleted file mode 100644 index 785ca2710..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Variable/VariableEditComponent.razor.css +++ /dev/null @@ -1,4 +0,0 @@ -h6 { - font-size: 1rem; - font-weight: bold; -} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Variable/VariablePage.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Variable/VariablePage.razor deleted file mode 100644 index 022f8695e..000000000 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Variable/VariablePage.razor +++ /dev/null @@ -1,112 +0,0 @@ -@page "/gateway/variable" -@namespace ThingsGateway.Gateway.Razor -@using ThingsGateway.Admin.Application -@using ThingsGateway.Admin.Razor -@using ThingsGateway.Gateway.Application -@attribute [Authorize] -@attribute [RolePermission] -@inherits ComponentDefault - - -
- - - - - @if (context is VariableSearchInput model) - { - @Render(model) - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -@code { - AdminTable table; -} -@code { - RenderFragment Render(VariableSearchInput model) => - @
-
- -
-
- -
-
- -
-
; - -} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Channel/ChannelEditComponent.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Channel/ChannelEditComponent.razor new file mode 100644 index 000000000..c5f783f72 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Channel/ChannelEditComponent.razor @@ -0,0 +1,110 @@ +@namespace ThingsGateway.Gateway.Razor +@using ThingsGateway.Admin.Application +@using ThingsGateway.Admin.Razor +@using ThingsGateway.Foundation +@using ThingsGateway.Gateway.Application +@inherits ComponentDefault + +
+ + @if (ValidateEnable) + { + + + + + + + + +
+
@GatewayLocalizer["BasicInformation"]
+
+
+
+ + + + + + +
+ +
+
+
+ + + + + + + +
+
@GatewayLocalizer["Connection"]
+
+
+
+ + + + +
+ InvokeAsync(StateHasChanged)) /> +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
@GatewayLocalizer["Runtime"]
+
+
+
+ + + context.DeviceRuntimeCounts ) /> + +
+ + + +
+ + + diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatus1.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Channel/ChannelRuntimeInfo1.razor.cs similarity index 54% rename from src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatus1.razor.cs rename to src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Channel/ChannelRuntimeInfo1.razor.cs index 37824ba91..eab065773 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatus1.razor.cs +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Channel/ChannelRuntimeInfo1.razor.cs @@ -8,49 +8,60 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using ThingsGateway.Admin.Razor; using ThingsGateway.Gateway.Application; namespace ThingsGateway.Gateway.Razor; -public partial class DeviceStatus1 : ComponentDefault, IDisposable +public partial class ChannelRuntimeInfo1 : IDisposable { - [Parameter, EditorRequired] - public Device DeviceInput { get; set; } = new(); - - public bool Disposed { get; set; } + [Inject] + IStringLocalizer GatewayLocalizer { get; set; } [Parameter, EditorRequired] - public EventCallback DriverBaseOnClick { get; set; } + public ChannelRuntime ChannelRuntime { get; set; } + private string Name => $"{ChannelRuntime.ToString()} - {(ChannelRuntime.DeviceThreadManage == null ? "Task cancel" : "Task run")}"; - [Parameter, EditorRequired] - public IEnumerable? DriverBases { get; set; } - - public void Dispose() + private async Task RestartChannelAsync() { - Disposed = true; - GC.SuppressFinalize(this); + if (ChannelRuntime.DeviceThreadManage?.ChannelThreadManage != null) + await ChannelRuntime.DeviceThreadManage?.ChannelThreadManage.RestartChannelAsync(ChannelRuntime); + else + await GlobalData.ChannelThreadManage.RestartChannelAsync(ChannelRuntime); } + protected override void OnInitialized() { _ = RunTimerAsync(); base.OnInitialized(); } + private bool Disposed; private async Task RunTimerAsync() { while (!Disposed) { try { - await InvokeAsync(StateHasChanged); - await Task.Delay(1000); + await InvokeAsync(() => + { + StateHasChanged(); + }); } catch (Exception ex) { - System.Console.WriteLine(ex); + NewLife.Log.XTrace.WriteException(ex); + } + finally + { + await Task.Delay(5000); } } } + + public void Dispose() + { + Disposed = true; + GC.SuppressFinalize(this); + } } diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Channel/ChannelRuntimeInfo1.razor.css b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Channel/ChannelRuntimeInfo1.razor.css new file mode 100644 index 000000000..72d66714f --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Channel/ChannelRuntimeInfo1.razor.css @@ -0,0 +1,44 @@ +.text-h6 { + /* Headline 6 */ + font-family: Roboto !important; + font-style: normal !important; + font-weight: bold !important; + font-size: 1rem !important; + line-height: 1.875rem !important; + /* identical to box height */ + letter-spacing: 0.01em !important; +} + +.text-caption { + /* Caption-说明 */ + font-family: Roboto !important; + font-style: normal !important; + font-weight: 500 !important; + font-size: 0.75rem !important; + line-height: 1.125rem !important; +} + +.channel ::deep [data-bs-toggle=tooltip]:has(.form-label) { + width: 100px; + overflow: inherit; +} +.channel ::deep [data-bs-toggle=tooltip]:has(.is-display) { + overflow: inherit; +} + +.channel ::deep [data-bs-toggle=tooltip]:has(.form-control) { + width: 100%; + /*overflow: inherit;*/ +} + +.channel ::deep .btn { + color: var(--bs-card-title-color); +} + +.channel ::deep .form-label is-display { +} + +.channel ::deep .bb-editor .row { + --bs-gutter-y: 0.5rem; + --bs-gutter-x: 0.5rem; +} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor new file mode 100644 index 000000000..0bb456d79 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor @@ -0,0 +1,70 @@ +@inherits ThingsGatewayModuleComponentBase +@attribute [JSModuleAutoLoader("Components/QuickActions.razor.js")] +@namespace ThingsGateway.Gateway.Razor +@using ThingsGateway.Admin.Application +@using ThingsGateway.Admin.Razor +@using ThingsGateway.Gateway.Application + +
+ ShowType ) Value="ShowType" ValueChanged="OnShowTypeChanged" ShowLabel="true" /> + + + @GatewayLocalizer["DeviceList"] + + + + + + + + @if (SelectModel != null) + { + EditChannel(a,b,ItemChangedType.Add))> + + BatchEditChannel(a,b))> + + EditChannel(a,b,ItemChangedType.Update))> + + + + + + + + + + + + EditDevice(a,b,ItemChangedType.Add))> + + BatchEditDevice(a,b))> + + EditDevice(a,b,ItemChangedType.Update))> + + + + + + + + + + + + } + + + + + + +
+@code { + RenderFragment RenderTreeItem = (item) => + + @@item.ToString() ; + +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.cs new file mode 100644 index 000000000..52c136881 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.cs @@ -0,0 +1,1161 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Mapster; + +using Microsoft.AspNetCore.Components.Forms; + +using SqlSugar; + +using ThingsGateway.Admin.Razor; +using ThingsGateway.Gateway.Application; +using ThingsGateway.NewLife.Extension; +using ThingsGateway.NewLife.Json.Extension; + +namespace ThingsGateway.Gateway.Razor; + +public partial class ChannelDeviceTree : IDisposable +{ + [Inject] + [NotNull] + protected BlazorAppContext? AppContext { get; set; } + + [Inject] + [NotNull] + private NavigationManager? NavigationManager { get; set; } + + public string RouteName => NavigationManager.ToBaseRelativePath(NavigationManager.Uri); + + protected bool AuthorizeButton(string operate) + { + return AppContext.IsHasButtonWithRole(RouteName, operate); + } + + [Parameter] + public EventCallback ShowTypeChanged { get; set; } + [Parameter] + public ShowTypeEnum? ShowType { get; set; } + + private async Task OnShowTypeChanged(ShowTypeEnum? showType) + { + ShowType = showType; + if (showType != null && Module != null) + await Module!.InvokeVoidAsync("saveShowType", ShowType); + if (ShowTypeChanged.HasDelegate) + await ShowTypeChanged.InvokeAsync(showType); + } + protected override async Task InvokeInitAsync() + { + await base.InvokeInitAsync(); + var showType = await Module!.InvokeAsync("getShowType"); + await OnShowTypeChanged(showType); + } + + [Parameter] + public bool AutoRestartThread { get; set; } + + + [Inject] + private MaskService MaskService { get; set; } + private static string GetClass(ChannelDeviceTreeItem item) + { + if (item.TryGetChannelRuntime(out var channelRuntime)) + { + return channelRuntime.DeviceThreadManage != null ? "enable--text" : "disabled--text"; + + } + else if (item.TryGetDeviceRuntime(out var deviceRuntime)) + { + if (deviceRuntime.Driver?.DeviceThreadManage != null) + { + if (deviceRuntime.DeviceStatus == DeviceStatusEnum.OnLine) + { + return "green--text"; + } + else + { + return "red--text"; + } + } + else + { + return "disabled--text"; + } + } + return "enable--text"; + } + + [Inject] + DialogService DialogService { get; set; } + + [Inject] + WinBoxService WinBoxService { get; set; } + + + + [Inject] + [NotNull] + private IGatewayExportService? GatewayExportService { get; set; } + + #region 通道 + + async Task EditChannel(ContextMenuItem item, object value, ItemChangedType itemChangedType) + { + var op = new DialogOption() + { + IsScrolling = false, + ShowMaximizeButton = true, + Size = Size.ExtraLarge, + Title = item.Text, + ShowFooter = false, + ShowCloseButton = false, + }; + PluginTypeEnum? pluginTypeEnum = null; + Channel oneModel = null; + if (value is not ChannelDeviceTreeItem channelDeviceTreeItem) return; + + if (channelDeviceTreeItem.TryGetChannelRuntime(out var channelRuntime)) + { + oneModel = channelRuntime.Adapt(); + if (itemChangedType == ItemChangedType.Add) + { + oneModel.Id = 0; + oneModel.Name = $"{oneModel.Name}-Copy"; + } + } + else if (channelDeviceTreeItem.TryGetPluginName(out var pluginName)) + { + oneModel = new(); + oneModel.PluginName = pluginName; + } + else if (channelDeviceTreeItem.TryGetPluginType(out var pluginType)) + { + oneModel = new(); + pluginTypeEnum = pluginType; + } + else + { + return; + } + + op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + {nameof(ChannelEditComponent.OnValidSubmit), async () => + { + await GlobalData.ChannelRuntimeService.SaveChannelAsync(oneModel,itemChangedType); + }}, + {nameof(ChannelEditComponent.Model),oneModel }, + {nameof(ChannelEditComponent.ValidateEnable),true }, + {nameof(ChannelEditComponent.BatchEditEnable),false }, + {nameof(ChannelEditComponent.PluginType), pluginTypeEnum }, + }); + + await DialogService.Show(op); + + } + + async Task BatchEditChannel(ContextMenuItem item, object value) + { + + var op = new DialogOption() + { + IsScrolling = false, + ShowMaximizeButton = true, + Size = Size.ExtraLarge, + Title = item.Text, + ShowFooter = false, + ShowCloseButton = false, + }; + + Channel oldModel = null; + Channel oneModel = null; + IEnumerable? changedModels = null; + IEnumerable? models = null; + + if (value is not ChannelDeviceTreeItem channelDeviceTreeItem) return; + + if (channelDeviceTreeItem.TryGetChannelRuntime(out var channelRuntime)) + { + await EditChannel(item, value, ItemChangedType.Update); + return; + } + //批量编辑只有分类和插件名称节点 + else if (channelDeviceTreeItem.TryGetPluginName(out var pluginName)) + { + //插件名称 + var data = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + models = data.Where(a => a.Value.PluginName == pluginName).Select(a => a.Value); + oldModel = models.FirstOrDefault(); + changedModels = models; + oneModel = oldModel.Adapt(); + + } + else if (channelDeviceTreeItem.TryGetPluginType(out var pluginType)) + { + //采集 + + var data = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + models = data.Where(a => a.Value.PluginType == pluginType).Select(a => a.Value); + oldModel = models.FirstOrDefault(); + changedModels = models; + oneModel = oldModel.Adapt(); + + + } + else + { + return; + } + + + op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + {nameof(ChannelEditComponent.OnValidSubmit), async () => + { + await InvokeAsync(async ()=> + { + + await MaskService.Show(new MaskOption() + { + ChildContent = builder => builder.AddContent(0, new MarkupString("loading ....")) + }); + }); + await GlobalData.ChannelRuntimeService.BatchEditAsync(changedModels,oldModel,oneModel); + await InvokeAsync(async ()=> + { + + await MaskService.Close(); + StateHasChanged(); + }); + }}, + {nameof(ChannelEditComponent.Model),oneModel }, + {nameof(ChannelEditComponent.ValidateEnable),true }, + {nameof(ChannelEditComponent.BatchEditEnable),true }, + }); + + await DialogService.Show(op); + + } + + async Task DeleteCurrentChannel(ContextMenuItem item, object value) + { + IEnumerable modelIds = null; + if (value is not ChannelDeviceTreeItem channelDeviceTreeItem) return; + + if (channelDeviceTreeItem.TryGetChannelRuntime(out var channelRuntime)) + { + modelIds = new List { channelRuntime }; + } + else if (channelDeviceTreeItem.TryGetPluginName(out var pluginName)) + { + //插件名称 + var data = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + modelIds = data.Where(a => a.Value.PluginName == pluginName).Select(a => a.Value); + + } + else if (channelDeviceTreeItem.TryGetPluginType(out var pluginType)) + { + //采集 + + var data = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + modelIds = data.Where(a => a.Value.PluginType == pluginType).Select(a => a.Value); + + } + else + { + return; + + } + + try + { + var op = new SwalOption() + { + Title = GatewayLocalizer["DeleteConfirmTitle"], + BodyTemplate = (__builder) => + { + var data = modelIds.Select(a => a.Name).ToJsonNetString(); + __builder.OpenElement(0, "div"); + __builder.AddAttribute(1, "class", "w-100 "); + __builder.OpenElement(2, "span"); + __builder.AddAttribute(3, "class", "text-truncate px-2"); + __builder.AddAttribute(4, "style", "display: flow;"); + __builder.AddAttribute(5, "title", data); + __builder.AddContent(6, data); + __builder.CloseElement(); + __builder.CloseElement(); + } + }; + var ret = await SwalService.ShowModal(op); + if (ret) + { + await Task.Run(async () => + { + await InvokeAsync(async () => + { + await MaskService.Show(new MaskOption() + { + ChildContent = builder => builder.AddContent(0, new MarkupString("loading ....")) + }); + }); + await GlobalData.ChannelRuntimeService.DeleteChannelAsync(modelIds.Select(a => a.Id)); + await InvokeAsync(async () => + { + await MaskService.Close(); + StateHasChanged(); + }); + }); + } + + } + catch (Exception ex) + { + await InvokeAsync(async () => + { + await ToastService.Warning(null, $"{ex.Message}"); + }); + } + + } + async Task DeleteAllChannel(ContextMenuItem item, object value) + { + try + { + var op = new SwalOption() + { + Title = GatewayLocalizer["DeleteConfirmTitle"], + BodyTemplate = (__builder) => + { + __builder.OpenElement(0, "div"); + __builder.AddAttribute(1, "class", "w-100 "); + __builder.OpenElement(2, "span"); + __builder.AddAttribute(3, "class", "text-truncate px-2"); + __builder.AddAttribute(4, "style", "display: flow;"); + __builder.AddAttribute(5, "title", GatewayLocalizer["AllChannel"]); + __builder.AddContent(6, GatewayLocalizer["AllChannel"]); + __builder.CloseElement(); + __builder.CloseElement(); + } + }; + var ret = await SwalService.ShowModal(op); + if (ret) + { + await Task.Run(async () => + { + await InvokeAsync(async () => + { + await MaskService.Show(new MaskOption() + { + ChildContent = builder => builder.AddContent(0, new MarkupString("loading ....")) + }); + }); + var key = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + await GlobalData.ChannelRuntimeService.DeleteChannelAsync(key.Select(a => a.Key)); + await InvokeAsync(async () => + { + await MaskService.Close(); + StateHasChanged(); + }); + }); + } + + } + catch (Exception ex) + { + await InvokeAsync(async () => + { + await ToastService.Warning(null, $"{ex.Message}"); + }); + } + + } + + + async Task ExportCurrentChannel(ContextMenuItem item, object value) + { + if (value is not ChannelDeviceTreeItem channelDeviceTreeItem) return; + + if (channelDeviceTreeItem.TryGetChannelRuntime(out var channelRuntime)) + { + await GatewayExportService.OnChannelExport(new() { QueryPageOptions = new(), DeviceId = channelRuntime.Id }); + } + else if (channelDeviceTreeItem.TryGetPluginName(out var pluginName)) + { + //插件名称 + await GatewayExportService.OnChannelExport(new() { QueryPageOptions = new(), PluginName = pluginName }); + } + else if (channelDeviceTreeItem.TryGetPluginType(out var pluginType)) + { + await GatewayExportService.OnChannelExport(new ExportFilter() { QueryPageOptions = new(), PluginType = pluginType }); + } + else + { + return; + } + + // 返回 true 时自动弹出提示框 + await ToastService.Default(); + } + async Task ExportAllChannel(ContextMenuItem item, object value) + { + await GatewayExportService.OnChannelExport(new() { QueryPageOptions = new() }); + + // 返回 true 时自动弹出提示框 + await ToastService.Default(); + } + + + async Task ImportChannel(ContextMenuItem item, object value) + { + var op = new DialogOption() + { + IsScrolling = true, + ShowMaximizeButton = true, + Size = Size.ExtraLarge, + Title = item.Text, + ShowFooter = false, + ShowCloseButton = false, + OnCloseAsync = async () => + { + await InvokeAsync(StateHasChanged); + //await InvokeAsync(table.QueryAsync); + }, + }; + + Func>> preview = (a => GlobalData.ChannelRuntimeService.PreviewAsync(a)); + Func, Task> import = (async value => + { + await InvokeAsync(async () => + { + await MaskService.Show(new MaskOption() + { + ChildContent = builder => builder.AddContent(0, new MarkupString("loading ....")) + }); + }); + await GlobalData.ChannelRuntimeService.ImportChannelAsync(value); + await InvokeAsync(async () => + { + await MaskService.Close(); + StateHasChanged(); + }); + + }); + op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + {nameof(ImportExcel.Import),import }, + {nameof(ImportExcel.Preview),preview }, + }); + await DialogService.Show(op); + + //await InvokeAsync(table.QueryAsync); + } + + + #endregion + + #region 设备 + + async Task EditDevice(ContextMenuItem item, object value, ItemChangedType itemChangedType) + { + var op = new DialogOption() + { + IsScrolling = false, + ShowMaximizeButton = true, + Size = Size.ExtraLarge, + Title = item.Text, + ShowFooter = false, + ShowCloseButton = false, + }; + Device oneModel = null; + if (value is not ChannelDeviceTreeItem channelDeviceTreeItem) return; + + if (channelDeviceTreeItem.TryGetDeviceRuntime(out var deviceRuntime)) + { + oneModel = deviceRuntime.Adapt(); + if (itemChangedType == ItemChangedType.Add) + { + oneModel.Id = 0; + oneModel.Name = $"{oneModel.Name}-Copy"; + } + } + else if (channelDeviceTreeItem.TryGetChannelRuntime(out var channelRuntime)) + { + oneModel = new(); + oneModel.ChannelId = channelRuntime.Id; + } + else + { + return; + } + + op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + {nameof(DeviceEditComponent.OnValidSubmit), async () => + { + await GlobalData.DeviceRuntimeService.SaveDeviceAsync(oneModel,itemChangedType, AutoRestartThread); + }}, + {nameof(DeviceEditComponent.Model),oneModel }, + {nameof(DeviceEditComponent.ValidateEnable),true }, + {nameof(DeviceEditComponent.BatchEditEnable),false }, + }); + + await DialogService.Show(op); + + } + + async Task BatchEditDevice(ContextMenuItem item, object value) + { + + var op = new DialogOption() + { + IsScrolling = false, + ShowMaximizeButton = true, + Size = Size.ExtraLarge, + Title = item.Text, + ShowFooter = false, + ShowCloseButton = false, + }; + + Device oldModel = null; + Device oneModel = null; + IEnumerable? changedModels = null; + IEnumerable? models = null; + + if (value is not ChannelDeviceTreeItem channelDeviceTreeItem) return; + + if (channelDeviceTreeItem.TryGetDeviceRuntime(out var deviceRuntime)) + { + await EditDevice(item, value, ItemChangedType.Update); + return; + } + else if (channelDeviceTreeItem.TryGetChannelRuntime(out var channelRuntime)) + { + //插件名称 + var data = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + models = data.Select(a => a.Value).Where(a => a.ChannelId == channelRuntime.Id); + oldModel = models.FirstOrDefault(); + changedModels = models; + oneModel = oldModel.Adapt(); + } + //批量编辑只有分类和插件名称节点 + else if (channelDeviceTreeItem.TryGetPluginName(out var pluginName)) + { + //插件名称 + var data = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + models = data.Select(a => a.Value).Where(a => a.PluginName == pluginName); ; + oldModel = models.FirstOrDefault(); + changedModels = models; + oneModel = oldModel.Adapt(); + + } + else if (channelDeviceTreeItem.TryGetPluginType(out var pluginType)) + { + //采集 + + var data = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + models = data.Select(a => a.Value).Where(a => a.PluginType == pluginType); ; + oldModel = models.FirstOrDefault(); + changedModels = models; + oneModel = oldModel.Adapt(); + + + } + else + { + return; + } + + + op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + {nameof(DeviceEditComponent.OnValidSubmit), async () => + { + await InvokeAsync(async () => + { + await MaskService.Show(new MaskOption() + { + ChildContent = builder => builder.AddContent(0, new MarkupString("loading ....")) + }); + }); + await GlobalData.DeviceRuntimeService.BatchEditAsync(changedModels,oldModel,oneModel,AutoRestartThread); + await InvokeAsync(async () => + { + await MaskService.Close(); + await OnClickSearch(SearchText); + }); + }}, + {nameof(DeviceEditComponent.Model),oneModel }, + {nameof(DeviceEditComponent.ValidateEnable),true }, + {nameof(DeviceEditComponent.BatchEditEnable),true }, + }); + + await DialogService.Show(op); + + } + + async Task DeleteCurrentDevice(ContextMenuItem item, object value) + { + IEnumerable modelIds = null; + if (value is not ChannelDeviceTreeItem channelDeviceTreeItem) return; + + if (channelDeviceTreeItem.TryGetDeviceRuntime(out var deviceRuntime)) + { + modelIds = new List { deviceRuntime }; + } + else if (channelDeviceTreeItem.TryGetChannelRuntime(out var channelRuntime)) + { + var data = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + modelIds = data.Select(a => a.Value).Where(a => a.ChannelId == channelRuntime.Id); + } + else if (channelDeviceTreeItem.TryGetPluginName(out var pluginName)) + { + //插件名称 + var data = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + modelIds = data.Select(a => a.Value).Where(a => a.PluginName == pluginName); + + } + else if (channelDeviceTreeItem.TryGetPluginType(out var pluginType)) + { + //采集 + var data = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + modelIds = data.Select(a => a.Value).Where(a => a.PluginType == pluginType); + } + else + { + return; + + } + + try + { + var op = new SwalOption() + { + Title = GatewayLocalizer["DeleteConfirmTitle"], + BodyTemplate = (__builder) => + { + var data = modelIds.Select(a => a.Name).ToJsonNetString(); + __builder.OpenElement(0, "div"); + __builder.AddAttribute(1, "class", "w-100 "); + __builder.OpenElement(2, "span"); + __builder.AddAttribute(3, "class", "text-truncate px-2"); + __builder.AddAttribute(4, "style", "display: flow;"); + __builder.AddAttribute(5, "title", data); + __builder.AddContent(6, data); + __builder.CloseElement(); + __builder.CloseElement(); + } + }; + var ret = await SwalService.ShowModal(op); + if (ret) + { + await Task.Run(async () => + { + await InvokeAsync(async () => + { + await MaskService.Show(new MaskOption() + { + ChildContent = builder => builder.AddContent(0, new MarkupString("loading ....")) + }); + }); + await GlobalData.DeviceRuntimeService.DeleteDeviceAsync(modelIds.Select(a => a.Id), AutoRestartThread); + await InvokeAsync(async () => + { + await MaskService.Close(); + await OnClickSearch(SearchText); + }); + }); + } + + } + catch (Exception ex) + { + await InvokeAsync(async () => + { + await ToastService.Warning(null, $"{ex.Message}"); + }); + } + + } + + async Task DeleteAllDevice(ContextMenuItem item, object value) + { + try + { + var op = new SwalOption() + { + Title = GatewayLocalizer["DeleteConfirmTitle"], + BodyTemplate = (__builder) => + { + __builder.OpenElement(0, "div"); + __builder.AddAttribute(1, "class", "w-100 "); + __builder.OpenElement(2, "span"); + __builder.AddAttribute(3, "class", "text-truncate px-2"); + __builder.AddAttribute(4, "style", "display: flow;"); + __builder.AddAttribute(5, "title", GatewayLocalizer["AllDevice"]); + __builder.AddContent(6, GatewayLocalizer["AllDevice"]); + __builder.CloseElement(); + __builder.CloseElement(); + } + }; + var ret = await SwalService.ShowModal(op); + if (ret) + { + await Task.Run(async () => + { + await InvokeAsync(async () => + { + await MaskService.Show(new MaskOption() + { + ChildContent = builder => builder.AddContent(0, new MarkupString("loading ....")) + }); + }); + var data = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + + await GlobalData.DeviceRuntimeService.DeleteDeviceAsync(data.Select(a => a.Key), AutoRestartThread); + await InvokeAsync(async () => + { + await MaskService.Close(); + await OnClickSearch(SearchText); + }); + }); + } + + } + catch (Exception ex) + { + await InvokeAsync(async () => + { + await ToastService.Warning(null, $"{ex.Message}"); + }); + } + + } + + async Task ExportCurrentDevice(ContextMenuItem item, object value) + { + if (value is not ChannelDeviceTreeItem channelDeviceTreeItem) return; + + if (channelDeviceTreeItem.TryGetDeviceRuntime(out var deviceRuntime)) + { + await GatewayExportService.OnDeviceExport(new() { QueryPageOptions = new(), DeviceId = deviceRuntime.Id }); + } + else if (channelDeviceTreeItem.TryGetChannelRuntime(out var channelRuntime)) + { + await GatewayExportService.OnDeviceExport(new() { QueryPageOptions = new(), ChannelId = channelRuntime.Id }); + } + else if (channelDeviceTreeItem.TryGetPluginName(out var pluginName)) + { + //插件名称 + await GatewayExportService.OnDeviceExport(new() { QueryPageOptions = new(), PluginName = pluginName }); + } + else if (channelDeviceTreeItem.TryGetPluginType(out var pluginType)) + { + //采集 + await GatewayExportService.OnDeviceExport(new() { QueryPageOptions = new(), PluginType = pluginType }); + } + else + { + return; + } + + // 返回 true 时自动弹出提示框 + await ToastService.Default(); + } + async Task ExportAllDevice(ContextMenuItem item, object value) + { + await GatewayExportService.OnDeviceExport(new() { QueryPageOptions = new() }); + + // 返回 true 时自动弹出提示框 + await ToastService.Default(); + } + + async Task ImportDevice(ContextMenuItem item, object value) + { + var op = new DialogOption() + { + IsScrolling = true, + ShowMaximizeButton = true, + Size = Size.ExtraLarge, + Title = item.Text, + ShowFooter = false, + ShowCloseButton = false, + OnCloseAsync = async () => + { + await InvokeAsync(StateHasChanged); + }, + }; + + Func>> preview = (a => GlobalData.DeviceRuntimeService.PreviewAsync(a)); + Func, Task> import = (async value => + { + await InvokeAsync(async () => + { + + await MaskService.Show(new MaskOption() + { + ChildContent = builder => builder.AddContent(0, new MarkupString("loading ....")) + }); + }); + + await GlobalData.DeviceRuntimeService.ImportDeviceAsync(value, AutoRestartThread); + await InvokeAsync(async () => + { + + await MaskService.Close(); + await OnClickSearch(SearchText); + }); + + }); + op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + {nameof(ImportExcel.Import),import }, + {nameof(ImportExcel.Preview),preview }, + }); + await DialogService.Show(op); + + //await InvokeAsync(table.QueryAsync); + } + + + #endregion + + [Inject] + SwalService SwalService { get; set; } + [Inject] + ToastService ToastService { get; set; } + + + + [Parameter] + [NotNull] + public ChannelDeviceTreeItem Value { get; set; } + + [Parameter] + public Func ChannelDeviceChanged { get; set; } + + [NotNull] + private List> Items { get; set; } + + [Inject] + private IStringLocalizer RazorLocalizer { get; set; } + + [Inject] + private IStringLocalizer Localizer { get; set; } + + [Inject] + private IStringLocalizer GatewayLocalizer { get; set; } + + [Inject] + private IStringLocalizer AdminLocalizer { get; set; } + + private async Task OnTreeItemClick(TreeViewItem item) + { + if (Value != item.Value) + { + Value = item.Value; + if (ChannelDeviceChanged != null) + { + await ChannelDeviceChanged.Invoke(item.Value); + } + } + else + { + Value = item.Value; + } + + } + + private List> ZItem; + + + private ChannelDeviceTreeItem CollectItem = new() { ChannelDevicePluginType = ChannelDevicePluginTypeEnum.PluginType, PluginType = PluginTypeEnum.Collect }; + private ChannelDeviceTreeItem BusinessItem = new() { ChannelDevicePluginType = ChannelDevicePluginTypeEnum.PluginType, PluginType = PluginTypeEnum.Business }; + private ChannelDeviceTreeItem UnknownItem = new() { ChannelDevicePluginType = ChannelDevicePluginTypeEnum.PluginType, PluginType = null }; + + private TreeViewItem BusinessTreeViewItem; + protected override async Task OnInitializedAsync() + { + BusinessTreeViewItem = new TreeViewItem(UnknownItem) + { + Text = GatewayLocalizer["Unknown"], + IsActive = Value == UnknownItem, + IsExpand = true, + }; + ZItem = new List>() {new TreeViewItem(CollectItem) + { + Text = GatewayLocalizer["Collect"], + IsActive = Value == CollectItem, + IsExpand = true, + }, + new TreeViewItem(BusinessItem) + { + Text = GatewayLocalizer["Business"], + IsActive = Value == BusinessItem, + IsExpand = true, + }}; + + var channels = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + + ZItem[0].Items = ResourceUtil.BuildTreeItemList(channels.Where(a => a.Value.IsCollect == true).Select(a => a.Value), new List { Value }, RenderTreeItem); + ZItem[1].Items = ResourceUtil.BuildTreeItemList(channels.Where(a => a.Value.IsCollect == false).Select(a => a.Value), new List { Value }, RenderTreeItem); + var item2 = ResourceUtil.BuildTreeItemList(channels.Where(a => a.Value.IsCollect == null).Select(a => a.Value), new List { Value }, RenderTreeItem); + if (item2.Count > 0) + { + BusinessTreeViewItem.Items = item2; + if (ZItem.Count >= 2) + { + + } + else + { + ZItem.Add(BusinessTreeViewItem); + } + + } + else + { + if (ZItem.Count >= 2) + { + ZItem.Remove(BusinessTreeViewItem); + } + else + { + } + } + + Items = ZItem; + context = ExecutionContext.Capture(); + ChannelRuntimeDispatchService.Subscribe(Refresh); + DeviceRuntimeDispatchService.Subscribe(Refresh); + await base.OnInitializedAsync(); + } + + private ExecutionContext? context; + + private Foundation.WaitLock WaitLock = new(); + + protected override void OnInitialized() + { + _ = Task.Run(async () => + { + while (!Disposed) + { + try + { + await Notify(); + } + catch + { + + } + finally + { + await Task.Delay(5000); + } + } + }); + base.OnInitialized(); + } + + private async Task Notify() + { + if (WaitLock.Waited) return; + try + { + await WaitLock.WaitAsync(); + await Task.Delay(500); + var current = ExecutionContext.Capture(); + try + { + ExecutionContext.Restore(context); + await InvokeAsync(async () => + { + await OnClickSearch(SearchText); + StateHasChanged(); + }); + } + finally + { + ExecutionContext.Restore(current); + } + } + finally + { + WaitLock.Release(); + } + } + private async Task Refresh(DispatchEntry entry) + { + await Notify(); + } + private async Task Refresh(DispatchEntry entry) + { + await Notify(); + } + [Inject] + private IDispatchService DeviceRuntimeDispatchService { get; set; } + [Inject] + private IDispatchService ChannelRuntimeDispatchService { get; set; } + private string SearchText; + + private async Task>> OnClickSearch(string searchText) + { + SearchText = searchText; + + var channels = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + if (searchText.IsNullOrWhiteSpace()) + { + var items = channels.Select(a => a.Value).WhereIF(!searchText.IsNullOrEmpty(), a => a.Name.Contains(searchText)); + + ZItem[0].Items = ResourceUtil.BuildTreeItemList(items.Where(a => a.IsCollect == true), new List { Value }, RenderTreeItem, items: ZItem[0].Items); + ZItem[1].Items = ResourceUtil.BuildTreeItemList(items.Where(a => a.IsCollect == false), new List { Value }, RenderTreeItem, items: ZItem[1].Items); + + var item2 = ResourceUtil.BuildTreeItemList(items.Where(a => a.IsCollect == null), new List { Value }, RenderTreeItem); + if (item2.Count > 0) + { + BusinessTreeViewItem.Items = item2; + if (ZItem.Count >= 2) + { + + } + else + { + ZItem.Add(BusinessTreeViewItem); + } + + } + else + { + if (ZItem.Count >= 2) + { + ZItem.Remove(BusinessTreeViewItem); + } + else + { + } + } + Items = ZItem; + return Items; + } + else + { + var items = channels.Select(a => a.Value).WhereIF(!searchText.IsNullOrEmpty(), a => a.Name.Contains(searchText)); + var devices = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + var deviceItems = devices.Select(a => a.Value).WhereIF(!searchText.IsNullOrEmpty(), a => a.Name.Contains(searchText)); + + Dictionary> collectChannelDevices = new(); + Dictionary> businessChannelDevices = new(); + Dictionary> otherChannelDevices = new(); + + foreach (var item in items) + { + if (item.PluginType == PluginTypeEnum.Collect) + collectChannelDevices.Add(item, new()); + else if (item.PluginType == PluginTypeEnum.Collect) + businessChannelDevices.Add(item, new()); + else + otherChannelDevices.Add(item, new()); + + } + foreach (var item in deviceItems.Where(a => a.IsCollect == true)) + { + if (collectChannelDevices.TryGetValue(item.ChannelRuntime, out var list)) + { + list.Add(item); + } + else + { + collectChannelDevices[item.ChannelRuntime] = new List { item }; + } + } + foreach (var item in deviceItems.Where(a => a.IsCollect == false)) + { + if (businessChannelDevices.TryGetValue(item.ChannelRuntime, out var list)) + { + list.Add(item); + } + else + { + businessChannelDevices[item.ChannelRuntime] = new List { item }; + } + } + foreach (var item in deviceItems.Where(a => a.IsCollect == null)) + { + if (otherChannelDevices.TryGetValue(item.ChannelRuntime, out var list)) + { + list.Add(item); + } + else + { + otherChannelDevices[item.ChannelRuntime] = new List { item }; + } + } + + ZItem[0].Items = collectChannelDevices.BuildTreeItemList(new List { Value }, RenderTreeItem, items: ZItem[0].Items); + ZItem[1].Items = businessChannelDevices.BuildTreeItemList(new List { Value }, RenderTreeItem, items: ZItem[1].Items); + var item2 = otherChannelDevices.BuildTreeItemList(new List { Value }, RenderTreeItem); + if (item2.Count > 0) + { + BusinessTreeViewItem.Items = item2; + if (ZItem.Count >= 2) + { + + } + else + { + ZItem.Add(BusinessTreeViewItem); + } + + } + else + { + if (ZItem.Count >= 2) + { + ZItem.Remove(BusinessTreeViewItem); + } + else + { + } + } + + Items = ZItem; + return Items; + } + + } + + private static bool ModelEqualityComparer(ChannelDeviceTreeItem x, ChannelDeviceTreeItem y) => x.Equals(y); + private bool Disposed; + public void Dispose() + { + Disposed = true; + context?.Dispose(); + ChannelRuntimeDispatchService.UnSubscribe(Refresh); + DeviceRuntimeDispatchService.UnSubscribe(Refresh); + GC.SuppressFinalize(this); + } + + ChannelDeviceTreeItem? SelectModel = default; + + Task OnBeforeShowCallback(object? item) + { + if (item is ChannelDeviceTreeItem channelDeviceTreeItem) + { + SelectModel = channelDeviceTreeItem; + } + else + { + SelectModel = null; + } + return Task.CompletedTask; + } + + +} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.css b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.css new file mode 100644 index 000000000..1634c2acf --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTree.razor.css @@ -0,0 +1,16 @@ + +.listtree-view { + height: calc(100% - 50px); +} + .listtree-view ::deep .bb-cm-zone { + height: calc(100% - 50px); + margin-bottom: 2px; + } + .listtree-view ::deep .tree-view { + --bb-tree-search-height: 32px; + min-height: 300px; + height: calc(100% - 50px); + } +.deviceEnable--text { + color: var(--bs-body-color); +} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTreeItem.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTreeItem.cs new file mode 100644 index 000000000..264ea4505 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ChannelDeviceTreeItem.cs @@ -0,0 +1,152 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + + + + +using ThingsGateway.Gateway.Application; + +namespace ThingsGateway.Gateway.Razor; +public enum ChannelDevicePluginTypeEnum +{ + PluginType, + PluginName, + Channel, + Device +} +public class ChannelDeviceTreeItem : IEqualityComparer +{ + public ChannelDevicePluginTypeEnum ChannelDevicePluginType { get; set; } + + public DeviceRuntime DeviceRuntime { get; set; } + public ChannelRuntime ChannelRuntime { get; set; } + public string PluginName { get; set; } + public PluginTypeEnum? PluginType { get; set; } + + public override bool Equals(object? obj) + { + if (obj is ChannelDeviceTreeItem item) + { + if (ChannelDevicePluginType == item.ChannelDevicePluginType) + { + if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.Device) + { + return DeviceRuntime == item.DeviceRuntime; + } + else if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.PluginType) + { + return PluginType == item.PluginType; + + } + else if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.Channel) + { + return ChannelRuntime == item.ChannelRuntime; + } + else if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.PluginName) + { + return PluginName == item.PluginName; + } + } + + } + return false; + + } + + public override int GetHashCode() + { + return HashCode.Combine(ChannelDevicePluginType, DeviceRuntime, ChannelRuntime, PluginName, PluginType); + } + public bool TryGetDeviceRuntime(out DeviceRuntime deviceRuntime) + { + if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.Device && DeviceRuntime?.Id > 0) + { + deviceRuntime = DeviceRuntime; + return true; + } + else + { + deviceRuntime = null; + return false; + } + } + public bool TryGetPluginName(out string pluginName) + { + if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.PluginName) + { + pluginName = PluginName; + return true; + } + else + { + pluginName = default; + return false; + } + } + + public bool TryGetPluginType(out PluginTypeEnum? pluginType) + { + if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.PluginType) + { + pluginType = PluginType; + return true; + } + else + { + pluginType = default; + return false; + } + } + public bool TryGetChannelRuntime(out ChannelRuntime channelRuntime) + { + if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.Channel && ChannelRuntime?.Id > 0) + { + channelRuntime = ChannelRuntime; + return true; + } + else + { + channelRuntime = null; + return false; + } + } + + + public override string ToString() + { + if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.Device) + { + return DeviceRuntime?.ToString(); + } + else if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.Channel) + { + return ChannelRuntime?.ToString(); + } + else if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.PluginName) + { + return PluginName; + } + else if (ChannelDevicePluginType == ChannelDevicePluginTypeEnum.PluginType) + { + return PluginType.ToString(); + } + return base.ToString(); + } + + public bool Equals(ChannelDeviceTreeItem? x, ChannelDeviceTreeItem? y) + { + return y.Equals(x); + } + + public int GetHashCode([DisallowNull] ChannelDeviceTreeItem obj) + { + return HashCode.Combine(obj.ChannelDevicePluginType, obj.DeviceRuntime, obj.ChannelRuntime, obj.PluginName, obj.PluginType); + } +} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceEditComponent.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceEditComponent.razor new file mode 100644 index 000000000..32482d1c8 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceEditComponent.razor @@ -0,0 +1,140 @@ +@namespace ThingsGateway.Gateway.Razor +@using ThingsGateway.Admin.Application +@using ThingsGateway.Admin.Razor +@using ThingsGateway.Foundation +@using ThingsGateway.Gateway.Application +@inherits ComponentDefault + +
+ + @if (ValidateEnable) + { + + + + + + + + + + +
+
@GatewayLocalizer["BasicInformation"]
+
+
+
+ + + + + + + + + + + +
+
@GatewayLocalizer["Connection"]
+
+
+
+ + + +
+ + + { + value.RedundantDeviceId = default; + return Task.CompletedTask; + })> + + @{ + string device = "none"; + if (value.RedundantDeviceId != null) + { + if (value.RedundantDeviceId.HasValue) + if (GlobalData.ReadOnlyDevices.TryGetValue(value.RedundantDeviceId.Value, out var deviceRuntime)) + device = deviceRuntime?.Name ?? device; + } + @device + } + + +
+
+ +
+ + + + + + + +
+
@GatewayLocalizer["Remark"]
+
+
+
+ + + + + + +
+ +
+
+ @if (!BatchEditEnable) + { + + @if (PluginPropertyModel != null && PluginPropertyEditorItems != null) + { + if (PluginPropertyRenderFragment == null) + { + + } + else + { + @PluginPropertyRenderFragment + } + } + + } + +
+ + +
+ + } +
\ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceEditComponent.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceEditComponent.razor.cs new file mode 100644 index 000000000..12d4fcaf3 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceEditComponent.razor.cs @@ -0,0 +1,159 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Web; + +using ThingsGateway.Extension.Generic; +using ThingsGateway.Gateway.Application; +using ThingsGateway.NewLife.Extension; + +namespace ThingsGateway.Gateway.Razor; + +public partial class DeviceEditComponent +{ + [Inject] + IStringLocalizer GatewayLocalizer { get; set; } + + [Parameter] + public bool BatchEditEnable { get; set; } + + [Parameter] + [EditorRequired] + public Device Model { get; set; } + + [Parameter] + public Func OnValidSubmit { get; set; } + + [Parameter] + public bool ValidateEnable { get; set; } + + [CascadingParameter] + private Func? OnCloseAsync { get; set; } + private IEnumerable _channelItems; + + protected override async Task OnParametersSetAsync() + { + var channels = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + _channelItems = channels.Select(a => a.Value).BuildChannelSelectList(); + base.OnParametersSet(); + } + + public ModelValueValidateForm PluginPropertyModel; + + public async Task ValidSubmit(EditContext editContext) + { + try + { + var result = (!PluginServiceUtil.HasDynamicProperty(PluginPropertyModel.Value)) || (PluginPropertyModel.ValidateForm?.Validate() != false); + if (!result) return; + + Model.DevicePropertys = PluginServiceUtil.SetDict(PluginPropertyModel.Value); + + if (OnValidSubmit != null) + await OnValidSubmit.Invoke(); + if (OnCloseAsync != null) + await OnCloseAsync(); + await ToastService.Default(); + } + catch (Exception ex) + { + await ToastService.Warn(ex); + } + } + + [Inject] + private IStringLocalizer ChannelLocalizer { get; set; } + private async Task AddChannel(MouseEventArgs args) + { + Channel oneModel = new(); + + var op = new DialogOption() + { + IsScrolling = false, + ShowMaximizeButton = true, + Size = Size.ExtraLarge, + Title = ChannelLocalizer["SaveChannel"], + ShowFooter = false, + ShowCloseButton = false, + }; + op.Component = BootstrapDynamicComponent.CreateComponent(new Dictionary + { + {nameof(ChannelEditComponent.OnValidSubmit), async () => + { + await GlobalData.ChannelRuntimeService.SaveChannelAsync(oneModel,ItemChangedType.Add); + OnParametersSet(); + }}, + {nameof(ChannelEditComponent.Model),oneModel }, + {nameof(ChannelEditComponent.ValidateEnable),true }, + {nameof(ChannelEditComponent.BatchEditEnable),false }, + {nameof(ChannelEditComponent.PluginType), null }, + }); + + await DialogService.Show(op); + } + + private static async Task> OnRedundantDevicesQuery(VirtualizeQueryOption option, Device device) + { + var ret = new QueryData() + { + IsSorted = false, + IsFiltered = false, + IsAdvanceSearch = false, + IsSearch = !option.SearchText.IsNullOrWhiteSpace() + }; + + var devices = await GlobalData.GetCurrentUserDevices().ConfigureAwait(false); + var pluginName = GlobalData.ReadOnlyDevices.TryGetValue(device.ChannelId, out var channel) ? channel.PluginName : string.Empty; + var items = new List() { new SelectedItem(string.Empty, "none") }.Concat(devices.WhereIf(!option.SearchText.IsNullOrWhiteSpace(), a => a.Value.Name.Contains(option.SearchText)) + .Where(a => a.Value.PluginName == pluginName && a.Value.Id != device.Id).Select(a => a.Value).BuildDeviceSelectList() + ); + + ret.TotalCount = items.Count(); + ret.Items = items; + return ret; + } + + internal IEnumerable PluginPropertyEditorItems; + private RenderFragment PluginPropertyRenderFragment; + + private async Task OnChannelChanged(SelectedItem selectedItem) + { + try + { + var pluginName = GlobalData.ReadOnlyChannels.TryGetValue(selectedItem.Value.ToLong(), out var channel) ? channel.PluginName : string.Empty; + + var data = GlobalData.PluginService.GetDriverPropertyTypes(pluginName); + PluginPropertyModel = new ModelValueValidateForm() { Value = data.Model }; + PluginPropertyEditorItems = data.EditorItems; + if (data.PropertyUIType != null) + { + var component = new BootstrapDynamicComponent(data.PropertyUIType, new Dictionary + { + [nameof(IPropertyUIBase.Id)] = Model.Id.ToString(), + [nameof(IPropertyUIBase.CanWrite)] = true, + [nameof(IPropertyUIBase.Model)] = PluginPropertyModel, + [nameof(IPropertyUIBase.PluginPropertyEditorItems)] = PluginPropertyEditorItems, + }); + PluginPropertyRenderFragment = component.Render(); + } + if (Model.DevicePropertys?.Count > 0) + { + PluginServiceUtil.SetModel(PluginPropertyModel.Value, Model.DevicePropertys); + } + } + catch (Exception ex) + { + await ToastService.Warn(ex); + } + } + + +} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceEditComponent.razor.css b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceEditComponent.razor.css new file mode 100644 index 000000000..6da53b22f --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceEditComponent.razor.css @@ -0,0 +1,30 @@ +h6 { + font-size: 1rem; + font-weight: bold; +} +.device ::deep .tabs-body-content { + overflow-x: hidden; +} +.device ::deep .bb-editor .row { + --bs-gutter-y: 0.5rem; + --bs-gutter-x: 0.5rem; +} +.text-h6 { + /* Headline 6 */ + font-family: Roboto !important; + font-style: normal !important; + font-weight: bold !important; + font-size: 1rem !important; + line-height: 1.875rem !important; + /* identical to box height */ + letter-spacing: 0.01em !important; +} + +.text-caption { + /* Caption-说明 */ + font-family: Roboto !important; + font-style: normal !important; + font-weight: 500 !important; + font-size: 0.75rem !important; + line-height: 1.125rem !important; +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo.razor new file mode 100644 index 000000000..801c84f27 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo.razor @@ -0,0 +1,17 @@ +@namespace ThingsGateway.Gateway.Razor +@using ThingsGateway.Admin.Application +@using ThingsGateway.Debug +@using ThingsGateway.Gateway.Application + + + + diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/BusinessDevicePage.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo.razor.cs similarity index 75% rename from src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/BusinessDevicePage.cs rename to src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo.razor.cs index 106411cc2..cefd0c57b 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/BusinessDevicePage.cs +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo.razor.cs @@ -12,9 +12,9 @@ using ThingsGateway.Gateway.Application; namespace ThingsGateway.Gateway.Razor; -[Route("/gateway/businessdevice")] -public class BusinessDevicePage : DevicePage +public partial class DeviceRuntimeInfo { - protected override PluginTypeEnum PluginType => PluginTypeEnum.Business; - protected override string RolePrex { get; } = "GatewayBusinessDevice"; + [Parameter, EditorRequired] + public DeviceRuntime DeviceRuntime { get; set; } + } diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatus3.razor.css b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo.razor.css similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatus3.razor.css rename to src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo.razor.css diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo1.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo1.razor new file mode 100644 index 000000000..190eb6a7c --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo1.razor @@ -0,0 +1,144 @@ +@namespace ThingsGateway.Gateway.Razor +@using ThingsGateway.Admin.Application +@using ThingsGateway.Admin.Razor +@using ThingsGateway.Debug +@using ThingsGateway.Foundation +@using ThingsGateway.Gateway.Application +@using ThingsGateway.Extension +@inherits ComponentDefault + +
+ + + + + + @(Name) + + +
+ @if (DeviceRuntime.Driver != null) + { + var driver = DeviceRuntime.Driver; + @if (driver?.DriverUIType != null) + { + + +
diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo1.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo1.razor.cs new file mode 100644 index 000000000..af2126a79 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo1.razor.cs @@ -0,0 +1,112 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Gateway.Application; + +namespace ThingsGateway.Gateway.Razor; + +public partial class DeviceRuntimeInfo1 : IDisposable +{ + [Inject] + IStringLocalizer GatewayLocalizer { get; set; } + + [Parameter, EditorRequired] + public DeviceRuntime DeviceRuntime { get; set; } + private string Name => $"{DeviceRuntime.ToString()} - {(DeviceRuntime.Driver?.DeviceThreadManage == null ? "Task cancel" : "Task run")}"; + public ModelValueValidateForm PluginPropertyModel; + + protected override void OnParametersSet() + { + if (PluginPropertyModel?.Value == null || PluginPropertyModel?.Value != DeviceRuntime.Driver?.DriverProperties) + { + PluginPropertyModel = new ModelValueValidateForm() + { + Value = DeviceRuntime.Driver?.DriverProperties + }; + } + base.OnParametersSet(); + } + + + + private async Task ShowDriverUI() + { + var driver = DeviceRuntime.Driver?.DriverUIType; + if (driver == null) + { + return; + } + await DialogService.Show(new DialogOption() + { + IsScrolling = false, + ShowMaximizeButton = true, + Size = Size.ExtraExtraLarge, + Title = DeviceRuntime.Name, + Component = BootstrapDynamicComponent.CreateComponent(driver, new Dictionary() + { + {nameof(IDriverUIBase.Driver),DeviceRuntime.Driver}, + }) + }); + } + private async Task DeviceRedundantThreadAsync() + { + if (GlobalData.TryGetDeviceThreadManage(DeviceRuntime, out var deviceThreadManage)) + { + await deviceThreadManage.DeviceRedundantThreadAsync(DeviceRuntime.Id); + } + } + private async Task RestartDeviceAsync(bool deleteCache) + { + if (GlobalData.TryGetDeviceThreadManage(DeviceRuntime, out var deviceThreadManage)) + { + await deviceThreadManage.RestartDeviceAsync(DeviceRuntime, deleteCache); + } + } + private void PauseThread() + { + if (DeviceRuntime.Driver != null) + DeviceRuntime.Driver.PauseThread(!DeviceRuntime.Pause); + } + + protected override void OnInitialized() + { + _ = RunTimerAsync(); + base.OnInitialized(); + } + + private bool Disposed; + private async Task RunTimerAsync() + { + while (!Disposed) + { + try + { + await InvokeAsync(() => + { + StateHasChanged(); + }); + } + catch (Exception ex) + { + NewLife.Log.XTrace.WriteException(ex); + } + finally + { + await Task.Delay(5000); + } + } + } + + public void Dispose() + { + Disposed = true; + GC.SuppressFinalize(this); + } +} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatus2.razor.css b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo1.razor.css similarity index 79% rename from src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatus2.razor.css rename to src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo1.razor.css index 9f188b873..4844d784b 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Runtime/DeviceStatus/DeviceStatus2.razor.css +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/DeviceRuntimeInfo1.razor.css @@ -20,7 +20,10 @@ .device ::deep [data-bs-toggle=tooltip]:has(.form-label) { width: 100px; - /*overflow: inherit;*/ + overflow: inherit; +} +.channel ::deep [data-bs-toggle=tooltip]:has(.is-display) { + overflow: inherit; } .device ::deep [data-bs-toggle=tooltip]:has(.form-control) { @@ -32,5 +35,7 @@ color: var(--bs-card-title-color); } -.device ::deep .form-label is-display { -} +.device ::deep .bb-editor .row { + --bs-gutter-y: 0.5rem; + --bs-gutter-x: 0.5rem; +} \ No newline at end of file diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/PropertyComponent.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/PropertyComponent.razor new file mode 100644 index 000000000..9d41a424b --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/PropertyComponent.razor @@ -0,0 +1,63 @@ +@using BootstrapBlazor.Components +@using ThingsGateway.Extension +@using ThingsGateway.Foundation +@using ThingsGateway.Admin.Application +@using ThingsGateway.Admin.Razor +@using ThingsGateway.Gateway.Application +@namespace ThingsGateway.Gateway.Razor + + + + + + + + @if (Model.Value is BusinessPropertyWithCacheIntervalScript businessProperty) + { + context) Field=@(context)> + + +
+ + +
+ +
+
+
+ + + +
+ +
+
+
+ + + +
+ +
+
+ +
+
+ } +
+
+
+ + + diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/BusinessPropertyWithCacheIntervalScriptRazor.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/PropertyComponent.razor.cs similarity index 93% rename from src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/BusinessPropertyWithCacheIntervalScriptRazor.razor.cs rename to src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/PropertyComponent.razor.cs index 20781c038..a388bcdcf 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/BusinessPropertyWithCacheIntervalScriptRazor.razor.cs +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/PropertyComponent.razor.cs @@ -11,23 +11,28 @@ using ThingsGateway.Gateway.Application; namespace ThingsGateway.Gateway.Razor; -public partial class BusinessPropertyWithCacheIntervalScriptRazor +public partial class PropertyComponent : IPropertyUIBase { - [Parameter, EditorRequired] - public Device Model { get; set; } + public string Id { get; set; } + [Parameter, EditorRequired] + public bool CanWrite { get; set; } + [Parameter, EditorRequired] + public ModelValueValidateForm Model { get; set; } [Parameter, EditorRequired] public IEnumerable PluginPropertyEditorItems { get; set; } - private IStringLocalizer BusinessPropertyWithCacheIntervalScriptLocalizer { get; set; } + + private IStringLocalizer PropertyComponentLocalizer { get; set; } protected override Task OnParametersSetAsync() { - BusinessPropertyWithCacheIntervalScriptLocalizer = App.CreateLocalizerByType(Model.PluginPropertyModel.Value.GetType()); + PropertyComponentLocalizer = App.CreateLocalizerByType(Model.Value.GetType()); return base.OnParametersSetAsync(); } [Inject] private IStringLocalizer Localizer { get; set; } + private async Task CheckScript(BusinessPropertyWithCacheIntervalScript businessProperty, string pname) { IEnumerable data = null; @@ -148,8 +153,6 @@ public partial class BusinessPropertyWithCacheIntervalScriptRazor else if (pname == nameof(BusinessPropertyWithCacheIntervalScript.BigTextScriptVariableModel)) { businessProperty.BigTextScriptVariableModel=v; - - } else if (pname == nameof(BusinessPropertyWithCacheIntervalScript.BigTextScriptDeviceModel)) { diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/ScriptCheck.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/ScriptCheck.razor similarity index 100% rename from src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/ScriptCheck.razor rename to src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/ScriptCheck.razor diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/ScriptCheck.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/ScriptCheck.razor.cs similarity index 97% rename from src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/ScriptCheck.razor.cs rename to src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/ScriptCheck.razor.cs index 4f0de25af..ac2618ed2 100644 --- a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/Config/Device/ScriptCheck.razor.cs +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Device/ScriptCheck.razor.cs @@ -8,8 +8,8 @@ // QQ群:605534569 //------------------------------------------------------------------------------ -using ThingsGateway.Core.Json.Extension; using ThingsGateway.Gateway.Application; +using ThingsGateway.NewLife.Json.Extension; namespace ThingsGateway.Gateway.Razor; diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/GatewayMonitorPage.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/GatewayMonitorPage.razor new file mode 100644 index 000000000..d615948b2 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/GatewayMonitorPage.razor @@ -0,0 +1,39 @@ +@page "/gateway/monitor" +@attribute [Authorize] +@attribute [RolePermission] +@inherits ComponentDefault +@using ThingsGateway.Admin.Application +@using ThingsGateway.Admin.Razor +@using ThingsGateway.Gateway.Application +@namespace ThingsGateway.Gateway.Razor + +
+ +
+ + + + + +
+
+ @if (ShowType == ShowTypeEnum.Variable) + { + + } + else + { + if (ShowDeviceRuntime != null) + { + + } + if (ShowChannelRuntime != null) + { + + } + } +
+ +
+ + diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/GatewayMonitorPage.razor.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/GatewayMonitorPage.razor.cs new file mode 100644 index 000000000..61a2f9c02 --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/GatewayMonitorPage.razor.cs @@ -0,0 +1,90 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +using ThingsGateway.Gateway.Application; + +namespace ThingsGateway.Gateway.Razor; + +public partial class GatewayMonitorPage +{ + private ChannelDeviceTreeItem SelectModel { get; set; } = new() { ChannelDevicePluginType = ChannelDevicePluginTypeEnum.PluginType, PluginType = PluginTypeEnum.Collect }; + + #region 查询 + + private async Task TreeChangedAsync(ChannelDeviceTreeItem channelDeviceTreeItem) + { + ShowChannelRuntime = null; + ShowDeviceRuntime = null; + SelectModel = channelDeviceTreeItem; + if (channelDeviceTreeItem.TryGetChannelRuntime(out var channelRuntime)) + { + ShowChannelRuntime = channelRuntime; + if (channelRuntime.IsCollect == true) + { + VariableRuntimes = channelRuntime.ReadDeviceRuntimes.SelectMany(a => a.Value.ReadVariableRuntimes.Select(a => a.Value)); + } + else + { + VariableRuntimes = channelRuntime.ReadDeviceRuntimes.SelectMany(a => a.Value.Driver?.VariableRuntimes?.Select(a => a.Value)).Where(a => a != null); + } + + } + else if (channelDeviceTreeItem.TryGetDeviceRuntime(out var deviceRuntime)) + { + ShowDeviceRuntime = deviceRuntime; + if (deviceRuntime.IsCollect == true) + { + VariableRuntimes = deviceRuntime.ReadVariableRuntimes.Select(a => a.Value); + } + else + { + VariableRuntimes = deviceRuntime.Driver?.VariableRuntimes? +.Select(a => a.Value) ?? Enumerable.Empty(); + } + + } + else if (channelDeviceTreeItem.TryGetPluginName(out var pluginName)) + { + var pluginType = GlobalData.PluginService.GetList().FirstOrDefault(a => a.FullName == pluginName)?.PluginType; + if (pluginType == PluginTypeEnum.Collect) + { + var channels = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + VariableRuntimes = channels.Where(a => a.Value.PluginName == pluginName).SelectMany(a => a.Value.ReadDeviceRuntimes).SelectMany(a => a.Value.ReadVariableRuntimes).Select(a => a.Value); + } + else + { + var channels = await GlobalData.GetCurrentUserChannels().ConfigureAwait(false); + VariableRuntimes = channels.Where(a => a.Value.PluginName == pluginName).SelectMany(a => a.Value.ReadDeviceRuntimes).SelectMany(a => a.Value.Driver?.VariableRuntimes).Select(a => a.Value).Where(a => a != null); + } + } + else + { + var variables = await GlobalData.GetCurrentUserIdVariables().ConfigureAwait(false); + VariableRuntimes = variables.Select(a => a.Value); + } + await InvokeAsync(StateHasChanged); + } + + #endregion 查询 + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + await TreeChangedAsync(SelectModel); + await base.OnAfterRenderAsync(firstRender); + } + public IEnumerable VariableRuntimes { get; set; } = Enumerable.Empty(); + + private ChannelRuntime ShowChannelRuntime { get; set; } + private DeviceRuntime ShowDeviceRuntime { get; set; } + public ShowTypeEnum? ShowType { get; set; } + private bool AutoRestartThread { get; set; } = true; + + +} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ShowTypeEnum.cs b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ShowTypeEnum.cs new file mode 100644 index 000000000..186b404aa --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/ShowTypeEnum.cs @@ -0,0 +1,17 @@ +//------------------------------------------------------------------------------ +// 此代码版权声明为全文件覆盖,如有原作者特别声明,会在下方手动补充 +// 此代码版权(除特别声明外的代码)归作者本人Diego所有 +// 源代码使用协议遵循本仓库的开源协议及附加协议 +// Gitee源代码仓库:https://gitee.com/diego2098/ThingsGateway +// Github源代码仓库:https://github.com/kimdiego2098/ThingsGateway +// 使用文档:https://thingsgateway.cn/ +// QQ群:605534569 +//------------------------------------------------------------------------------ + +namespace ThingsGateway.Gateway.Razor; + +public enum ShowTypeEnum +{ + Variable, + LogInfo +} diff --git a/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableEditComponent.razor b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableEditComponent.razor new file mode 100644 index 000000000..18de143cd --- /dev/null +++ b/src/Gateway/ThingsGateway.Gateway.Razor/Pages/GatewayMonitorPage/Variable/VariableEditComponent.razor @@ -0,0 +1,236 @@ +@namespace ThingsGateway.Gateway.Razor +@using ThingsGateway.Admin.Application +@using ThingsGateway.Admin.Razor +@using ThingsGateway.Foundation +@using ThingsGateway.Gateway.Application +@inherits ComponentDefault + +
+ + @if (ValidateEnable) + { + + @renderFragment + + + + + } + else + { + @renderFragment + } +
+@code { + RenderFragment renderFragment => + @ + + + + + + + + +
+
@GatewayLocalizer["BasicInformation"]
+
+
+
+ + + + + + + + + + + + + + + +
+
@GatewayLocalizer["Connection"]
+
+
+
+ + + +
+ + +
+
+
+ + + +
+ + + + +
+
+
+ + + + + + + + +
+
@GatewayLocalizer["Remark"]
+
+
+
+ + + + + + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @if(!BatchEditEnable) + { + +
+
+ +
+