😎1、增加前端form字段验证 2、优化接口重复请求 3、优化前端字典、下拉框组件 4、调整代码生成及其命名空间 5、其它优化

This commit is contained in:
zuohuaijun 2025-09-19 02:46:24 +08:00
parent 0a414a06c5
commit 9d4f3c20bd
31 changed files with 1773 additions and 805 deletions

View File

@ -14,99 +14,41 @@ namespace Admin.NET.Core;
/// </summary> /// </summary>
[SuppressSniffer] [SuppressSniffer]
[AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = true)] [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = true)]
public class IdempotentAttribute : Attribute, IAsyncActionFilter public class IdempotentAttribute(int intervalTime = 5, bool throwBah = true, string message = "您的操作过于频繁,请稍后再试!") : Attribute, IAsyncActionFilter
{ {
/// <summary> private static readonly Lazy<SysCacheService> SysCacheService = new(() => App.GetService<SysCacheService>());
/// 请求间隔时间/秒
/// </summary>
public int IntervalTime { get; set; } = 5;
/// <summary>
/// 错误提示内容
/// </summary>
public string Message { get; set; } = "你操作频率过快,请稍后重试!";
/// <summary>
/// 缓存前缀: Key+请求路由+用户Id+请求参数
/// </summary>
public string CacheKey { get; set; } = CacheConst.KeyIdempotent;
/// <summary>
/// 是否直接抛出异常Ture是False返回上次请求结果
/// </summary>
public bool ThrowBah { get; set; }
/// <summary>
/// 锁前缀
/// </summary>
public string LockPrefix { get; set; } = "lock_";
public IdempotentAttribute()
{
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{ {
var httpContext = context.HttpContext; var httpContext = context.HttpContext;
var path = httpContext.Request.Path.Value.ToString(); var path = httpContext.Request.Path.Value;
var userId = httpContext.User?.FindFirstValue(ClaimConst.UserId); var userId = httpContext.User.FindFirstValue(ClaimConst.UserId);
var cacheExpireTime = TimeSpan.FromSeconds(IntervalTime);
var parameters = JsonConvert.SerializeObject(context.ActionArguments, Formatting.None, new JsonSerializerSettings var parameters = JsonConvert.SerializeObject(context.ActionArguments, Formatting.None, new JsonSerializerSettings
{ {
NullValueHandling = NullValueHandling.Include, NullValueHandling = NullValueHandling.Include,
DefaultValueHandling = DefaultValueHandling.Include DefaultValueHandling = DefaultValueHandling.Include
}); });
var cacheKey = CacheKey + MD5Encryption.Encrypt($"{path}{userId}{parameters}"); // 分布式锁
var sysCacheService = httpContext.RequestServices.GetService<SysCacheService>(); var md5Key = MD5Encryption.Encrypt($"{path}{userId}{parameters}");
try using var distributedLock = SysCacheService.Value.BeginCacheLock(CacheConst.KeyIdempotent + md5Key);
if (distributedLock == null)
{ {
// 分布式锁 if (throwBah) throw Oops.Oh(message);
using var distributedLock = sysCacheService.BeginCacheLock($"{LockPrefix}{cacheKey}") ?? throw Oops.Oh(Message); return;
var cacheValue = sysCacheService.Get<ResponseData>(cacheKey);
if (cacheValue != null)
{
if (ThrowBah) throw Oops.Oh(Message);
context.Result = new ObjectResult(cacheValue.Value);
return;
}
else
{
var resultContext = await next();
// 缓存请求结果 null 值不缓存
if (resultContext.Result is ObjectResult { Value: { } } objectResult)
{
var typeName = objectResult.Value.GetType().Name;
var responseData = new ResponseData
{
Type = typeName,
Value = objectResult.Value
};
sysCacheService.Set(cacheKey, responseData, cacheExpireTime);
}
}
} }
catch (Exception ex)
// 判断是否存在重复请求
var cacheKey = CacheConst.KeyIdempotent + "cache:" + md5Key;
var isExist = SysCacheService.Value.ExistKey(cacheKey);
if (isExist)
{ {
throw Oops.Oh($"{Message}-{ex}"); if (throwBah) throw Oops.Oh(message);
return;
} }
}
/// <summary> // 标记请求
/// 请求结果数据 SysCacheService.Value.Set(cacheKey, 1, TimeSpan.FromSeconds(intervalTime));
/// </summary> await next();
private class ResponseData
{
/// <summary>
/// 结果类型
/// </summary>
public string Type { get; set; }
/// <summary>
/// 请求结果
/// </summary>
public dynamic Value { get; set; }
} }
} }

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 代码生成策略工厂 /// 代码生成策略工厂

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 自定义模板引擎 /// 自定义模板引擎

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
public class TableEntityEngine : ViewEngineModel public class TableEntityEngine : ViewEngineModel
{ {

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
public class TableSeedDataEngine : ViewEngineModel public class TableSeedDataEngine : ViewEngineModel
{ {

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 代码生成详细配置参数 /// 代码生成详细配置参数

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 模板输出上下文 /// 模板输出上下文

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 种子数据策略基类处理TableSeedDataEngine类型 /// 种子数据策略基类处理TableSeedDataEngine类型

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 基础策略接口 /// 基础策略接口

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 表策略基类处理SysCodeGen类型 /// 表策略基类处理SysCodeGen类型

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 表实体代码生成策略类 /// 表实体代码生成策略类

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 表种子数据代码生成策略类 /// 表种子数据代码生成策略类

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 主从表代码生成策略类 /// 主从表代码生成策略类

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 单表代码生成策略类 /// 单表代码生成策略类

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 关系对照代码生成策略类 /// 关系对照代码生成策略类

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 主从明细带树组件代码生成策略类 /// 主从明细带树组件代码生成策略类

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 单表带树组件代码生成策略类 /// 单表带树组件代码生成策略类

View File

@ -4,7 +4,7 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core.CodeGen; namespace Admin.NET.Core;
/// <summary> /// <summary>
/// 关系对照带树组件代码生成策略类 /// 关系对照带树组件代码生成策略类

View File

@ -112,9 +112,9 @@ public class CacheConst
public const string KeyDict = "sys_dict:"; public const string KeyDict = "sys_dict:";
/// <summary> /// <summary>
/// 重复请求(幂等)字典缓存 /// 重复请求(幂等)缓存
/// </summary> /// </summary>
public const string KeyIdempotent = "sys_idempotent:"; public const string KeyIdempotent = "sys_idempotent_lock:";
/// <summary> /// <summary>
/// Excel临时文件缓存 /// Excel临时文件缓存

View File

@ -4,7 +4,6 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
using Admin.NET.Core.CodeGen;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;

View File

@ -4,7 +4,6 @@
// //
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! // 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
using Admin.NET.Core.CodeGen;
using System.IO.Compression; using System.IO.Compression;
namespace Admin.NET.Core; namespace Admin.NET.Core;

View File

@ -18,16 +18,19 @@ public class SysDatabaseService : IDynamicApiController, ITransient
private readonly ISqlSugarClient _db; private readonly ISqlSugarClient _db;
private readonly IViewEngine _viewEngine; private readonly IViewEngine _viewEngine;
private readonly CodeGenOptions _codeGenOptions; private readonly CodeGenOptions _codeGenOptions;
private readonly CodeGenStrategyFactory _codeGenStrategyFactory;
public SysDatabaseService(UserManager userManager, public SysDatabaseService(UserManager userManager,
ISqlSugarClient db, ISqlSugarClient db,
IViewEngine viewEngine, IViewEngine viewEngine,
IOptions<CodeGenOptions> codeGenOptions) IOptions<CodeGenOptions> codeGenOptions,
CodeGenStrategyFactory codeGenStrategyFactory)
{ {
_userManager = userManager; _userManager = userManager;
_db = db; _db = db;
_viewEngine = viewEngine; _viewEngine = viewEngine;
_codeGenOptions = codeGenOptions.Value; _codeGenOptions = codeGenOptions.Value;
_codeGenStrategyFactory = codeGenStrategyFactory;
} }
/// <summary> /// <summary>
@ -363,33 +366,10 @@ public class SysDatabaseService : IDynamicApiController, ITransient
/// <param name="input"></param> /// <param name="input"></param>
[ApiDescriptionSettings(Name = "CreateEntity"), HttpPost] [ApiDescriptionSettings(Name = "CreateEntity"), HttpPost]
[DisplayName("创建实体")] [DisplayName("创建实体")]
public void CreateEntity(CreateEntityInput input) public async Task CreateEntity(CreateEntityInput input)
{ {
var tResult = GenerateEntity(input); var template = await GenerateEntity(input);
var targetPath = GetEntityTargetPath(input); await File.WriteAllTextAsync(template.OutPath, template.Context, Encoding.UTF8);
File.WriteAllText(targetPath, tResult, Encoding.UTF8);
}
/// <summary>
/// 创建实体文件内容
/// </summary>
/// <param name="ConfigId"></param>
/// <param name="TableName"></param>
/// <param name="Position"></param>
/// <param name="BaseClassName"></param>
/// <returns></returns>
[DisplayName("创建实体文件内容")]
public string GenerateEntity(string ConfigId, string TableName, string Position, string BaseClassName)
{
var input = new CreateEntityInput
{
TableName = TableName,
EntityName = TableName.ToFirstLetterUpperCase(),
ConfigId = ConfigId,
Position = string.IsNullOrWhiteSpace(Position) ? "Admin.NET.Application" : Position,
BaseClassName = string.IsNullOrWhiteSpace(BaseClassName) ? "EntityBaseId" : BaseClassName
};
return GenerateEntity(input);
} }
/// <summary> /// <summary>
@ -397,53 +377,12 @@ public class SysDatabaseService : IDynamicApiController, ITransient
/// </summary> /// </summary>
/// <param name="input"></param> /// <param name="input"></param>
/// <returns></returns> /// <returns></returns>
[ApiDescriptionSettings(Name = "GenerateEntity"), HttpPost]
[DisplayName("创建实体文件内容")] [DisplayName("创建实体文件内容")]
public string GenerateEntity(CreateEntityInput input) public async Task<TemplateContextOutput> GenerateEntity(CreateEntityInput input)
{ {
var config = App.GetOptions<DbConnectionOptions>().ConnectionConfigs.FirstOrDefault(u => u.ConfigId.ToString() == input.ConfigId); var strategy = _codeGenStrategyFactory.GetStrategy<CreateEntityInput>(CodeGenSceneEnum.TableEntity);
input.Position = string.IsNullOrWhiteSpace(input.Position) ? "Admin.NET.Application" : input.Position; return (await strategy.GenerateCode(input))?.FirstOrDefault();
input.EntityName = string.IsNullOrWhiteSpace(input.EntityName)
? (config.DbSettings.EnableUnderLine ? CodeGenHelper.CamelColumnName(input.TableName, null) : input.TableName)
: input.EntityName;
string[] dbColumnNames = [];
// Entity.cs.vm中是允许创建没有基类的实体的所以这里也要做出相同的判断
if (!string.IsNullOrWhiteSpace(input.BaseClassName))
{
_codeGenOptions.EntityBaseColumn.TryGetValue(input.BaseClassName, out dbColumnNames);
if (dbColumnNames is null || dbColumnNames is { Length: 0 })
throw Oops.Oh("基类配置文件不存在此类型");
}
var db = _db.AsTenant().GetConnectionScope(input.ConfigId);
var dbColumnInfos = db.DbMaintenance.GetColumnInfosByTableName(input.TableName, false);
dbColumnInfos.ForEach(u =>
{
// 禁止字段全是大写的
if (u.DbColumnName.ToUpper() == u.DbColumnName)
throw new Exception($"字段命名规范错误:{u.DbColumnName} 字段全是大写字母,请用大驼峰式命名规范!");
u.PropertyName = config.DbSettings.EnableUnderLine ? CodeGenHelper.CamelColumnName(u.DbColumnName, dbColumnNames) : u.DbColumnName; // 转下划线后的列名需要再转回来
u.DataType = CodeGenHelper.ConvertDataType(u, config.DbType);
});
if (_codeGenOptions.BaseEntityNames.Contains(input.BaseClassName, StringComparer.OrdinalIgnoreCase))
dbColumnInfos = dbColumnInfos.Where(u => !dbColumnNames.Contains(u.PropertyName, StringComparer.OrdinalIgnoreCase)).ToList();
var dbTableInfo = db.DbMaintenance.GetTableInfoList(false).FirstOrDefault(u => u.Name == input.TableName || u.Name == input.TableName.ToLower()) ?? throw Oops.Oh(ErrorCodeEnum.db1001);
var templatePath = GetEntityTemplatePath();
var tContent = File.ReadAllText(templatePath);
var tResult = _viewEngine.RunCompileFromCached(tContent, new
{
NameSpace = $"{input.Position}.Entity",
TableName = input.TableName,
EntityName = input.EntityName,
BaseClassName = string.IsNullOrWhiteSpace(input.BaseClassName) ? "" : $": {input.BaseClassName}",
ConfigId = input.ConfigId,
Description = string.IsNullOrWhiteSpace(dbTableInfo.Description) ? input.EntityName + "业务表" : dbTableInfo.Description,
TableFields = dbColumnInfos,
AuthorName = "Admin.NET",
Email = "Admin.NET@qq.com"
});
return tResult;
} }
/// <summary> /// <summary>
@ -454,191 +393,9 @@ public class SysDatabaseService : IDynamicApiController, ITransient
[DisplayName("创建种子数据")] [DisplayName("创建种子数据")]
public async Task CreateSeedData(CreateSeedDataInput input) public async Task CreateSeedData(CreateSeedDataInput input)
{ {
var config = App.GetOptions<DbConnectionOptions>().ConnectionConfigs.FirstOrDefault(u => u.ConfigId.ToString() == input.ConfigId); var strategy = _codeGenStrategyFactory.GetStrategy<CreateSeedDataInput>(CodeGenSceneEnum.TableSeedData);
input.Position = string.IsNullOrWhiteSpace(input.Position) ? "Admin.NET.Core" : input.Position; var result = (await strategy.GenerateCode(input))?.FirstOrDefault();
await File.WriteAllTextAsync(result!.OutPath, result!.Context, Encoding.UTF8);
var templatePath = GetSeedDataTemplatePath();
var db = _db.AsTenant().GetConnectionScope(input.ConfigId);
var tableInfo = db.DbMaintenance.GetTableInfoList(false).First(u => u.Name == input.TableName); // 表名
List<DbColumnInfo> dbColumnInfos = db.DbMaintenance.GetColumnInfosByTableName(input.TableName, false); // 所有字段
IEnumerable<EntityInfo> entityInfos = await GetEntityInfos();
Type entityType = null;
foreach (var item in entityInfos)
{
if (tableInfo.Name.ToLower() != (config.DbSettings.EnableUnderLine ? UtilMethods.ToUnderLine(item.DbTableName) : item.DbTableName).ToLower()) continue;
entityType = item.Type;
break;
}
if (entityType == null) throw Oops.Oh(ErrorCodeEnum.db1003);
input.EntityName = entityType.Name;
input.SeedDataName = entityType.Name + "SeedData";
if (!string.IsNullOrWhiteSpace(input.Suffix)) input.SeedDataName += input.Suffix;
// 查询所有数据
var query = db.QueryableByObject(entityType);
// 如果 entityType.Name=="SysDictType"或"SysDictData"或"SysDictDataTenant" 加入查询条件 u.IsEnum!=1
if (entityType.Name == "SysDictType" || entityType.Name == "SysDictData" || entityType.Name == "SysDictDataTenant")
{
if (config.DbSettings.EnableUnderLine)
query = query.Where("is_enum != 1");
else
query = query.Where("IsEnum != 1");
}
// 优先用创建时间排序
DbColumnInfo orderField = dbColumnInfos.FirstOrDefault(u => u.DbColumnName.ToLower() == "create_time" || u.DbColumnName.ToLower() == "createtime");
if (orderField != null) query = query.OrderBy(orderField.DbColumnName);
// 优先用创建时间排序,再使用第一个主键排序
if (dbColumnInfos.Any(u => u.IsPrimarykey))
query = query.OrderBy(dbColumnInfos.First(u => u.IsPrimarykey).DbColumnName);
var records = ((IEnumerable)await query.ToListAsync()).ToDynamicList();
// 过滤已存在的数据
if (input.FilterExistingData && records.Count != 0)
{
// 获取实体类型-所有种数据数据类型
var entityTypes = App.EffectiveTypes.Where(u => !u.IsInterface && !u.IsAbstract && u.IsClass && u.IsDefined(typeof(SugarTable), false) && u.FullName.EndsWith("." + input.EntityName))
.Where(u => !u.GetCustomAttributes<IgnoreTableAttribute>().Any()).ToList();
if (entityTypes.Count == 1) // 只有一个实体匹配才能过滤
{
// 获取实体的主键对应的属性名称
var pkInfo = entityTypes[0].GetProperties().FirstOrDefault(u => u.GetCustomAttribute<SugarColumn>()?.IsPrimaryKey == true);
if (pkInfo != null)
{
var seedDataTypes = SeedDataHelper.GetSeedDataTypeList(entityTypes[0]);
// 可能会重名的种子数据不作为过滤项
string doNotFilterFullName1 = $"{input.Position}.SeedData.{input.SeedDataName}";
string doNotFilterFullName2 = $"{input.Position}.{input.SeedDataName}"; // Core中的命名空间没有SeedData
PropertyInfo idPropertySeedData = records[0].GetType().GetProperty("Id");
for (int i = seedDataTypes.Count - 1; i >= 0; i--)
{
string fullName = seedDataTypes[i].FullName;
if ((fullName == doNotFilterFullName1) || (fullName == doNotFilterFullName2)) continue;
// 删除重复数据
var instance = Activator.CreateInstance(seedDataTypes[i]);
var hasDataMethod = seedDataTypes[i].GetMethod("HasData");
var seedData = ((IEnumerable)hasDataMethod?.Invoke(instance, null))?.Cast<object>();
if (seedData == null) continue;
List<object> recordsToRemove = [];
foreach (var record in records)
{
object recordId = pkInfo.GetValue(record);
if (seedData.Select(d1 => idPropertySeedData.GetValue(d1)).Any(dataId => recordId != null && dataId != null && recordId.Equals(dataId)))
{
recordsToRemove.Add(record);
}
}
foreach (var itemToRemove in recordsToRemove)
{
records.Remove(itemToRemove);
}
}
}
}
}
// 检查有没有 System.Text.Json.Serialization.JsonIgnore 或 Newtonsoft.Json.JsonIgnore 的属性
// 如果 JsonIgnore 和 SugarColumn 都存在那么后成序更化时就生成这了这些字段就需要在这里另外补充以处理用户表SysUser中的Password为例
var jsonIgnoreProperties = entityType.GetProperties().Where(p => (p.GetAttribute<System.Text.Json.Serialization.JsonIgnoreAttribute>() != null ||
p.GetAttribute<Newtonsoft.Json.JsonIgnoreAttribute>() != null) && p.GetAttribute<SugarColumn>() != null).ToList();
var jsonIgnoreInfo = new List<List<JsonIgnoredPropertyData>>();
if (jsonIgnoreProperties.Count > 0)
{
int recordIndex = 0;
foreach (var r in (IEnumerable)records)
{
List<JsonIgnoredPropertyData> record = [];
foreach (var item in jsonIgnoreProperties)
{
object v = item.GetValue(r);
string strValue = "null";
if (v != null)
{
strValue = v.ToString();
if (v.GetType() == typeof(string))
strValue = "\"" + strValue + "\"";
else if (v.GetType() == typeof(DateTime))
strValue = "DateTime.Parse(\"" + ((DateTime)v).ToString("yyyy-MM-dd HH:mm:ss") + "\")";
}
record.Add(new JsonIgnoredPropertyData { RecordIndex = recordIndex, Name = item.Name, Value = strValue });
}
recordIndex++;
jsonIgnoreInfo.Add(record);
}
}
// 获取所有字段信息
var propertyList = entityType.GetProperties().Where(x => false == (x.GetCustomAttribute<SugarColumn>()?.IsIgnore ?? false)).ToList();
for (var i = 0; i < propertyList.Count; i++)
{
if (propertyList[i].Name != nameof(EntityBaseId.Id) || !(propertyList[i].GetCustomAttribute<SugarColumn>()?.IsPrimaryKey ?? true)) continue;
var temp = propertyList[i];
for (var j = i; j > 0; j--) propertyList[j] = propertyList[j - 1];
propertyList[0] = temp;
}
// var timeConverter = new IsoDateTimeConverter { DateTimeFormat = "yyyy-MM-dd HH:mm:ss" };
// var recordList = JsonConvert.SerializeObject(records, Formatting.Indented, timeConverter);
// 拼接种子数据
var recordList = records.Select(obj => string.Join(", ", propertyList.Select(prop =>
{
var propType = Nullable.GetUnderlyingType(prop.PropertyType) ?? prop.PropertyType;
object value = prop.GetValue(obj);
if (value == null) value = "null";
else if (propType == typeof(string))
{
value = $"@\"{value.ToString().Replace("\"", "\"\"")}\"";
}
else if (propType.IsEnum)
{
value = $"{propType.Name}.{value}";
}
else if (propType == typeof(bool))
{
value = (bool)value ? "true" : "false";
}
else if (propType == typeof(DateTime))
{
value = $"DateTime.Parse(\"{((DateTime)value):yyyy-MM-dd HH:mm:ss.fff}\")";
}
return $"{prop.Name}={value}";
}))).ToList();
var tContent = await File.ReadAllTextAsync(templatePath);
var data = new
{
NameSpace = $"{input.Position}.SeedData",
EntityNameSpace = entityType.Namespace,
input.TableName,
input.EntityName,
input.SeedDataName,
input.ConfigId,
tableInfo.Description,
JsonIgnoreInfo = jsonIgnoreInfo,
RecordList = recordList
};
var tResult = await _viewEngine.RunCompileAsync(tContent, data, builderAction: builder =>
{
builder.AddAssemblyReferenceByName("System.Linq");
builder.AddAssemblyReferenceByName("System.Collections");
builder.AddAssemblyReferenceByName("System.Text.RegularExpressions");
builder.AddUsing("System.Text.RegularExpressions");
builder.AddUsing("System.Collections.Generic");
builder.AddUsing("System.Linq");
});
var targetPath = GetSeedDataTargetPath(input);
await File.WriteAllTextAsync(targetPath, tResult, Encoding.UTF8);
} }
/// <summary> /// <summary>

View File

@ -0,0 +1,56 @@
// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
//
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
//
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
using Xunit.Abstractions;
namespace Admin.NET.Test.Attribute;
/// <summary>
/// 防止重复请求测试
/// </summary>
public class IdempotentTest
{
private readonly ITestOutputHelper _testOutputHelper;
private const string BaseAddress = "http://localhost:5005";
private readonly HttpClient _client;
public IdempotentTest(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
_client = new HttpClient();
_client.BaseAddress = new Uri(BaseAddress);
}
private async Task<string> PostAsync(string action)
{
var result = await _client.PostAsync(action, null);
return await result.Content.ReadAsStringAsync();
}
[Fact]
public async Task IdempotentTest1()
{
// 模拟重复请求50次
var tasks = Task.WhenAll(Enumerable.Range(0, 50).Select(u => PostAsync("/api/test/idempotentTest1?input=1")));
var resultList = await tasks;
_testOutputHelper.WriteLine(string.Join('\n', resultList));
Assert.Equal(1, resultList.Count(u => u == "请求成功"));
Assert.Equal(resultList.Length - 1, resultList.Count(u => u == "\"您的操作过于频繁,请稍后再试!\""));
}
[Fact]
public async Task IdempotentTest2()
{
// 模拟重复请求50次
var tasks = Task.WhenAll(Enumerable.Range(0, 50).Select(u => PostAsync("/api/test/idempotentTest2?input=1")));
var resultList = await tasks;
_testOutputHelper.WriteLine(string.Join('\n', resultList));
Assert.Equal(1, resultList.Count(u => u == "请求成功"));
Assert.Equal(resultList.Length - 1, resultList.Count(string.IsNullOrWhiteSpace));
}
}

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { PropType, reactive, watch } from 'vue'; import { PropType, reactive, watch } from 'vue';
import { NullableNumber, NullableString } from '/@/types/global';
const emit = defineEmits(['update:modelValue', 'change']); const emit = defineEmits(['update:modelValue', 'change']);
@ -105,7 +106,7 @@ watch(
</script> </script>
<template> <template>
<el-tabs v-model="state.active" class="ml5 mt5" v-bind="$attrs"> <el-tabs v-model="state.active" class="ml5 mt5 container" v-bind="$attrs">
<el-tab-pane v-for="(item, index) in state.data" :key="index" :label="item.label" :name="item.value" :disabled="item.disabled" v-show="item.visible === undefined || item.visible"> <el-tab-pane v-for="(item, index) in state.data" :key="index" :label="item.label" :name="item.value" :disabled="item.disabled" v-show="item.visible === undefined || item.visible">
<template #label v-if="(props.hideZero && item.count) || !props.hideZero"> <template #label v-if="(props.hideZero && item.count) || !props.hideZero">
<el-badge :value="item.count ?? 0" :max="props.max" :offset="props.offset">{{ item.label }}</el-badge> <el-badge :value="item.count ?? 0" :max="props.max" :offset="props.offset">{{ item.label }}</el-badge>
@ -114,4 +115,11 @@ watch(
<slot></slot> <slot></slot>
</el-tabs> </el-tabs>
</template> </template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.container {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@ -0,0 +1,361 @@
<template>
<el-dialog v-model="state.isShowDialog" width="900px" draggable :close-on-click-modal="false">
<template #header>
<div style="color: #fff">
<el-icon size="16" style="margin-right: 3px; display: inline; vertical-align: middle"> <ele-Edit /> </el-icon>
<span> 拍照 </span>
</div>
</template>
<el-scrollbar class="camera-container">
<div v-if="state.cameraError" class="error-container">
<el-alert :title="state.cameraError" type="error" show-icon />
<!-- 提示不支持访问摄像头的指导 -->
<div style="margin-top: 16px; text-align: left; background: #fdf6ec; border-radius: 4px; padding: 10px; font-size: 13px">
<h3>提示不支持访问摄像头的配置方法</h3>
<ol style="margin: 8px 0 0 18px; padding: 0">
<li class="mb15">
在浏览器地址栏输入
<a style="color: #409eff" href="javascript:void(0);" @click="() => comFunc.copyText(state.configUrl)">{{ state.configUrl }}</a>
并回车
</li>
<li class="mb15">
启用配置项并在输入框粘贴当前站点地址<a style="color: #409eff" href="javascript:void(0);" @click="() => comFunc.copyText(state.origin)">{{ state.origin }}</a>
<el-image :src="cameraConfig" style="max-width: 100%; margin-top: 8px" />
</li>
<li class="mb15">重启浏览器</li>
</ol>
</div>
<!-- 提示无权访问摄像头的指导 -->
<div style="margin-top: 16px; text-align: left; background: #f6f6f6; border-radius: 4px; padding: 10px; font-size: 13px">
<h3>提示无权访问摄像头的配置方法</h3>
<ol style="margin: 8px 0 0 18px; padding: 0">
<li class="mb15">点击浏览器地址栏左侧图标</li>
<li class="mb15">允许浏览器访问权限</li>
<el-image :src="cameraConfig2" style="max-width: 100%; margin-top: 8px" />
</ol>
</div>
</div>
<div v-show="!state.cameraError" class="content-container">
<!-- 左侧摄像头区域 -->
<div class="camera-panel">
<video ref="videoRef" autoplay playsinline muted width="400" height="300" style="background: #000"></video>
<canvas ref="canvasRef" style="display: none"></canvas>
</div>
<!-- 右侧预览区域 -->
<div class="preview-panel" style="width: 400px; height: 300px">
<div v-if="state.capturedImage" class="image-preview" style="width: 100%; height: 100%">
<el-image :src="state.capturedImage" fit="contain" :style="{ width: '100%', height: '100%' }" />
</div>
<div v-else class="placeholder">
<i class="el-icon-picture-outline" style="font-size: 48px; color: #c0c4cc"></i>
<p style="color: #909399; margin-top: 10px">暂无照片</p>
</div>
</div>
</div>
</el-scrollbar>
<template #footer>
<div class="dialog-footer">
<el-button @click="captureImage" v-if="!state.cameraError">拍照</el-button>
<el-button type="primary" :disabled="!state.capturedImage" @click="confirmImage" v-if="!state.cameraError">确认</el-button>
<el-button @click="closeDialog">取消</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts" setup name="CameraDialog">
import { reactive, ref, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import commonFunction from '/@/utils/commonFunction';
import cameraConfig from '/@/assets/camera-config.png';
import cameraConfig2 from '/@/assets/camera-config2.png';
import { log } from 'console';
//
interface Props {
imageWidth?: number;
imageHeight?: number;
watermarkText?: string; //
watermarkFontSize?: number; //
watermarkColor?: string; //
watermarkDensity?: number; //
showWatermark?: boolean; //
watermarkAngle?: number; //
}
const props = withDefaults(defineProps<Props>(), {
imageWidth: 600,
imageHeight: 400,
watermarkText: '水印',
watermarkFontSize: 20,
watermarkColor: 'rgba(255, 255, 255, 0.3)',
watermarkDensity: 0.5,
showWatermark: false,
watermarkAngle: -30,
});
const comFunc = commonFunction();
const videoRef = ref<HTMLVideoElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const streamRef = ref<MediaStream | null>(null);
const state = reactive({
configUrl: 'chrome://flags/#unsafely-treat-insecure-origin-as-secure',
origin: window.location.origin,
isShowDialog: false,
cameraError: '',
capturedImage: '',
});
//
const panelWidth = ref(400);
const panelHeight = ref(300);
const emits = defineEmits(['confirm']);
//
const openDialog = () => {
state.isShowDialog = true;
state.capturedImage = '';
state.cameraError = '';
//
const aspectRatio = props.imageWidth / props.imageHeight;
panelHeight.value = Math.round(panelWidth.value / aspectRatio);
setTimeout(() => {
initCamera();
}, 0);
};
//
const initCamera = async () => {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
state.cameraError = '当前浏览器不支持访问摄像头';
return;
}
const constraints = {
video: {
facingMode: 'environment',
width: { ideal: props.imageWidth },
height: { ideal: props.imageHeight },
},
audio: false,
};
streamRef.value = await navigator.mediaDevices.getUserMedia(constraints);
if (videoRef.value) {
videoRef.value.srcObject = streamRef.value;
}
} catch (err: any) {
console.error('获取摄像头权限失败:', err);
state.cameraError = '无法访问摄像头: ' + (err.message || '未知错误');
ElMessage.error('无法访问摄像头,请检查设备连接和权限设置');
}
};
//
const captureImage = () => {
if (!videoRef.value || !canvasRef.value) return;
const video = videoRef.value;
const canvas = canvasRef.value;
const context = canvas.getContext('2d');
if (!context) {
ElMessage.error('无法获取画布上下文');
return;
}
//
const targetWidth = props.imageWidth;
const targetHeight = props.imageHeight;
canvas.width = targetWidth;
canvas.height = targetHeight;
//
const videoWidth = video.videoWidth;
const videoHeight = video.videoHeight;
//
const targetRatio = targetWidth / targetHeight;
const videoRatio = videoWidth / videoHeight;
let cropWidth, cropHeight, sx, sy;
if (videoRatio > targetRatio) {
//
cropHeight = videoHeight;
cropWidth = cropHeight * targetRatio;
sx = (videoWidth - cropWidth) / 2;
sy = 0;
} else {
//
cropWidth = videoWidth;
cropHeight = cropWidth / targetRatio;
sx = 0;
sy = (videoHeight - cropHeight) / 2;
}
//
context.drawImage(video, sx, sy, cropWidth, cropHeight, 0, 0, targetWidth, targetHeight);
//
if (props.showWatermark) {
drawWatermark(context, targetWidth, targetHeight);
}
console.log(canvas.width, canvas.height);
// base64
state.capturedImage = canvas.toDataURL('image/jpeg', 0.9);
};
//
const drawWatermark = (context: CanvasRenderingContext2D, width: number, height: number) => {
const { watermarkText, watermarkFontSize, watermarkColor, watermarkAngle, watermarkDensity } = props;
//
context.save();
//
context.font = `bold ${watermarkFontSize}px Arial`;
context.fillStyle = watermarkColor;
context.textAlign = 'left';
context.textBaseline = 'middle';
//
const textMetrics = context.measureText(watermarkText);
const textWidth = textMetrics.width;
const textHeight = watermarkFontSize;
//
const density = Math.max(0.1, Math.min(1.0, watermarkDensity));
// 使
// 0.14
// 1.01.5
const spacingFactor = 5 - density * 3;
const spacingX = textWidth * spacingFactor;
const spacingY = textHeight * spacingFactor * 1.2;
//
const angleInRadians = (watermarkAngle * Math.PI) / 180;
//
context.translate(width / 2, height / 2);
context.rotate(angleInRadians);
//
const diagonal = Math.sqrt(width * width + height * height);
const startX = -diagonal / 2;
const endX = diagonal / 2;
const startY = -diagonal / 2;
const endY = diagonal / 2;
//
for (let y = startY; y < endY; y += spacingY) {
for (let x = startX; x < endX; x += spacingX) {
// 使
const adjustedX = x + (Math.floor((y - startY) / spacingY) % 2 === 0 ? 0 : spacingX / 2);
context.fillText(watermarkText, adjustedX, y);
}
}
//
context.restore();
};
// 使
const confirmImage = () => {
if (state.capturedImage) {
emits('confirm', state.capturedImage);
closeDialog();
}
};
//
const closeDialog = () => {
state.isShowDialog = false;
stopCamera();
};
//
const stopCamera = () => {
if (streamRef.value) {
const tracks = streamRef.value.getTracks();
tracks.forEach((track) => track.stop());
streamRef.value = null;
}
if (videoRef.value) {
videoRef.value.srcObject = null;
}
state.capturedImage = '';
};
//
onUnmounted(() => {
stopCamera();
});
defineExpose({ openDialog });
</script>
<style scoped lang="scss">
.camera-container {
text-align: center;
height: 100%;
max-height: 400px !important;
}
.content-container {
display: flex;
gap: 20px;
justify-content: center;
align-items: flex-start;
}
.camera-panel {
width: 400px;
height: 300px;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
background: #000;
display: flex;
justify-content: center;
align-items: center;
}
.preview-panel {
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
background: #f5f5f5;
display: flex;
justify-content: center;
align-items: center;
}
.placeholder {
text-align: center;
color: #909399;
}
.error-container {
padding: 20px;
text-align: center;
}
.dialog-footer {
text-align: right;
}
</style>

View File

@ -160,10 +160,6 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
/**
* 查询字符输入字符醒目展示
*/
allowCreate: Boolean,
/** /**
* 是否禁用 * 是否禁用
*/ */
@ -301,10 +297,10 @@ const selectVisibleChange = (visible: boolean) => {
// //
const setDefaultOptions = (options: any[]) => { const setDefaultOptions = (options: any[]) => {
const list = [] as any[]; const list = [] as any[];
for(const item of [...options ?? [], ...state.defaultOptions]) { for (const item of [...(options ?? []), ...state.defaultOptions]) {
const value = item?.[props.valueProp]; const value = item?.[props.valueProp];
const label = props.labelFormat?.(item) || item?.[props.labelProp]; const label = props.labelFormat?.(item) || item?.[props.labelProp];
if (value && label) list.push({ [props.valueProp]: value, [props.labelProp]: label }) if (value && label) list.push({ [props.valueProp]: value, [props.labelProp]: label });
} }
state.defaultOptions = Array.from(new Set(list)); state.defaultOptions = Array.from(new Set(list));
}; };
@ -332,8 +328,16 @@ const setValue = (option: any | any[], row: any) => {
emit('change', state.selectedValues, row); emit('change', state.selectedValues, row);
}; };
watch(() => props.modelValue, (val: any) => state.selectedValues = val, { immediate: true }); watch(
watch(() => props.defaultOptions, (val: any) => setDefaultOptions(val), { immediate: true }); () => props.modelValue,
(val: any) => (state.selectedValues = val),
{ immediate: true }
);
watch(
() => props.defaultOptions,
(val: any) => setDefaultOptions(val),
{ immediate: true }
);
defineExpose({ defineExpose({
setValue, setValue,
@ -345,21 +349,19 @@ defineExpose({
<template> <template>
<el-select <el-select
v-model="state.selectedValues" v-model="state.selectedValues"
:clearable="clearable" :clearable="clearable"
:multiple="multiple" :multiple="multiple"
:disabled="disabled" :disabled="disabled"
:placeholder="placeholder" :placeholder="placeholder"
:allow-create="allowCreate" :remote-method="remoteMethod"
:remote-method="remoteMethod" @visible-change="selectVisibleChange"
:default-first-option="allowCreate" :style="{ width: dropdownWidth }"
@visible-change="selectVisibleChange" popper-class="popper-class"
:style="{ width: dropdownWidth }" ref="selectRef"
popper-class="popper-class" remote-show-suffix
ref="selectRef" filterable
remote-show-suffix remote
filterable
remote
> >
<!-- 隐藏的选项用于占位 --> <!-- 隐藏的选项用于占位 -->
<el-option style="width: 0; height: 0" /> <el-option style="width: 0; height: 0" />
@ -383,11 +385,11 @@ defineExpose({
<!-- 数据表格 --> <!-- 数据表格 -->
<el-table <el-table
ref="tableRef" ref="tableRef"
@row-click="handleChange" @row-click="handleChange"
:data="state.tableData?.items ?? []" :data="state.tableData?.items ?? []"
:height="`calc(${dropdownHeight} - 175px${$slots.queryForm ? ` - ${queryHeightOffset}px` : ''}${state.tableQuery[keywordProp] && allowCreate ? ` - ${queryHeightOffset}px` : ''})`" :height="`calc(${dropdownHeight} - 175px${$slots.queryForm ? ` - ${queryHeightOffset}px` : ''}${state.tableQuery[keywordProp] && allowCreate ? ` - ${queryHeightOffset}px` : ''})`"
highlight-current-row highlight-current-row
> >
<template #empty><el-empty :image-size="25" /></template> <template #empty><el-empty :image-size="25" /></template>
<slot name="columns"></slot> <slot name="columns"></slot>
@ -395,22 +397,23 @@ defineExpose({
<!-- 分页组件 --> <!-- 分页组件 -->
<el-pagination <el-pagination
v-if="props.pagination" v-if="props.pagination"
:disabled="state.loading" :disabled="state.loading"
:currentPage="state.tableQuery.page" :currentPage="state.tableQuery.page"
:page-size="state.tableQuery.pageSize" :page-size="state.tableQuery.pageSize"
:total="state.tableData.total" :total="state.tableData.total"
:pager-count="4" :pager-count="4"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
layout="prev, pager, next" layout="prev, pager, next"
size="small" size="small"
background background
/> />
</div> </div>
</el-select> </el-select>
</template> </template>
<style scoped>
<style scoped lang="scss">
.query-form { .query-form {
z-index: 9999; z-index: 9999;
} }
@ -427,8 +430,7 @@ defineExpose({
:deep(.popper-class) :deep(.el-select-dropdown__wrap) { :deep(.popper-class) :deep(.el-select-dropdown__wrap) {
max-height: 600px !important; max-height: 600px !important;
} }
</style>
<style>
.popper-class .el-select-dropdown__wrap { .popper-class .el-select-dropdown__wrap {
max-height: 450px !important; max-height: 450px !important;
} }
@ -437,4 +439,4 @@ defineExpose({
.el-select-dropdown__wrap[max-height] { .el-select-dropdown__wrap[max-height] {
max-height: 450px !important; max-height: 450px !important;
} }
</style> </style>

View File

@ -36,13 +36,13 @@ type TagType = (typeof TAG_TYPES)[number];
* @property {string|number} [value] - * @property {string|number} [value] -
*/ */
interface DictItem { interface DictItem {
[key: string]: any; [key: string]: any;
tagType?: TagType; tagType?: TagType;
styleSetting?: string; styleSetting?: string;
classSetting?: string; classSetting?: string;
disabled?: boolean; disabled?: boolean;
label?: string; label?: string;
value?: string | number; value?: string | number;
} }
/** /**
@ -52,8 +52,8 @@ interface DictItem {
* @property {Array<string|number>} excludes - 被互斥的选项值列表 * @property {Array<string|number>} excludes - 被互斥的选项值列表
*/ */
interface MutexConfig { interface MutexConfig {
value: string | number; value: string | number;
excludes: (string | number)[]; excludes: (string | number)[];
} }
/** /**
@ -63,8 +63,8 @@ interface MutexConfig {
* @property {string} Comma - 逗号分隔模式'1,2,3' * @property {string} Comma - 逗号分隔模式'1,2,3'
*/ */
const MultipleModel = { const MultipleModel = {
Array: 'array', Array: 'array',
Comma: 'comma', Comma: 'comma',
} as const; } as const;
// //
@ -77,7 +77,7 @@ type MultipleModelType = (typeof MultipleModel)[keyof typeof MultipleModel];
* @returns {value is RenderType} - 是否为合法的渲染类型 * @returns {value is RenderType} - 是否为合法的渲染类型
*/ */
function isRenderType(value: any): value is RenderType { function isRenderType(value: any): value is RenderType {
return RENDER_TYPES.includes(value); return RENDER_TYPES.includes(value);
} }
/** /**
@ -87,7 +87,7 @@ function isRenderType(value: any): value is RenderType {
* @returns {value is MultipleModel} - 是否为合法的多选模式 * @returns {value is MultipleModel} - 是否为合法的多选模式
*/ */
function isMultipleModel(value: any): value is MultipleModelType { function isMultipleModel(value: any): value is MultipleModelType {
return Object.values(MultipleModel).includes(value); return Object.values(MultipleModel).includes(value);
} }
</script> </script>
@ -103,147 +103,147 @@ const emit = defineEmits(['update:modelValue', 'change']);
* 组件属性定义 * 组件属性定义
*/ */
const props = defineProps({ const props = defineProps({
/** /**
* 绑定值支持多种类型 * 绑定值支持多种类型
* @type {string|number|boolean|Array|null} * @type {string|number|boolean|Array|null}
* @required * @required
* @example * @example
* // * //
* <g-sys-dict v-model="selectedValue" code="gender" renderAs="select" /> * <g-sys-dict v-model="selectedValue" code="gender" renderAs="select" />
* *
* // * //
* <g-sys-dict v-model="selectedValues" code="roles" renderAs="select" multiple /> * <g-sys-dict v-model="selectedValues" code="roles" renderAs="select" multiple />
* *
* // * //
* <g-sys-dict v-model="selectedValues" code="roles" renderAs="select" multiple multiple-model="comma" /> * <g-sys-dict v-model="selectedValues" code="roles" renderAs="select" multiple multiple-model="comma" />
*/ */
modelValue: { modelValue: {
type: [String, Number, Boolean, Array, null] as PropType<string | number | boolean | any[] | Nullable>, type: [String, Number, Boolean, Array, null] as PropType<string | number | boolean | any[] | Nullable>,
default: null, default: null,
required: true, required: true,
}, },
/** /**
* 字典编码用于从字典中获取数据 * 字典编码用于从字典中获取数据
* @type {string} * @type {string}
* @required * @required
* @example 'gender' * @example 'gender'
*/ */
code: { code: {
type: String, type: String,
required: false, required: false,
}, },
/** /**
* 直接传入的字典数据源优先级高于code * 直接传入的字典数据源优先级高于code
* @type {DictItem[]} * @type {DictItem[]}
* @example [{ label: '选项1', value: '1' }, { label: '选项2', value: '2' }] * @example [{ label: '选项1', value: '1' }, { label: '选项2', value: '2' }]
*/ */
data: { data: {
type: Array as PropType<DictItem[]>, type: Array as PropType<DictItem[]>,
default: () => [], default: () => [],
}, },
/** /**
* 是否为常量字典true从常量列表获取false从字典列表获取 * 是否为常量字典true从常量列表获取false从字典列表获取
* @type {boolean} * @type {boolean}
* @default false * @default false
* @example true * @example true
*/ */
isConst: { isConst: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
/** /**
* 字典项中用于显示的字段名 * 字典项中用于显示的字段名
* @type {string} * @type {string}
* @default 'label' * @default 'label'
* @example 'name' * @example 'name'
*/ */
propLabel: { propLabel: {
type: String, type: String,
default: 'label', default: 'label',
}, },
/** /**
* 字典项中用于取值的字段名 * 字典项中用于取值的字段名
* @type {string} * @type {string}
* @default 'value' * @default 'value'
* @example 'id' * @example 'id'
*/ */
propValue: { propValue: {
type: String, type: String,
default: 'value', default: 'value',
}, },
/** /**
* 字典项过滤函数 * 字典项过滤函数
* @type {Function} * @type {Function}
* @param {DictItem} dict - 当前字典项 * @param {DictItem} dict - 当前字典项
* @returns {boolean} - 是否保留该项 * @returns {boolean} - 是否保留该项
* @default (dict) => true * @default (dict) => true
* @example * @example
* // * //
* :onItemFilter="(dict) => dict.status === 1" * :onItemFilter="(dict) => dict.status === 1"
*/ */
onItemFilter: { onItemFilter: {
type: Function as PropType<(dict: DictItem) => boolean>, type: Function as PropType<(dict: DictItem) => boolean>,
default: () => true, default: () => true,
}, },
/** /**
* 字典项显示内容格式化函数 * 字典项显示内容格式化函数
* @type {Function} * @type {Function}
* @param {DictItem} dict - 当前字典项 * @param {DictItem} dict - 当前字典项
* @returns {string|undefined|null} - 格式化后的显示内容 * @returns {string|undefined|null} - 格式化后的显示内容
* @default () => undefined * @default () => undefined
* @example * @example
* // * //
* :onItemFormatter="(dict) => `${dict.label} <icon-user />`" * :onItemFormatter="(dict) => `${dict.label} <icon-user />`"
*/ */
onItemFormatter: { onItemFormatter: {
type: Function as PropType<(dict: DictItem) => string | undefined | null>, type: Function as PropType<(dict: DictItem) => string | undefined | null>,
default: () => undefined, default: () => undefined,
}, },
/** /**
* 组件渲染方式 * 组件渲染方式
* @type {'tag'|'select'|'radio'|'checkbox'|'radio-button'} * @type {'tag'|'select'|'radio'|'checkbox'|'radio-button'}
* @default 'tag' * @default 'tag'
* @example 'select' * @example 'select'
*/ */
renderAs: { renderAs: {
type: String as PropType<RenderType>, type: String as PropType<RenderType>,
default: 'tag', default: 'tag',
validator: isRenderType, validator: isRenderType,
}, },
/** /**
* 是否多选仅在renderAs为select/checkbox时有效 * 是否多选仅在renderAs为select/checkbox时有效
* @type {boolean} * @type {boolean}
* @default false * @default false
* @example true * @example true
*/ */
multiple: { multiple: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
/** /**
* 多选值模式仅在multiple为true时有效 * 多选值模式仅在multiple为true时有效
* @type {'array'|'comma'} * @type {'array'|'comma'}
* @default 'array' * @default 'array'
* @example 'comma' * @example 'comma'
*/ */
multipleModel: { multipleModel: {
type: String as PropType<MultipleModelType>, type: String as PropType<MultipleModelType>,
default: MultipleModel.Array, default: MultipleModel.Array,
validator: isMultipleModel, validator: isMultipleModel,
}, },
/** /**
* 互斥配置项仅在多选模式下有效 * 互斥配置项仅在多选模式下有效
* @type {Array<MutexConfig>} * @type {Array<MutexConfig>}
* @example * @example
* :mutex-configs="[ * :mutex-configs="[
* { value: 'all', excludes: ['1', '2', '3'] }, * { value: 'all', excludes: ['1', '2', '3'] },
* { value: '1', excludes: ['all'] } * { value: '1', excludes: ['all'] }
* ]" * ]"
*/ */
mutexConfigs: { mutexConfigs: {
type: Array as PropType<MutexConfig[]>, type: Array as PropType<MutexConfig[]>,
default: () => [], default: () => [],
}, },
}); });
/** /**
@ -252,9 +252,9 @@ const props = defineProps({
* @property {any} value - 当前值 * @property {any} value - 当前值
*/ */
const state = reactive({ const state = reactive({
dictData: [] as DictItem[], dictData: [] as DictItem[],
value: props.modelValue, value: props.modelValue,
conversion: false, conversion: false,
}); });
/** /**
@ -263,26 +263,26 @@ const state = reactive({
* @returns {DictItem[]} - 过滤并格式化后的字典数据 * @returns {DictItem[]} - 过滤并格式化后的字典数据
*/ */
const formattedDictData = computed(() => { const formattedDictData = computed(() => {
const baseData = state.dictData.filter(props.onItemFilter).map((item) => ({ const baseData = state.dictData.filter(props.onItemFilter).map((item) => ({
...item, ...item,
label: item[props.propLabel], label: item[props.propLabel],
value: item[props.propValue], value: item[props.propValue],
})); }));
// //
if (!props.multiple || !props.mutexConfigs || props.mutexConfigs.length === 0) { if (!props.multiple || !props.mutexConfigs || props.mutexConfigs.length === 0) {
return baseData; return baseData;
} }
// //
return baseData.map((item) => { return baseData.map((item) => {
// //
const isDisabled = isItemDisabled(item.value, state.value, props.mutexConfigs); const isDisabled = isItemDisabled(item.value, state.value, props.mutexConfigs);
return { return {
...item, ...item,
disabled: isDisabled || item.disabled, // disabled disabled: isDisabled || item.disabled, // disabled
}; };
}); });
}); });
/** /**
@ -291,15 +291,15 @@ const formattedDictData = computed(() => {
* @returns {DictItem|DictItem[]|null} - 当前选中的字典项或字典项数组 * @returns {DictItem|DictItem[]|null} - 当前选中的字典项或字典项数组
*/ */
const currentDictItems = computed(() => { const currentDictItems = computed(() => {
if (!state.value) return null; if (!state.value) return null;
if (Array.isArray(state.value)) { if (Array.isArray(state.value)) {
// //
const uniqueValues = [...new Set(state.value)]; const uniqueValues = [...new Set(state.value)];
return formattedDictData.value.filter((item) => uniqueValues.includes(item.value)); return formattedDictData.value.filter((item) => uniqueValues.includes(item.value));
} }
return formattedDictData.value.find((item) => item.value == state.value) || null; return formattedDictData.value.find((item) => item.value == state.value) || null;
}); });
/** /**
@ -309,34 +309,34 @@ const currentDictItems = computed(() => {
* @throws {Error} - 获取数据失败时抛出错误 * @throws {Error} - 获取数据失败时抛出错误
*/ */
const getDataList = (): DictItem[] => { const getDataList = (): DictItem[] => {
try { try {
// data 使 // data 使
if (props.data && props.data.length > 0) { if (props.data && props.data.length > 0) {
return props.data.map((item: any) => ({ return props.data.map((item: any) => ({
...item, ...item,
label: item[props.propLabel] ?? [item.name, item.desc].filter((x) => x).join('-'), label: item[props.propLabel] ?? [item.name, item.desc].filter((x) => x).join('-'),
value: item[props.propValue] ?? item.code, value: item[props.propValue] ?? item.code,
})); }));
} }
if (!props.code) { if (!props.code) {
console.error('[g-sys-dict] code和data不能同时为空'); console.error('[g-sys-dict] code和data不能同时为空');
return []; return [];
} }
const source = props.isConst ? userStore.constList : userStore.dictList; const source = props.isConst ? userStore.constList : userStore.dictList;
const data = props.isConst ? (source?.find((x: any) => x.code === props.code)?.data?.result ?? []) : (source[props.code] ?? []); const data = props.isConst ? (source?.find((x: any) => x.code === props.code)?.data?.result ?? []) : (source[props.code] ?? []);
data.sort((a: number, b: number) => a - b); data.sort((a: number, b: number) => a - b);
return data.map((item: any) => ({ return data.map((item: any) => ({
...item, ...item,
label: item[props.propLabel] ?? [item.name, item.desc].filter((x) => x).join('-'), label: item[props.propLabel] ?? [item.name, item.desc].filter((x) => x).join('-'),
value: item[props.propValue] ?? item.code, value: item[props.propValue] ?? item.code,
})); }));
} catch (error) { } catch (error) {
console.error(`[g-sys-dict] 获取字典[${props.code}]数据失败:`, error); console.error(`[g-sys-dict] 获取字典[${props.code}]数据失败:`, error);
return []; return [];
} }
}; };
/** /**
@ -345,13 +345,13 @@ const getDataList = (): DictItem[] => {
* @param {any} value - 待处理的值 * @param {any} value - 待处理的值
*/ */
const processNumericValues = (value: any) => { const processNumericValues = (value: any) => {
if (typeof value === 'number' || (Array.isArray(value) && typeof value[0] === 'number')) { if (typeof value === 'number' || (Array.isArray(value) && typeof value[0] === 'number')) {
state.dictData.forEach((item) => { state.dictData.forEach((item) => {
if (item.value) { if (item.value) {
item.value = Number(item.value); item.value = Number(item.value);
} }
}); });
} }
}; };
/** /**
@ -361,48 +361,48 @@ const processNumericValues = (value: any) => {
* @returns {any} - 解析后的值 * @returns {any} - 解析后的值
*/ */
const parseMultipleValue = (value: any): any => { const parseMultipleValue = (value: any): any => {
// //
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
return props.multiple ? [] : value; return props.multiple ? [] : value;
} }
// //
if (typeof value === 'number' && !state.conversion) { if (typeof value === 'number' && !state.conversion) {
try { try {
state.dictData.forEach((item) => { state.dictData.forEach((item) => {
if (item.value) item.value = Number(item.value); if (item.value) item.value = Number(item.value);
}); });
state.conversion = true; state.conversion = true;
} catch (error) { } catch (error) {
console.warn('[g-sys-dict] 数字转换失败:', error); console.warn('[g-sys-dict] 数字转换失败:', error);
} }
} }
// //
if (typeof value === 'string') { if (typeof value === 'string') {
const trimmedValue = value.trim(); const trimmedValue = value.trim();
// JSON // JSON
if (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')) { if (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')) {
try { try {
return JSON.parse(trimmedValue); return JSON.parse(trimmedValue);
} catch (error) { } catch (error) {
console.warn('[g-sys-dict] 解析多选值失败:', error); console.warn('[g-sys-dict] 解析多选值失败:', error);
return []; return [];
} }
} }
// //
if (props.multipleModel === MultipleModel.Comma && trimmedValue.includes(',')) { if (props.multipleModel === MultipleModel.Comma && trimmedValue.includes(',')) {
return trimmedValue.split(','); return trimmedValue.split(',');
} }
// //
return props.multiple ? [trimmedValue] : trimmedValue; return props.multiple ? [trimmedValue] : trimmedValue;
} }
// //
return value; return value;
}; };
/** /**
@ -414,28 +414,28 @@ const parseMultipleValue = (value: any): any => {
* @returns {boolean} - 是否应该禁用 * @returns {boolean} - 是否应该禁用
*/ */
const isItemDisabled = (itemValue: string | number, currentValue: any, mutexConfigs: MutexConfig[]): boolean => { const isItemDisabled = (itemValue: string | number, currentValue: any, mutexConfigs: MutexConfig[]): boolean => {
// //
if (!mutexConfigs || mutexConfigs.length === 0) { if (!mutexConfigs || mutexConfigs.length === 0) {
return false; return false;
} }
// //
const selectedValues = Array.isArray(currentValue) ? currentValue : currentValue ? [currentValue] : []; const selectedValues = Array.isArray(currentValue) ? currentValue : currentValue ? [currentValue] : [];
// //
for (const config of mutexConfigs) { for (const config of mutexConfigs) {
// //
if (selectedValues.includes(config.value) && config.excludes.includes(itemValue)) { if (selectedValues.includes(config.value) && config.excludes.includes(itemValue)) {
return true; return true;
} }
// //
if (itemValue == config.value && config.excludes.some((exclude) => selectedValues.includes(exclude))) { if (itemValue == config.value && config.excludes.some((exclude) => selectedValues.includes(exclude))) {
return true; return true;
} }
} }
return false; return false;
}; };
/** /**
@ -446,20 +446,20 @@ const isItemDisabled = (itemValue: string | number, currentValue: any, mutexConf
* @returns {any} - 处理后的值 * @returns {any} - 处理后的值
*/ */
const handleMutex = (newValue: any, mutexConfigs: MutexConfig[]): any => { const handleMutex = (newValue: any, mutexConfigs: MutexConfig[]): any => {
// //
if (!mutexConfigs || mutexConfigs.length === 0) return newValue; if (!mutexConfigs || mutexConfigs.length === 0) return newValue;
// //
if (!props.multiple) return newValue; if (!props.multiple) return newValue;
// //
// formattedDictData // formattedDictData
let resultValue = Array.isArray(newValue) ? [...newValue] : newValue ? [newValue] : []; let resultValue = Array.isArray(newValue) ? [...newValue] : newValue ? [newValue] : [];
// //
const validValues = formattedDictData.value.filter((item) => !item.disabled).map((item) => item.value); const validValues = formattedDictData.value.filter((item) => !item.disabled).map((item) => item.value);
return resultValue.filter((val) => validValues.includes(val)); return resultValue.filter((val) => validValues.includes(val));
}; };
/** /**
@ -468,30 +468,31 @@ const handleMutex = (newValue: any, mutexConfigs: MutexConfig[]): any => {
* @param {any} newValue - 新值 * @param {any} newValue - 新值
*/ */
const updateValue = (newValue: any) => { const updateValue = (newValue: any) => {
// //
let processedValue = newValue; let processedValue = newValue;
if (props.mutexConfigs && props.mutexConfigs.length > 0) { if (props.mutexConfigs && props.mutexConfigs.length > 0) {
processedValue = handleMutex(newValue, props.mutexConfigs); processedValue = handleMutex(newValue, props.mutexConfigs);
} }
let emitValue = processedValue; let emitValue = processedValue;
if (props.multipleModel === MultipleModel.Comma) { if (props.multipleModel === MultipleModel.Comma) {
if (Array.isArray(processedValue)) { if (Array.isArray(processedValue)) {
emitValue = processedValue.length > 0 ? processedValue.sort().join(',') : []; emitValue = processedValue.length > 0 ? processedValue.sort().join(',') : [];
} else if (processedValue === null || processedValue === undefined) { } else if (processedValue === null || processedValue === undefined) {
emitValue = undefined; emitValue = undefined;
} }
} else { } else {
if (Array.isArray(processedValue)) { if (Array.isArray(processedValue)) {
emitValue = processedValue.length > 0 ? processedValue.sort() : []; emitValue = processedValue.length > 0 ? processedValue.sort() : [];
} else if (processedValue === null || processedValue === undefined) { } else if (processedValue === null || processedValue === undefined) {
emitValue = undefined; emitValue = undefined;
} }
} }
console.log('更新值:', { newValue, processedValue, emitValue });
state.value = processedValue; state.value = processedValue;
emit('update:modelValue', emitValue === '' || emitValue.length === 0 ? undefined : emitValue); emit('update:modelValue', emitValue === '' || emitValue?.length === 0 ? undefined : emitValue);
emit('change', state.value, currentDictItems, state.dictData); emit('change', state.value, currentDictItems, state.dictData);
}; };
/** /**
@ -501,7 +502,7 @@ const updateValue = (newValue: any) => {
* @returns {TagType} - 合法的标签类型 * @returns {TagType} - 合法的标签类型
*/ */
const ensureTagType = (item: DictItem): TagType => { const ensureTagType = (item: DictItem): TagType => {
return TAG_TYPES.includes(item.tagType as TagType) ? (item.tagType as TagType) : 'primary'; return TAG_TYPES.includes(item.tagType as TagType) ? (item.tagType as TagType) : 'primary';
}; };
/** /**
@ -511,9 +512,9 @@ const ensureTagType = (item: DictItem): TagType => {
* @returns {string} - 显示文本 * @returns {string} - 显示文本
*/ */
const getDisplayText = (dict?: DictItem): string => { const getDisplayText = (dict?: DictItem): string => {
if (!dict) return String(state.value || ''); if (!dict) return String(state.value || '');
const formattedText = props.onItemFormatter?.(dict); const formattedText = props.onItemFormatter?.(dict);
return formattedText ?? dict[props.propLabel] ?? ''; return formattedText ?? dict[props.propLabel] ?? '';
}; };
/** /**
@ -521,20 +522,20 @@ const getDisplayText = (dict?: DictItem): string => {
* @function * @function
*/ */
const initData = () => { const initData = () => {
// code data // code data
if (!props.code && (!props.data || props.data.length === 0)) { if (!props.code && (!props.data || props.data.length === 0)) {
console.error('[g-sys-dict] code和data不能同时为空'); console.error('[g-sys-dict] code和data不能同时为空');
state.dictData = []; state.dictData = [];
state.value = props.multiple ? [] : null; state.value = props.multiple ? [] : null;
return; return;
} }
state.dictData = getDataList(); state.dictData = getDataList();
processNumericValues(props.modelValue); processNumericValues(props.modelValue);
const initialValue = parseMultipleValue(props.modelValue); const initialValue = parseMultipleValue(props.modelValue);
if (initialValue !== state.value) { if (initialValue !== state.value) {
state.value = initialValue; state.value = initialValue;
} }
}; };
/** /**
@ -542,78 +543,78 @@ const initData = () => {
* @function * @function
*/ */
const validateInitialValue = () => { const validateInitialValue = () => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (props.renderAs === 'tag' || !state.value) return resolve(undefined); if (props.renderAs === 'tag' || !state.value) return resolve(undefined);
if (Array.isArray(state.value)) { if (Array.isArray(state.value)) {
const errorValues = state.value.filter((val) => state.dictData.find((e) => e[props.propValue] == val) === undefined); const errorValues = state.value.filter((val) => state.dictData.find((e) => e[props.propValue] == val) === undefined);
if (errorValues && errorValues.length > 0) { if (errorValues && errorValues.length > 0) {
reject(`[g-sys-dict] 未匹配到选项值:${JSON.stringify(errorValues)}`); reject(`[g-sys-dict] 未匹配到选项值:${JSON.stringify(errorValues)}`);
} }
} else if (state.value) { } else if (state.value) {
if (!state.dictData.find((e) => e[props.propValue] == state.value)) { if (!state.dictData.find((e) => e[props.propValue] == state.value)) {
reject(`[g-sys-dict] 未匹配到选项值:${state.value}`); reject(`[g-sys-dict] 未匹配到选项值:${state.value}`);
} }
} }
resolve(undefined); resolve(undefined);
}); });
}; };
// //
watch( watch(
() => props.modelValue, () => props.modelValue,
(newValue) => { (newValue) => {
state.value = parseMultipleValue(newValue); state.value = parseMultipleValue(newValue);
validateInitialValue(); validateInitialValue();
} }
); );
watch(() => [userStore.dictList, userStore.constList, props.data, state], initData, { immediate: true }); watch(() => [userStore.dictList, userStore.constList, props.data, state], initData, { immediate: true });
</script> </script>
<template> <template>
<!-- 渲染标签 --> <!-- 渲染标签 -->
<template v-if="props.renderAs === 'tag'"> <template v-if="props.renderAs === 'tag'">
<template v-if="Array.isArray(currentDictItems)"> <template v-if="Array.isArray(currentDictItems)">
<el-tag v-for="(item, index) in currentDictItems" :key="index" v-bind="$attrs" :type="ensureTagType(item)" :style="item.styleSetting" :class="item.classSetting" class="mr2"> <el-tag v-for="(item, index) in currentDictItems" :key="index" v-bind="$attrs" :type="ensureTagType(item)" :style="item.styleSetting" :class="item.classSetting" class="mr2">
{{ getDisplayText(item) }} {{ getDisplayText(item) }}
</el-tag> </el-tag>
</template> </template>
<template v-else> <template v-else>
<el-tag v-if="currentDictItems" v-bind="$attrs" :type="ensureTagType(currentDictItems)" :style="currentDictItems.styleSetting" :class="currentDictItems.classSetting"> <el-tag v-if="currentDictItems" v-bind="$attrs" :type="ensureTagType(currentDictItems)" :style="currentDictItems.styleSetting" :class="currentDictItems.classSetting">
{{ getDisplayText(currentDictItems) }} {{ getDisplayText(currentDictItems) }}
</el-tag> </el-tag>
<span v-else>{{ getDisplayText() }}</span> <span v-else>{{ getDisplayText() }}</span>
</template> </template>
</template> </template>
<!-- 渲染选择器 --> <!-- 渲染选择器 -->
<el-select v-else-if="props.renderAs === 'select'" v-model="state.value" v-bind="$attrs" :multiple="props.multiple" @change="updateValue" filterable allow-create default-first-option clearable> <el-select v-else-if="props.renderAs === 'select'" v-model="state.value" v-bind="$attrs" :multiple="props.multiple" @change="updateValue" filterable allow-create default-first-option clearable>
<el-option v-for="(item, index) in formattedDictData" :key="index" :label="getDisplayText(item)" :value="item.value" :disabled="item.disabled" /> <el-option v-for="(item, index) in formattedDictData" :key="index" :label="getDisplayText(item)" :value="item.value" :disabled="item.disabled" />
</el-select> </el-select>
<!-- 多选框多选 --> <!-- 多选框多选 -->
<el-checkbox-group v-else-if="props.renderAs === 'checkbox'" v-model="state.value" v-bind="$attrs" @change="updateValue"> <el-checkbox-group v-else-if="props.renderAs === 'checkbox'" v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-checkbox v-for="(item, index) in formattedDictData" :key="index" :value="item.value" :label="getDisplayText(item)" :disabled="item.disabled" /> <el-checkbox v-for="(item, index) in formattedDictData" :key="index" :value="item.value" :label="getDisplayText(item)" :disabled="item.disabled" />
</el-checkbox-group> </el-checkbox-group>
<!-- 多选框-按钮多选 --> <!-- 多选框-按钮多选 -->
<el-checkbox-group v-else-if="props.renderAs === 'checkbox-button'" v-model="state.value" v-bind="$attrs" @change="updateValue"> <el-checkbox-group v-else-if="props.renderAs === 'checkbox-button'" v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-checkbox-button v-for="(item, index) in formattedDictData" :key="index" :value="item.value" :disabled="item.disabled"> <el-checkbox-button v-for="(item, index) in formattedDictData" :key="index" :value="item.value" :disabled="item.disabled">
{{ getDisplayText(item) }} {{ getDisplayText(item) }}
</el-checkbox-button> </el-checkbox-button>
</el-checkbox-group> </el-checkbox-group>
<!-- 渲染单选框 --> <!-- 渲染单选框 -->
<el-radio-group v-else-if="props.renderAs === 'radio'" v-model="state.value" v-bind="$attrs" @change="updateValue"> <el-radio-group v-else-if="props.renderAs === 'radio'" v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-radio v-for="(item, index) in formattedDictData" :key="index" :value="item.value"> <el-radio v-for="(item, index) in formattedDictData" :key="index" :value="item.value">
{{ getDisplayText(item) }} {{ getDisplayText(item) }}
</el-radio> </el-radio>
</el-radio-group> </el-radio-group>
<!-- 渲染单选框按钮 --> <!-- 渲染单选框按钮 -->
<el-radio-group v-else-if="props.renderAs === 'radio-button'" v-model="state.value" v-bind="$attrs" @change="updateValue"> <el-radio-group v-else-if="props.renderAs === 'radio-button'" v-model="state.value" v-bind="$attrs" @change="updateValue">
<el-radio-button v-for="(item, index) in formattedDictData" :key="index" :value="item.value"> <el-radio-button v-for="(item, index) in formattedDictData" :key="index" :value="item.value">
{{ getDisplayText(item) }} {{ getDisplayText(item) }}
</el-radio-button> </el-radio-button>
</el-radio-group> </el-radio-group>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss"></style>

View File

@ -56,6 +56,7 @@ import { Session } from '/@/utils/storage';
import { isObjectValueEqual } from '/@/utils/arrayOperation'; import { isObjectValueEqual } from '/@/utils/arrayOperation';
import other from '/@/utils/other'; import other from '/@/utils/other';
import mittBus from '/@/utils/mitt'; import mittBus from '/@/utils/mitt';
import { RefType, RouteItem, RouteItems, RouteToFrom, WheelEventType } from '/@/types/global';
// //
const Contextmenu = defineAsyncComponent(() => import('/@/layout/navBars/tagsView/contextmenu.vue')); const Contextmenu = defineAsyncComponent(() => import('/@/layout/navBars/tagsView/contextmenu.vue'));

View File

@ -364,39 +364,41 @@
} }
.el-tree { .el-tree {
--el-tree-node-content-height: 30px; --el-tree-node-content-height: 30px;
} }
.el-table .el-table__cell { .el-table .el-table__cell {
&:has(.cell .el-button, .el-tag, .el-switch, .el-avatar) { &:has(.cell .el-button, .el-tag, .el-switch, .el-avatar) {
padding: 0; padding: 0;
.cell { text-overflow: clip; } .cell {
} text-overflow: clip;
}
}
.el-button.is-text { .el-button.is-text {
height: 20px; height: 20px;
} }
.el-text--large { .el-text--large {
font-size: var(--el-font-size-base);; font-size: var(--el-font-size-base);
} }
.el-button--large { .el-button--large {
--el-button-size: 32px; --el-button-size: 32px;
} }
.el-tag--large { .el-tag--large {
height: 28px; height: 28px;
} }
.el-button--default { .el-button--default {
height: 28px; height: 28px;
} }
.el-button [class*=el-icon]+span{ .el-button [class*='el-icon'] + span {
margin-left: 4px; margin-left: 4px;
} }
} }
.el-table [class*=el-table__row--level] .el-table__expand-icon { .el-table [class*='el-table__row--level'] .el-table__expand-icon {
height: 14px; height: 14px;
line-height: 14px; line-height: 14px;
width: 14px; width: 14px;
} }
/* Card 卡片 /* Card 卡片
@ -418,6 +420,8 @@
.el-table { .el-table {
// 表头背景色 // 表头背景色
--el-table-header-bg-color: var(--next-bg-main-color); --el-table-header-bg-color: var(--next-bg-main-color);
// 当前行背景色
// --el-table-current-row-bg-color: #cbd8e4;
.el-button.is-text { .el-button.is-text {
padding: 0; padding: 0;

402
Web/src/utils/formRule.ts Normal file
View File

@ -0,0 +1,402 @@
import type { FormItemRule } from 'element-plus';
import { ElMessage } from 'element-plus';
const TRIGGER_INPUT: FormItemRule['trigger'] = 'change';
const TRIGGER_BLUR: FormItemRule['trigger'] = 'blur';
export function useFormRulePresets() {
const required = (label = '此项', trigger: FormItemRule['trigger'] = TRIGGER_BLUR): FormItemRule => ({
required: true,
message: `${label}为必填项`,
trigger,
});
const requiredIf = (required?: boolean, label = '此项', trigger: FormItemRule['trigger'] = TRIGGER_BLUR): FormItemRule => ({
required: required ?? false,
message: `${label}为必填项`,
trigger,
});
const maxLen = (len: number, label = '此项', trigger = TRIGGER_INPUT): FormItemRule => ({
max: len,
message: `${label}长度不能超过 ${len} 个字符`,
trigger,
});
const minLen = (len: number, label = '此项', trigger = TRIGGER_INPUT): FormItemRule => ({
min: len,
message: `${label}长度不能少于 ${len} 个字符`,
trigger,
});
const pattern = (re: RegExp, msg: string, trigger = TRIGGER_BLUR): FormItemRule => ({
pattern: re,
message: msg,
trigger,
});
const validator = (fn: (value: any) => true | string | Promise<true | string>, trigger = TRIGGER_BLUR): FormItemRule => ({
trigger,
validator: (_rule, value, callback) => {
Promise.resolve(fn(value))
.then((res) => {
if (res === true) return callback();
return callback(new Error(res));
})
.catch((err) => callback(err instanceof Error ? err : new Error(String(err))));
},
});
const phone = () => pattern(/^1[3,4,5,6,7,8,9][0-9]{9}$/, '请输入有效的手机号');
const idCard = () =>
validator((value) => {
if (!value) return true; // 如果为空让required规则处理
const idCard = String(value).trim();
// 15位身份证号验证老身份证
if (idCard.length === 15) {
const reg15 = /^[1-9]\d{5}\d{2}((0[1-9])|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$/;
if (!reg15.test(idCard)) {
return '请输入有效的15位身份证号';
}
return true;
}
// 18位身份证号验证新身份证
if (idCard.length === 18) {
const reg18 = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}(\d|X|x)$/;
if (!reg18.test(idCard)) {
return '请输入有效的18位身份证号';
}
// 验证校验码
const factor = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const parity = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
let sum = 0;
let ai = 0;
let wi = 0;
for (let i = 0; i < 17; i++) {
ai = parseInt(idCard[i]);
wi = factor[i];
sum += ai * wi;
}
const last = parity[sum % 11];
if (last !== idCard[17].toUpperCase()) {
return '身份证号校验码错误';
}
return true;
}
return '身份证号必须是15位或18位';
}, TRIGGER_BLUR);
const email = () => pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, '请输入有效的邮箱地址');
const digits = (label = '此项', trigger = TRIGGER_INPUT) => pattern(/^\d+$/, `${label}需为纯数字`, trigger);
const intRange = (min: number, max: number, label = '数值') =>
validator((v) => {
if (v === '' || v === undefined || v === null) return `请输入${label}`;
if (!Number.isInteger(Number(v))) return `${label}需为整数`;
const n = Number(v);
if (n < min || n > max) return `${label}需在 ${min}~${max} 之间`;
return true;
}, TRIGGER_INPUT);
const confirmSame = (getOther: () => any, label = '两次输入') => validator((v) => (v === getOther() ? true : `${label}不一致`), TRIGGER_INPUT);
const toDate = (v: any): Date | null => {
if (v instanceof Date && !isNaN(v.getTime())) return v;
if (typeof v === 'number') {
const d = new Date(v);
return isNaN(d.getTime()) ? null : d;
}
if (typeof v === 'string' && v) {
const d = new Date(v);
return isNaN(d.getTime()) ? null : d;
}
return null;
};
const dateAfterToday = (label = '日期', trigger = TRIGGER_INPUT) =>
validator((v) => {
if (v === '' || v === undefined || v === null) return true;
const d = toDate(v);
if (!d) return `请选择有效的${label}`;
const today = new Date();
today.setHours(0, 0, 0, 0);
return d.getTime() > today.getTime() ? true : `${label}必须在今天之后`;
}, trigger);
const dateBeforeToday = (label = '日期', trigger = TRIGGER_INPUT) =>
validator((v) => {
if (v === '' || v === undefined || v === null) return true;
const d = toDate(v);
if (!d) return `请选择有效的${label}`;
const today = new Date();
today.setHours(0, 0, 0, 0);
return d.getTime() < today.getTime() ? true : `${label}必须在今天之前`;
}, trigger);
const numberPrecision = (maxIntDigits: number | null, maxFracDigits: number | null, label = '此项') =>
validator((v) => {
if (v === '' || v === undefined || v === null) return true;
const str = String(v).trim();
// 允许 0、整数或小数正数不允许负数与多余小数点
if (!/^\d+(?:\.\d+)?$/.test(str)) {
return `${label}必须为数字`;
}
const [intPart, fracPart = ''] = str.split('.');
if (maxIntDigits !== null) {
if (intPart.length > maxIntDigits) {
return `${label}整数位不能超过${maxIntDigits}`;
}
}
if (fracPart && maxFracDigits !== null && fracPart.length > maxFracDigits) {
if (maxFracDigits === 0) {
return `${label}必须为整数`;
}
return `${label}小数位不能超过${maxFracDigits}`;
}
return true;
}, TRIGGER_BLUR);
// ========== 直接显示错误的验证函数(直接用于元素)==========
// 长度验证函数
const validateLength = (value: any, maxLength: number, fieldName = '字段') => {
if (!value) return true;
if (String(value).length > maxLength) {
ElMessage.error(`${fieldName}长度不能超过${maxLength}个字符`);
return false;
}
return true;
};
// 必填验证函数
const validateRequired = (value: any, fieldName = '字段') => {
if (!value || String(value).trim() === '') {
ElMessage.error(`${fieldName}不能为空`);
return false;
}
return true;
};
// 数字验证函数
const validateNumber = (value: any, fieldName = '字段') => {
if (!value) return true;
if (!/^\d+$/.test(String(value))) {
ElMessage.error(`${fieldName}必须为纯数字`);
return false;
}
return true;
};
// 整数范围验证函数
const validateIntRange = (value: any, min: number, max: number, fieldName = '字段') => {
if (!value) return true;
const num = Number(value);
if (!Number.isInteger(num)) {
ElMessage.error(`${fieldName}必须为整数`);
return false;
}
if (num < min || num > max) {
ElMessage.error(`${fieldName}必须在${min}~${max}之间`);
return false;
}
return true;
};
// 中文验证函数
const validateChinese = (value: any, fieldName = '字段') => {
if (!value) return true;
if (!/^[\u4e00-\u9fa5]+$/.test(String(value))) {
ElMessage.error(`${fieldName}必须为中文`);
return false;
}
return true;
};
// 邮箱验证函数
const validateEmail = (value: any, fieldName = '字段') => {
if (!value) return true;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))) {
ElMessage.error(`${fieldName}格式不正确`);
return false;
}
return true;
};
// 手机号验证函数
const validatePhone = (value: any, fieldName = '字段') => {
if (!value) return true;
if (!/^1[3-9]\d{9}$/.test(String(value))) {
ElMessage.error(`${fieldName}格式不正确`);
return false;
}
return true;
};
// 身份证号验证函数
const validateIdCard = (value: any, fieldName = '字段') => {
if (!value) return true;
const idCard = String(value).trim();
if (idCard.length === 15) {
const reg15 = /^[1-9]\d{5}\d{2}((0[1-9])|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$/;
if (!reg15.test(idCard)) {
ElMessage.error(`${fieldName}格式不正确`);
return false;
}
return true;
}
if (idCard.length === 18) {
const reg18 = /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}(\d|X|x)$/;
if (!reg18.test(idCard)) {
ElMessage.error(`${fieldName}格式不正确`);
return false;
}
// 验证校验码
const factor = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const parity = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
let sum = 0;
for (let i = 0; i < 17; i++) {
sum += parseInt(idCard[i]) * factor[i];
}
const last = parity[sum % 11];
if (last !== idCard[17].toUpperCase()) {
ElMessage.error(`${fieldName}校验码错误`);
return false;
}
return true;
}
ElMessage.error(`${fieldName}必须是15位或18位`);
return false;
};
// 自定义正则验证函数
const validatePattern = (value: any, pattern: RegExp, message: string) => {
if (!value) return true;
if (!pattern.test(String(value))) {
ElMessage.error(message);
return false;
}
return true;
};
// 组合验证函数(可同时验证多个规则)
const validateMultiple = (value: any, validators: Array<(value: any) => boolean>) => {
for (const validator of validators) {
if (!validator(value)) {
return false;
}
}
return true;
};
// 验证小数和整数位数的函数
const validateDecimalPrecision = (value: any, maxIntDigits: number, maxFracDigits: number, fieldName = '字段') => {
if (!value) return true;
const str = String(value).trim();
// 允许 0、整数或小数正数不允许负数与多余小数点
if (!/^\d+(?:\.\d+)?$/.test(str)) {
ElMessage.error(`${fieldName}必须为数字`);
return false;
}
const [intPart, fracPart = ''] = str.split('.');
// 验证整数位数
if (intPart.length > maxIntDigits) {
ElMessage.error(`${fieldName}整数位不能超过${maxIntDigits}`);
return false;
}
// 验证小数位数
if (fracPart && fracPart.length > maxFracDigits) {
if (maxFracDigits === 0) {
ElMessage.error(`${fieldName}必须为整数`);
return false;
}
ElMessage.error(`${fieldName}小数位不能超过${maxFracDigits}`);
return false;
}
return true;
};
// 通用验证函数(根据类型自动选择验证规则)
const validateField = (value: any, type: string, fieldName: string, options?: any) => {
switch (type) {
case 'required':
return validateRequired(value, fieldName);
case 'length':
return validateLength(value, options.maxLength, fieldName);
case 'number':
return validateNumber(value, fieldName);
case 'range':
return validateIntRange(value, options.min, options.max, fieldName);
case 'chinese':
return validateChinese(value, fieldName);
case 'email':
return validateEmail(value, fieldName);
case 'phone':
return validatePhone(value, fieldName);
case 'idcard':
return validateIdCard(value, fieldName);
case 'pattern':
return validatePattern(value, options.pattern, options.message);
case 'decimal':
return validateDecimalPrecision(value, options.maxIntDigits, options.maxFracDigits, fieldName);
default:
return true;
}
};
return {
required,
requiredIf,
maxLen,
minLen,
pattern,
validator,
phone,
idCard,
email,
digits,
intRange,
confirmSame,
dateAfterToday,
dateBeforeToday,
numberPrecision,
validateRequired,
validateNumber,
validateIntRange,
validateChinese,
validateLength,
validateEmail,
validatePhone,
validateIdCard,
validatePattern,
validateMultiple,
validateField,
validateDecimalPrecision,
};
}

View File

@ -0,0 +1,436 @@
// 验证规则类型定义
export type ValidationRule = {
type: string;
message: string;
validator?: (value: any) => boolean;
pattern?: RegExp;
min?: number;
max?: number;
required?: boolean;
minLength?: number;
maxLength?: number;
value?: any;
trigger?: 'blur' | 'change' | 'submit' | string | string[]; // 补齐 trigger 属性
};
// 验证器配置接口
export interface ValidatorConfig {
prop?: string;
label?: string;
customMessages?: Record<string, string>;
trigger?: 'blur' | 'change' | 'submit' | string | string[]; // 补齐 trigger 属性
}
// 验证器建造者类
class ValidationBuilder {
private readonly prop?: string;
private readonly label?: string;
private readonly customMessages: Record<string, string>;
private readonly rules: ValidationRule[] = [];
private readonly defaultTrigger?: string | string[]; // 存储默认触发方式
constructor(config: ValidatorConfig) {
this.prop = config.prop;
this.label = config.label || config.prop;
this.customMessages = config.customMessages || {};
this.defaultTrigger = config.trigger || 'blur'; // 设置默认触发方式
}
// 获取自定义消息或默认消息
private getMessage(key: string, defaultMessage: string): string {
return this.customMessages[key] || defaultMessage;
}
// 添加基于正则表达式的验证规则
private addPatternRule(type: string, pattern: RegExp, message: string): ValidationBuilder {
const rule: ValidationRule = {
type: '',
message,
pattern,
};
this.rules.push(rule);
return this;
}
// 添加基于自定义函数的验证规则
private addValidatorRule(type: string, validator: (value: any) => boolean, message: string): ValidationBuilder {
const rule: ValidationRule = {
type,
message,
validator,
};
this.rules.push(rule);
return this;
}
// 必填验证
required(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}不能为空`;
const rule: ValidationRule = {
type: 'required', // 添加 type 字段
message: message || this.getMessage('required', defaultMessage),
required: true,
};
this.rules.push(rule);
return this;
}
// 最小长度验证
min(len: number, message?: string): ValidationBuilder {
const defaultMessage = `${this.label}最少输入${len}个字符`;
const rule: ValidationRule = {
type: 'min', // 添加 type 字段
message: message || this.getMessage('min', defaultMessage),
minLength: len,
};
this.rules.push(rule);
return this;
}
// 最大长度验证
max(len: number, message?: string): ValidationBuilder {
const defaultMessage = `${this.label}最多输入${len}个字符`;
const rule: ValidationRule = {
type: 'max', // 添加 type 字段
message: message || this.getMessage('max', defaultMessage),
maxLength: len,
};
this.rules.push(rule);
return this;
}
// 长度范围验证
range(min: number, max: number, message?: string): ValidationBuilder {
const defaultMessage = `${this.label}长度应在${min}-${max}个字符之间`;
const rule: ValidationRule = {
type: 'range', // 添加 type 字段
message: message || this.getMessage('range', defaultMessage),
minLength: min,
maxLength: max,
};
this.rules.push(rule);
return this;
}
// 正则表达式验证
pattern(regex: RegExp, message: string): ValidationBuilder {
const rule: ValidationRule = {
type: 'pattern', // 添加 type 字段
message,
pattern: regex,
};
this.rules.push(rule);
return this;
}
// 邮箱验证
email(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确`;
return this.addPatternRule('email', /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message || this.getMessage('email', defaultMessage));
}
// 手机号验证
mobile(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确`;
return this.addPatternRule('mobile', /^1[3-9]\d{9}$/, message || this.getMessage('mobile', defaultMessage));
}
// 固定电话验证
telephone(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确`;
return this.addPatternRule('telephone', /^(\d{3,4}-?)?\d{7,8}(-\d{1,6})?$/, message || this.getMessage('telephone', defaultMessage));
}
// 数字验证
number(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}必须为数字`;
return this.addPatternRule('number', /^-?\d+(\.\d+)?$/, message || this.getMessage('number', defaultMessage));
}
// 整数验证
integer(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}必须为整数`;
return this.addPatternRule('integer', /^-?[1-9]\d*$|^0$/, message || this.getMessage('integer', defaultMessage));
}
// 正整数验证
positiveInteger(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}必须为正整数`;
return this.addPatternRule('positiveInteger', /^[1-9]\d*$/, message || this.getMessage('positiveInteger', defaultMessage));
}
// 身份证号码验证
idCard(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确`;
return this.addValidatorRule(
'idCard',
(value) => {
if (!value) return true;
// 基本格式验证
const reg = /(^\d{15}$)|(^\d{17}(\d|X|x)$)/;
if (!reg.test(value)) return false;
// 15位身份证直接返回true因为没有校验位
if (value.length === 15) return true;
// 18位身份证需要验证校验位和出生日期
// 验证出生日期
const birth = value.substring(6, 14);
const year = parseInt(birth.substring(0, 4));
const month = parseInt(birth.substring(4, 6));
const day = parseInt(birth.substring(6, 8));
// 简单的日期有效性检查
if (month < 1 || month > 12) return false;
if (day < 1 || day > 31) return false;
// 验证校验位
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
const checks = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
let sum = 0;
for (let i = 0; i < 17; i++) {
sum += parseInt(value.charAt(i)) * weights[i];
}
const mod = sum % 11;
const checkBit = checks[mod];
return value.charAt(17).toUpperCase() === checkBit;
},
message || this.getMessage('idCard', defaultMessage)
);
}
// 统一社会信用代码验证
unifiedSocialCreditCode(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确`;
return this.addValidatorRule(
'unifiedSocialCreditCode',
(value) => {
if (!value) return true;
// 基本格式验证18位前17位为数字或大写字母最后1位为数字或大写字母
const reg = /^[0-9A-Z]{18}$/;
if (!reg.test(value)) return false;
// 统一社会信用代码校验规则
const code = value.toUpperCase();
const weights = [1, 3, 9, 27, 19, 17, 5, 33, 25, 11, 31, 7, 13, 37, 29, 41, 15];
const codes = '0123456789ABCDEFGHJKLMNPQRTUWXY';
const sum = weights.reduce((acc, weight, index) => {
const char = code.charAt(index);
const codeIndex = codes.indexOf(char);
if (codeIndex === -1) return acc; // 非法字符
return acc + weight * codeIndex;
}, 0);
const mod = sum % 31;
const checkBitIndex = 31 - mod;
const checkBit = codes[checkBitIndex];
return code.charAt(17) === checkBit;
},
message || this.getMessage('unifiedSocialCreditCode', defaultMessage)
);
}
// 银行卡号验证
bankCard(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确`;
return this.addValidatorRule(
'bankCard',
(value) => {
if (!value) return true;
// 基本格式验证16-19位数字
const reg = /^\d{16,19}$/;
if (!reg.test(value)) return false;
// Luhn算法验证
let sum = 0;
let isEven = false;
// 从右向左遍历
for (let i = value.length - 1; i >= 0; i--) {
let digit = parseInt(value.charAt(i));
if (isEven) {
digit *= 2;
if (digit > 9) {
digit -= 9;
}
}
sum += digit;
isEven = !isEven;
}
return sum % 10 === 0;
},
message || this.getMessage('bankCard', defaultMessage)
);
}
// URL验证
url(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确`;
return this.addPatternRule('url', /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/, message || this.getMessage('url', defaultMessage));
}
// IP地址验证
ip(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确`;
return this.addPatternRule('ip', /^(\d{1,3}\.){3}\d{1,3}$/, message || this.getMessage('ip', defaultMessage));
}
// 邮政编码验证
postalCode(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确`;
return this.addPatternRule('postalCode', /^\d{6}$/, message || this.getMessage('postalCode', defaultMessage));
}
// 日期格式验证 (YYYY-MM-DD)
date(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确应为YYYY-MM-DD格式`;
return this.addPatternRule('date', /^\d{4}-\d{2}-\d{2}$/, message || this.getMessage('date', defaultMessage));
}
// 时间格式验证 (HH:mm)
time(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确应为HH:mm格式`;
return this.addPatternRule('time', /^([01]\d|2[0-3]):[0-5]\d$/, message || this.getMessage('time', defaultMessage));
}
// 日期时间格式验证 (YYYY-MM-DD HH:mm)
datetime(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}格式不正确应为YYYY-MM-DD HH:mm格式`;
return this.addPatternRule('datetime', /^\d{4}-\d{2}-\d{2} ([01]\d|2[0-3]):[0-5]\d$/, message || this.getMessage('datetime', defaultMessage));
}
// 字母验证
letter(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}只能包含字母`;
return this.addPatternRule('letter', /^[a-zA-Z]+$/, message || this.getMessage('letter', defaultMessage));
}
// 字母和数字验证
alphanumeric(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}只能包含字母和数字`;
return this.addPatternRule('alphanumeric', /^[a-zA-Z0-9]+$/, message || this.getMessage('alphanumeric', defaultMessage));
}
// 中文验证
chinese(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}只能包含中文字符`;
return this.addPatternRule('chinese', /^[\u4e00-\u9fa5]+$/, message || this.getMessage('chinese', defaultMessage));
}
// 不包含空格验证
noSpace(message?: string): ValidationBuilder {
const defaultMessage = `${this.label}不能包含空格`;
return this.addPatternRule('noSpace', /^[^\s]*$/, message || this.getMessage('noSpace', defaultMessage));
}
// 最小值验证(用于数字)
minValue(min: number, message?: string): ValidationBuilder {
const defaultMessage = `${this.label}不能小于${min}`;
return this.addValidatorRule(
'minValue',
(value) => {
if (value === '' || value === null || value === undefined) return true;
const num = Number(value);
return !isNaN(num) && num >= min;
},
message || this.getMessage('minValue', defaultMessage)
);
}
// 最大值验证(用于数字)
maxValue(max: number, message?: string): ValidationBuilder {
const defaultMessage = `${this.label}不能大于${max}`;
return this.addValidatorRule(
'maxValue',
(value) => {
if (value === '' || value === null || value === undefined) return true;
const num = Number(value);
return !isNaN(num) && num <= max;
},
message || this.getMessage('maxValue', defaultMessage)
);
}
// 数值范围验证
rangeValue(min: number, max: number, message?: string): ValidationBuilder {
const defaultMessage = `${this.label}必须在${min}${max}之间`;
return this.addValidatorRule(
'rangeValue',
(value) => {
if (value === '' || value === null || value === undefined) return true;
const num = Number(value);
return !isNaN(num) && num >= min && num <= max;
},
message || this.getMessage('rangeValue', defaultMessage)
);
}
// 等于某个值验证
equals(compareValue: any, message?: string): ValidationBuilder {
const defaultMessage = `${this.label}值不正确`;
return this.addValidatorRule('equals', (value) => value === compareValue, message || this.getMessage('equals', defaultMessage));
}
// 与另一字段相等验证(常用于确认密码)
equalsTo(getCompareValue: () => any, message?: string): ValidationBuilder {
const defaultMessage = `${this.label}与确认字段不一致`;
return this.addValidatorRule('equalsTo', (value) => value === getCompareValue(), message || this.getMessage('equalsTo', defaultMessage));
}
// 自定义验证器
custom(validator: (value: any) => boolean, message: string): ValidationBuilder {
return this.addValidatorRule('custom', validator, message);
}
// 构建并返回规则数组
build(): ValidationRule[] {
return this.rules;
}
// 清空规则
clear(): ValidationBuilder {
this.rules.length = 0; // 更简洁的清空方式
return this;
}
}
/**
*
* @param propOrConfig
* @param trigger
* @example 示例1: 必填 +
* createValid('手机号码').required().mobile().build();
* @example 2 +
* createValid('身份证号').required().idCard().build();
* @example 3
* createValid('邮箱', 'blur').required().email().build();
*/
const createValid = (propOrConfig: string | ValidatorConfig, trigger?: 'blur' | 'change' | 'submit' | string | string[]): ValidationBuilder => {
// 判断第一个参数是字符串还是配置对象
if (typeof propOrConfig === 'string') {
// 第一个参数是字符串,表示 prop
const config: ValidatorConfig = {
prop: propOrConfig,
};
// 如果提供了 trigger 参数,则添加到配置中
if (trigger !== undefined) config.trigger = trigger;
return new ValidationBuilder(config);
} else {
// 第一个参数是配置对象,按原来的方式处理
return new ValidationBuilder(propOrConfig);
}
};
export { ValidationBuilder, createValid };