😎1、增加前端form字段验证 2、优化接口重复请求 3、优化前端字典、下拉框组件 4、调整代码生成及其命名空间 5、其它优化
This commit is contained in:
parent
0a414a06c5
commit
9d4f3c20bd
@ -14,99 +14,41 @@ namespace Admin.NET.Core;
|
||||
/// </summary>
|
||||
[SuppressSniffer]
|
||||
[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>
|
||||
/// 请求间隔时间/秒
|
||||
/// </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()
|
||||
{
|
||||
}
|
||||
private static readonly Lazy<SysCacheService> SysCacheService = new(() => App.GetService<SysCacheService>());
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
var path = httpContext.Request.Path.Value.ToString();
|
||||
var userId = httpContext.User?.FindFirstValue(ClaimConst.UserId);
|
||||
var cacheExpireTime = TimeSpan.FromSeconds(IntervalTime);
|
||||
|
||||
var path = httpContext.Request.Path.Value;
|
||||
var userId = httpContext.User.FindFirstValue(ClaimConst.UserId);
|
||||
var parameters = JsonConvert.SerializeObject(context.ActionArguments, Formatting.None, new JsonSerializerSettings
|
||||
{
|
||||
NullValueHandling = NullValueHandling.Include,
|
||||
DefaultValueHandling = DefaultValueHandling.Include
|
||||
});
|
||||
|
||||
var cacheKey = CacheKey + MD5Encryption.Encrypt($"{path}{userId}{parameters}");
|
||||
var sysCacheService = httpContext.RequestServices.GetService<SysCacheService>();
|
||||
try
|
||||
// 分布式锁
|
||||
var md5Key = MD5Encryption.Encrypt($"{path}{userId}{parameters}");
|
||||
using var distributedLock = SysCacheService.Value.BeginCacheLock(CacheConst.KeyIdempotent + md5Key);
|
||||
if (distributedLock == null)
|
||||
{
|
||||
// 分布式锁
|
||||
using var distributedLock = sysCacheService.BeginCacheLock($"{LockPrefix}{cacheKey}") ?? throw Oops.Oh(Message);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (throwBah) throw Oops.Oh(message);
|
||||
return;
|
||||
}
|
||||
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>
|
||||
/// 请求结果数据
|
||||
/// </summary>
|
||||
private class ResponseData
|
||||
{
|
||||
/// <summary>
|
||||
/// 结果类型
|
||||
/// </summary>
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 请求结果
|
||||
/// </summary>
|
||||
public dynamic Value { get; set; }
|
||||
// 标记请求
|
||||
SysCacheService.Value.Set(cacheKey, 1, TimeSpan.FromSeconds(intervalTime));
|
||||
await next();
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 代码生成策略工厂
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 自定义模板引擎
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
public class TableEntityEngine : ViewEngineModel
|
||||
{
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
public class TableSeedDataEngine : ViewEngineModel
|
||||
{
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 代码生成详细配置参数
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 模板输出上下文
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 种子数据策略基类(处理TableSeedDataEngine类型)
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 基础策略接口
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 表策略基类(处理SysCodeGen类型)
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 表实体代码生成策略类
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 表种子数据代码生成策略类
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 主从表代码生成策略类
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 单表代码生成策略类
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 关系对照代码生成策略类
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 主从明细带树组件代码生成策略类
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 单表带树组件代码生成策略类
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Core.CodeGen;
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
/// <summary>
|
||||
/// 关系对照带树组件代码生成策略类
|
||||
|
||||
@ -112,9 +112,9 @@ public class CacheConst
|
||||
public const string KeyDict = "sys_dict:";
|
||||
|
||||
/// <summary>
|
||||
/// 重复请求(幂等)字典缓存
|
||||
/// 重复请求(幂等)缓存
|
||||
/// </summary>
|
||||
public const string KeyIdempotent = "sys_idempotent:";
|
||||
public const string KeyIdempotent = "sys_idempotent_lock:";
|
||||
|
||||
/// <summary>
|
||||
/// Excel临时文件缓存
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
using Admin.NET.Core.CodeGen;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
using Admin.NET.Core.CodeGen;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace Admin.NET.Core;
|
||||
|
||||
@ -18,16 +18,19 @@ public class SysDatabaseService : IDynamicApiController, ITransient
|
||||
private readonly ISqlSugarClient _db;
|
||||
private readonly IViewEngine _viewEngine;
|
||||
private readonly CodeGenOptions _codeGenOptions;
|
||||
private readonly CodeGenStrategyFactory _codeGenStrategyFactory;
|
||||
|
||||
public SysDatabaseService(UserManager userManager,
|
||||
ISqlSugarClient db,
|
||||
IViewEngine viewEngine,
|
||||
IOptions<CodeGenOptions> codeGenOptions)
|
||||
IOptions<CodeGenOptions> codeGenOptions,
|
||||
CodeGenStrategyFactory codeGenStrategyFactory)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_db = db;
|
||||
_viewEngine = viewEngine;
|
||||
_codeGenOptions = codeGenOptions.Value;
|
||||
_codeGenStrategyFactory = codeGenStrategyFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -363,33 +366,10 @@ public class SysDatabaseService : IDynamicApiController, ITransient
|
||||
/// <param name="input"></param>
|
||||
[ApiDescriptionSettings(Name = "CreateEntity"), HttpPost]
|
||||
[DisplayName("创建实体")]
|
||||
public void CreateEntity(CreateEntityInput input)
|
||||
public async Task CreateEntity(CreateEntityInput input)
|
||||
{
|
||||
var tResult = GenerateEntity(input);
|
||||
var targetPath = GetEntityTargetPath(input);
|
||||
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);
|
||||
var template = await GenerateEntity(input);
|
||||
await File.WriteAllTextAsync(template.OutPath, template.Context, Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -397,53 +377,12 @@ public class SysDatabaseService : IDynamicApiController, ITransient
|
||||
/// </summary>
|
||||
/// <param name="input"></param>
|
||||
/// <returns></returns>
|
||||
[ApiDescriptionSettings(Name = "GenerateEntity"), HttpPost]
|
||||
[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);
|
||||
input.Position = string.IsNullOrWhiteSpace(input.Position) ? "Admin.NET.Application" : input.Position;
|
||||
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;
|
||||
var strategy = _codeGenStrategyFactory.GetStrategy<CreateEntityInput>(CodeGenSceneEnum.TableEntity);
|
||||
return (await strategy.GenerateCode(input))?.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -454,191 +393,9 @@ public class SysDatabaseService : IDynamicApiController, ITransient
|
||||
[DisplayName("创建种子数据")]
|
||||
public async Task CreateSeedData(CreateSeedDataInput input)
|
||||
{
|
||||
var config = App.GetOptions<DbConnectionOptions>().ConnectionConfigs.FirstOrDefault(u => u.ConfigId.ToString() == input.ConfigId);
|
||||
input.Position = string.IsNullOrWhiteSpace(input.Position) ? "Admin.NET.Core" : input.Position;
|
||||
|
||||
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);
|
||||
var strategy = _codeGenStrategyFactory.GetStrategy<CreateSeedDataInput>(CodeGenSceneEnum.TableSeedData);
|
||||
var result = (await strategy.GenerateCode(input))?.FirstOrDefault();
|
||||
await File.WriteAllTextAsync(result!.OutPath, result!.Context, Encoding.UTF8);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
56
Admin.NET/Admin.NET.Test/Attribute/IdempotentTest.cs
Normal file
56
Admin.NET/Admin.NET.Test/Attribute/IdempotentTest.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { PropType, reactive, watch } from 'vue';
|
||||
import { NullableNumber, NullableString } from '/@/types/global';
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
@ -105,7 +106,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@ -114,4 +115,11 @@ watch(
|
||||
<slot></slot>
|
||||
</el-tabs>
|
||||
</template>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
|
||||
361
Web/src/components/cameraDialog/cameraDialog.vue
Normal file
361
Web/src/components/cameraDialog/cameraDialog.vue
Normal 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.1时,间距为文本宽度的4倍
|
||||
// 当密度为1.0时,间距为文本宽度的1.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>
|
||||
@ -160,10 +160,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/**
|
||||
* 查询字符输入字符醒目展示
|
||||
*/
|
||||
allowCreate: Boolean,
|
||||
/**
|
||||
* 是否禁用
|
||||
*/
|
||||
@ -301,10 +297,10 @@ const selectVisibleChange = (visible: boolean) => {
|
||||
// 设置默认选项
|
||||
const setDefaultOptions = (options: 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 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));
|
||||
};
|
||||
@ -332,8 +328,16 @@ const setValue = (option: any | any[], row: any) => {
|
||||
emit('change', state.selectedValues, row);
|
||||
};
|
||||
|
||||
watch(() => props.modelValue, (val: any) => state.selectedValues = val, { immediate: true });
|
||||
watch(() => props.defaultOptions, (val: any) => setDefaultOptions(val), { immediate: true });
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val: any) => (state.selectedValues = val),
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(
|
||||
() => props.defaultOptions,
|
||||
(val: any) => setDefaultOptions(val),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
setValue,
|
||||
@ -345,21 +349,19 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<el-select
|
||||
v-model="state.selectedValues"
|
||||
:clearable="clearable"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
:allow-create="allowCreate"
|
||||
:remote-method="remoteMethod"
|
||||
:default-first-option="allowCreate"
|
||||
@visible-change="selectVisibleChange"
|
||||
:style="{ width: dropdownWidth }"
|
||||
popper-class="popper-class"
|
||||
ref="selectRef"
|
||||
remote-show-suffix
|
||||
filterable
|
||||
remote
|
||||
v-model="state.selectedValues"
|
||||
:clearable="clearable"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
:remote-method="remoteMethod"
|
||||
@visible-change="selectVisibleChange"
|
||||
:style="{ width: dropdownWidth }"
|
||||
popper-class="popper-class"
|
||||
ref="selectRef"
|
||||
remote-show-suffix
|
||||
filterable
|
||||
remote
|
||||
>
|
||||
<!-- 隐藏的选项,用于占位 -->
|
||||
<el-option style="width: 0; height: 0" />
|
||||
@ -383,11 +385,11 @@ defineExpose({
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
@row-click="handleChange"
|
||||
:data="state.tableData?.items ?? []"
|
||||
:height="`calc(${dropdownHeight} - 175px${$slots.queryForm ? ` - ${queryHeightOffset}px` : ''}${state.tableQuery[keywordProp] && allowCreate ? ` - ${queryHeightOffset}px` : ''})`"
|
||||
highlight-current-row
|
||||
ref="tableRef"
|
||||
@row-click="handleChange"
|
||||
:data="state.tableData?.items ?? []"
|
||||
:height="`calc(${dropdownHeight} - 175px${$slots.queryForm ? ` - ${queryHeightOffset}px` : ''}${state.tableQuery[keywordProp] && allowCreate ? ` - ${queryHeightOffset}px` : ''})`"
|
||||
highlight-current-row
|
||||
>
|
||||
<template #empty><el-empty :image-size="25" /></template>
|
||||
<slot name="columns"></slot>
|
||||
@ -395,22 +397,23 @@ defineExpose({
|
||||
|
||||
<!-- 分页组件 -->
|
||||
<el-pagination
|
||||
v-if="props.pagination"
|
||||
:disabled="state.loading"
|
||||
:currentPage="state.tableQuery.page"
|
||||
:page-size="state.tableQuery.pageSize"
|
||||
:total="state.tableData.total"
|
||||
:pager-count="4"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
layout="prev, pager, next"
|
||||
size="small"
|
||||
background
|
||||
v-if="props.pagination"
|
||||
:disabled="state.loading"
|
||||
:currentPage="state.tableQuery.page"
|
||||
:page-size="state.tableQuery.pageSize"
|
||||
:total="state.tableData.total"
|
||||
:pager-count="4"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
layout="prev, pager, next"
|
||||
size="small"
|
||||
background
|
||||
/>
|
||||
</div>
|
||||
</el-select>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.query-form {
|
||||
z-index: 9999;
|
||||
}
|
||||
@ -427,8 +430,7 @@ defineExpose({
|
||||
:deep(.popper-class) :deep(.el-select-dropdown__wrap) {
|
||||
max-height: 600px !important;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
|
||||
.popper-class .el-select-dropdown__wrap {
|
||||
max-height: 450px !important;
|
||||
}
|
||||
@ -437,4 +439,4 @@ defineExpose({
|
||||
.el-select-dropdown__wrap[max-height] {
|
||||
max-height: 450px !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@ -36,13 +36,13 @@ type TagType = (typeof TAG_TYPES)[number];
|
||||
* @property {string|number} [value] - 值
|
||||
*/
|
||||
interface DictItem {
|
||||
[key: string]: any;
|
||||
tagType?: TagType;
|
||||
styleSetting?: string;
|
||||
classSetting?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
value?: string | number;
|
||||
[key: string]: any;
|
||||
tagType?: TagType;
|
||||
styleSetting?: string;
|
||||
classSetting?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
value?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -52,8 +52,8 @@ interface DictItem {
|
||||
* @property {Array<string|number>} excludes - 被互斥的选项值列表
|
||||
*/
|
||||
interface MutexConfig {
|
||||
value: string | number;
|
||||
excludes: (string | number)[];
|
||||
value: string | number;
|
||||
excludes: (string | number)[];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -63,8 +63,8 @@ interface MutexConfig {
|
||||
* @property {string} Comma - 逗号分隔模式,如'1,2,3'
|
||||
*/
|
||||
const MultipleModel = {
|
||||
Array: 'array',
|
||||
Comma: 'comma',
|
||||
Array: 'array',
|
||||
Comma: 'comma',
|
||||
} as const;
|
||||
|
||||
// 多选值模式枚举类型
|
||||
@ -77,7 +77,7 @@ type MultipleModelType = (typeof MultipleModel)[keyof typeof MultipleModel];
|
||||
* @returns {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} - 是否为合法的多选模式
|
||||
*/
|
||||
function isMultipleModel(value: any): value is MultipleModelType {
|
||||
return Object.values(MultipleModel).includes(value);
|
||||
return Object.values(MultipleModel).includes(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -103,147 +103,147 @@ const emit = defineEmits(['update:modelValue', 'change']);
|
||||
* 组件属性定义
|
||||
*/
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 绑定值,支持多种类型
|
||||
* @type {string|number|boolean|Array|null}
|
||||
* @required
|
||||
* @example
|
||||
* // 单选选择器
|
||||
* <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 multiple-model="comma" />
|
||||
*/
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean, Array, null] as PropType<string | number | boolean | any[] | Nullable>,
|
||||
default: null,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* 字典编码,用于从字典中获取数据
|
||||
* @type {string}
|
||||
* @required
|
||||
* @example 'gender'
|
||||
*/
|
||||
code: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
/**
|
||||
* 直接传入的字典数据源(优先级高于code)
|
||||
* @type {DictItem[]}
|
||||
* @example [{ label: '选项1', value: '1' }, { label: '选项2', value: '2' }]
|
||||
*/
|
||||
data: {
|
||||
type: Array as PropType<DictItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
/**
|
||||
* 是否为常量字典(true从常量列表获取,false从字典列表获取)
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
isConst: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* 字典项中用于显示的字段名
|
||||
* @type {string}
|
||||
* @default 'label'
|
||||
* @example 'name'
|
||||
*/
|
||||
propLabel: {
|
||||
type: String,
|
||||
default: 'label',
|
||||
},
|
||||
/**
|
||||
* 字典项中用于取值的字段名
|
||||
* @type {string}
|
||||
* @default 'value'
|
||||
* @example 'id'
|
||||
*/
|
||||
propValue: {
|
||||
type: String,
|
||||
default: 'value',
|
||||
},
|
||||
/**
|
||||
* 字典项过滤函数
|
||||
* @type {Function}
|
||||
* @param {DictItem} dict - 当前字典项
|
||||
* @returns {boolean} - 是否保留该项
|
||||
* @default (dict) => true
|
||||
* @example
|
||||
* // 只显示启用的字典项
|
||||
* :onItemFilter="(dict) => dict.status === 1"
|
||||
*/
|
||||
onItemFilter: {
|
||||
type: Function as PropType<(dict: DictItem) => boolean>,
|
||||
default: () => true,
|
||||
},
|
||||
/**
|
||||
* 字典项显示内容格式化函数
|
||||
* @type {Function}
|
||||
* @param {DictItem} dict - 当前字典项
|
||||
* @returns {string|undefined|null} - 格式化后的显示内容
|
||||
* @default () => undefined
|
||||
* @example
|
||||
* // 在标签前添加图标
|
||||
* :onItemFormatter="(dict) => `${dict.label} <icon-user />`"
|
||||
*/
|
||||
onItemFormatter: {
|
||||
type: Function as PropType<(dict: DictItem) => string | undefined | null>,
|
||||
default: () => undefined,
|
||||
},
|
||||
/**
|
||||
* 组件渲染方式
|
||||
* @type {'tag'|'select'|'radio'|'checkbox'|'radio-button'}
|
||||
* @default 'tag'
|
||||
* @example 'select'
|
||||
*/
|
||||
renderAs: {
|
||||
type: String as PropType<RenderType>,
|
||||
default: 'tag',
|
||||
validator: isRenderType,
|
||||
},
|
||||
/**
|
||||
* 是否多选(仅在renderAs为select/checkbox时有效)
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* 多选值模式(仅在multiple为true时有效)
|
||||
* @type {'array'|'comma'}
|
||||
* @default 'array'
|
||||
* @example 'comma'
|
||||
*/
|
||||
multipleModel: {
|
||||
type: String as PropType<MultipleModelType>,
|
||||
default: MultipleModel.Array,
|
||||
validator: isMultipleModel,
|
||||
},
|
||||
/**
|
||||
* 互斥配置项(仅在多选模式下有效)
|
||||
* @type {Array<MutexConfig>}
|
||||
* @example
|
||||
* :mutex-configs="[
|
||||
* { value: 'all', excludes: ['1', '2', '3'] },
|
||||
* { value: '1', excludes: ['all'] }
|
||||
* ]"
|
||||
*/
|
||||
mutexConfigs: {
|
||||
type: Array as PropType<MutexConfig[]>,
|
||||
default: () => [],
|
||||
},
|
||||
/**
|
||||
* 绑定值,支持多种类型
|
||||
* @type {string|number|boolean|Array|null}
|
||||
* @required
|
||||
* @example
|
||||
* // 单选选择器
|
||||
* <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 multiple-model="comma" />
|
||||
*/
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean, Array, null] as PropType<string | number | boolean | any[] | Nullable>,
|
||||
default: null,
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* 字典编码,用于从字典中获取数据
|
||||
* @type {string}
|
||||
* @required
|
||||
* @example 'gender'
|
||||
*/
|
||||
code: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
/**
|
||||
* 直接传入的字典数据源(优先级高于code)
|
||||
* @type {DictItem[]}
|
||||
* @example [{ label: '选项1', value: '1' }, { label: '选项2', value: '2' }]
|
||||
*/
|
||||
data: {
|
||||
type: Array as PropType<DictItem[]>,
|
||||
default: () => [],
|
||||
},
|
||||
/**
|
||||
* 是否为常量字典(true从常量列表获取,false从字典列表获取)
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
isConst: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* 字典项中用于显示的字段名
|
||||
* @type {string}
|
||||
* @default 'label'
|
||||
* @example 'name'
|
||||
*/
|
||||
propLabel: {
|
||||
type: String,
|
||||
default: 'label',
|
||||
},
|
||||
/**
|
||||
* 字典项中用于取值的字段名
|
||||
* @type {string}
|
||||
* @default 'value'
|
||||
* @example 'id'
|
||||
*/
|
||||
propValue: {
|
||||
type: String,
|
||||
default: 'value',
|
||||
},
|
||||
/**
|
||||
* 字典项过滤函数
|
||||
* @type {Function}
|
||||
* @param {DictItem} dict - 当前字典项
|
||||
* @returns {boolean} - 是否保留该项
|
||||
* @default (dict) => true
|
||||
* @example
|
||||
* // 只显示启用的字典项
|
||||
* :onItemFilter="(dict) => dict.status === 1"
|
||||
*/
|
||||
onItemFilter: {
|
||||
type: Function as PropType<(dict: DictItem) => boolean>,
|
||||
default: () => true,
|
||||
},
|
||||
/**
|
||||
* 字典项显示内容格式化函数
|
||||
* @type {Function}
|
||||
* @param {DictItem} dict - 当前字典项
|
||||
* @returns {string|undefined|null} - 格式化后的显示内容
|
||||
* @default () => undefined
|
||||
* @example
|
||||
* // 在标签前添加图标
|
||||
* :onItemFormatter="(dict) => `${dict.label} <icon-user />`"
|
||||
*/
|
||||
onItemFormatter: {
|
||||
type: Function as PropType<(dict: DictItem) => string | undefined | null>,
|
||||
default: () => undefined,
|
||||
},
|
||||
/**
|
||||
* 组件渲染方式
|
||||
* @type {'tag'|'select'|'radio'|'checkbox'|'radio-button'}
|
||||
* @default 'tag'
|
||||
* @example 'select'
|
||||
*/
|
||||
renderAs: {
|
||||
type: String as PropType<RenderType>,
|
||||
default: 'tag',
|
||||
validator: isRenderType,
|
||||
},
|
||||
/**
|
||||
* 是否多选(仅在renderAs为select/checkbox时有效)
|
||||
* @type {boolean}
|
||||
* @default false
|
||||
* @example true
|
||||
*/
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
/**
|
||||
* 多选值模式(仅在multiple为true时有效)
|
||||
* @type {'array'|'comma'}
|
||||
* @default 'array'
|
||||
* @example 'comma'
|
||||
*/
|
||||
multipleModel: {
|
||||
type: String as PropType<MultipleModelType>,
|
||||
default: MultipleModel.Array,
|
||||
validator: isMultipleModel,
|
||||
},
|
||||
/**
|
||||
* 互斥配置项(仅在多选模式下有效)
|
||||
* @type {Array<MutexConfig>}
|
||||
* @example
|
||||
* :mutex-configs="[
|
||||
* { value: 'all', excludes: ['1', '2', '3'] },
|
||||
* { value: '1', excludes: ['all'] }
|
||||
* ]"
|
||||
*/
|
||||
mutexConfigs: {
|
||||
type: Array as PropType<MutexConfig[]>,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@ -252,9 +252,9 @@ const props = defineProps({
|
||||
* @property {any} value - 当前值
|
||||
*/
|
||||
const state = reactive({
|
||||
dictData: [] as DictItem[],
|
||||
value: props.modelValue,
|
||||
conversion: false,
|
||||
dictData: [] as DictItem[],
|
||||
value: props.modelValue,
|
||||
conversion: false,
|
||||
});
|
||||
|
||||
/**
|
||||
@ -263,26 +263,26 @@ const state = reactive({
|
||||
* @returns {DictItem[]} - 过滤并格式化后的字典数据
|
||||
*/
|
||||
const formattedDictData = computed(() => {
|
||||
const baseData = state.dictData.filter(props.onItemFilter).map((item) => ({
|
||||
...item,
|
||||
label: item[props.propLabel],
|
||||
value: item[props.propValue],
|
||||
}));
|
||||
const baseData = state.dictData.filter(props.onItemFilter).map((item) => ({
|
||||
...item,
|
||||
label: item[props.propLabel],
|
||||
value: item[props.propValue],
|
||||
}));
|
||||
|
||||
// 如果没有互斥配置或多选模式,直接返回基础数据
|
||||
if (!props.multiple || !props.mutexConfigs || props.mutexConfigs.length === 0) {
|
||||
return baseData;
|
||||
}
|
||||
// 如果没有互斥配置或多选模式,直接返回基础数据
|
||||
if (!props.multiple || !props.mutexConfigs || props.mutexConfigs.length === 0) {
|
||||
return baseData;
|
||||
}
|
||||
|
||||
// 处理互斥逻辑,设置禁用状态
|
||||
return baseData.map((item) => {
|
||||
// 检查当前项是否应该被禁用
|
||||
const isDisabled = isItemDisabled(item.value, state.value, props.mutexConfigs);
|
||||
return {
|
||||
...item,
|
||||
disabled: isDisabled || item.disabled, // 保持原有的disabled状态
|
||||
};
|
||||
});
|
||||
// 处理互斥逻辑,设置禁用状态
|
||||
return baseData.map((item) => {
|
||||
// 检查当前项是否应该被禁用
|
||||
const isDisabled = isItemDisabled(item.value, state.value, props.mutexConfigs);
|
||||
return {
|
||||
...item,
|
||||
disabled: isDisabled || item.disabled, // 保持原有的disabled状态
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@ -291,15 +291,15 @@ const formattedDictData = computed(() => {
|
||||
* @returns {DictItem|DictItem[]|null} - 当前选中的字典项或字典项数组
|
||||
*/
|
||||
const currentDictItems = computed(() => {
|
||||
if (!state.value) return null;
|
||||
if (!state.value) return null;
|
||||
|
||||
if (Array.isArray(state.value)) {
|
||||
// 去重
|
||||
const uniqueValues = [...new Set(state.value)];
|
||||
return formattedDictData.value.filter((item) => uniqueValues.includes(item.value));
|
||||
}
|
||||
if (Array.isArray(state.value)) {
|
||||
// 去重
|
||||
const uniqueValues = [...new Set(state.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} - 获取数据失败时抛出错误
|
||||
*/
|
||||
const getDataList = (): DictItem[] => {
|
||||
try {
|
||||
// 如果提供了 data 数据源,优先使用
|
||||
if (props.data && props.data.length > 0) {
|
||||
return props.data.map((item: any) => ({
|
||||
...item,
|
||||
label: item[props.propLabel] ?? [item.name, item.desc].filter((x) => x).join('-'),
|
||||
value: item[props.propValue] ?? item.code,
|
||||
}));
|
||||
}
|
||||
try {
|
||||
// 如果提供了 data 数据源,优先使用
|
||||
if (props.data && props.data.length > 0) {
|
||||
return props.data.map((item: any) => ({
|
||||
...item,
|
||||
label: item[props.propLabel] ?? [item.name, item.desc].filter((x) => x).join('-'),
|
||||
value: item[props.propValue] ?? item.code,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!props.code) {
|
||||
console.error('[g-sys-dict] code和data不能同时为空');
|
||||
return [];
|
||||
}
|
||||
if (!props.code) {
|
||||
console.error('[g-sys-dict] code和data不能同时为空');
|
||||
return [];
|
||||
}
|
||||
|
||||
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] ?? []);
|
||||
data.sort((a: number, b: number) => a - b);
|
||||
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] ?? []);
|
||||
data.sort((a: number, b: number) => a - b);
|
||||
|
||||
return data.map((item: any) => ({
|
||||
...item,
|
||||
label: item[props.propLabel] ?? [item.name, item.desc].filter((x) => x).join('-'),
|
||||
value: item[props.propValue] ?? item.code,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[g-sys-dict] 获取字典[${props.code}]数据失败:`, error);
|
||||
return [];
|
||||
}
|
||||
return data.map((item: any) => ({
|
||||
...item,
|
||||
label: item[props.propLabel] ?? [item.name, item.desc].filter((x) => x).join('-'),
|
||||
value: item[props.propValue] ?? item.code,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[g-sys-dict] 获取字典[${props.code}]数据失败:`, error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -345,13 +345,13 @@ const getDataList = (): DictItem[] => {
|
||||
* @param {any} value - 待处理的值
|
||||
*/
|
||||
const processNumericValues = (value: any) => {
|
||||
if (typeof value === 'number' || (Array.isArray(value) && typeof value[0] === 'number')) {
|
||||
state.dictData.forEach((item) => {
|
||||
if (item.value) {
|
||||
item.value = Number(item.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof value === 'number' || (Array.isArray(value) && typeof value[0] === 'number')) {
|
||||
state.dictData.forEach((item) => {
|
||||
if (item.value) {
|
||||
item.value = Number(item.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -361,48 +361,48 @@ const processNumericValues = (value: any) => {
|
||||
* @returns {any} - 解析后的值
|
||||
*/
|
||||
const parseMultipleValue = (value: any): any => {
|
||||
// 处理空值情况
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return props.multiple ? [] : value;
|
||||
}
|
||||
// 处理空值情况
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return props.multiple ? [] : value;
|
||||
}
|
||||
|
||||
// 处理数字
|
||||
if (typeof value === 'number' && !state.conversion) {
|
||||
try {
|
||||
state.dictData.forEach((item) => {
|
||||
if (item.value) item.value = Number(item.value);
|
||||
});
|
||||
state.conversion = true;
|
||||
} catch (error) {
|
||||
console.warn('[g-sys-dict] 数字转换失败:', error);
|
||||
}
|
||||
}
|
||||
// 处理数字
|
||||
if (typeof value === 'number' && !state.conversion) {
|
||||
try {
|
||||
state.dictData.forEach((item) => {
|
||||
if (item.value) item.value = Number(item.value);
|
||||
});
|
||||
state.conversion = true;
|
||||
} catch (error) {
|
||||
console.warn('[g-sys-dict] 数字转换失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理字符串值
|
||||
if (typeof value === 'string') {
|
||||
const trimmedValue = value.trim();
|
||||
// 处理字符串值
|
||||
if (typeof value === 'string') {
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
// 处理JSON数组格式
|
||||
if (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')) {
|
||||
try {
|
||||
return JSON.parse(trimmedValue);
|
||||
} catch (error) {
|
||||
console.warn('[g-sys-dict] 解析多选值失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// 处理JSON数组格式
|
||||
if (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')) {
|
||||
try {
|
||||
return JSON.parse(trimmedValue);
|
||||
} catch (error) {
|
||||
console.warn('[g-sys-dict] 解析多选值失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 处理逗号分隔格式
|
||||
if (props.multipleModel === MultipleModel.Comma && trimmedValue.includes(',')) {
|
||||
return trimmedValue.split(',');
|
||||
}
|
||||
// 处理逗号分隔格式
|
||||
if (props.multipleModel === MultipleModel.Comma && trimmedValue.includes(',')) {
|
||||
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} - 是否应该禁用
|
||||
*/
|
||||
const isItemDisabled = (itemValue: string | number, currentValue: any, mutexConfigs: MutexConfig[]): boolean => {
|
||||
// 如果没有配置互斥规则,不禁用任何项
|
||||
if (!mutexConfigs || mutexConfigs.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// 如果没有配置互斥规则,不禁用任何项
|
||||
if (!mutexConfigs || mutexConfigs.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取当前选中的值数组
|
||||
const selectedValues = Array.isArray(currentValue) ? currentValue : currentValue ? [currentValue] : [];
|
||||
// 获取当前选中的值数组
|
||||
const selectedValues = Array.isArray(currentValue) ? currentValue : currentValue ? [currentValue] : [];
|
||||
|
||||
// 检查每个互斥配置
|
||||
for (const config of mutexConfigs) {
|
||||
// 如果互斥触发项已被选中,且当前项是被互斥项,则禁用
|
||||
if (selectedValues.includes(config.value) && config.excludes.includes(itemValue)) {
|
||||
return true;
|
||||
}
|
||||
// 检查每个互斥配置
|
||||
for (const config of mutexConfigs) {
|
||||
// 如果互斥触发项已被选中,且当前项是被互斥项,则禁用
|
||||
if (selectedValues.includes(config.value) && config.excludes.includes(itemValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果当前项是互斥触发项,且有被互斥项被选中,则禁用
|
||||
if (itemValue == config.value && config.excludes.some((exclude) => selectedValues.includes(exclude))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// 如果当前项是互斥触发项,且有被互斥项被选中,则禁用
|
||||
if (itemValue == config.value && config.excludes.some((exclude) => selectedValues.includes(exclude))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -446,20 +446,20 @@ const isItemDisabled = (itemValue: string | number, currentValue: any, mutexConf
|
||||
* @returns {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中处理
|
||||
let resultValue = Array.isArray(newValue) ? [...newValue] : newValue ? [newValue] : [];
|
||||
// 对于禁用模式,我们只需要确保新值是有效的(即没有违反互斥规则)
|
||||
// 实际的禁用逻辑在formattedDictData中处理
|
||||
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 - 新值
|
||||
*/
|
||||
const updateValue = (newValue: any) => {
|
||||
// 如果有互斥配置,先处理互斥
|
||||
let processedValue = newValue;
|
||||
if (props.mutexConfigs && props.mutexConfigs.length > 0) {
|
||||
processedValue = handleMutex(newValue, props.mutexConfigs);
|
||||
}
|
||||
// 如果有互斥配置,先处理互斥
|
||||
let processedValue = newValue;
|
||||
if (props.mutexConfigs && props.mutexConfigs.length > 0) {
|
||||
processedValue = handleMutex(newValue, props.mutexConfigs);
|
||||
}
|
||||
|
||||
let emitValue = processedValue;
|
||||
if (props.multipleModel === MultipleModel.Comma) {
|
||||
if (Array.isArray(processedValue)) {
|
||||
emitValue = processedValue.length > 0 ? processedValue.sort().join(',') : [];
|
||||
} else if (processedValue === null || processedValue === undefined) {
|
||||
emitValue = undefined;
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(processedValue)) {
|
||||
emitValue = processedValue.length > 0 ? processedValue.sort() : [];
|
||||
} else if (processedValue === null || processedValue === undefined) {
|
||||
emitValue = undefined;
|
||||
}
|
||||
}
|
||||
let emitValue = processedValue;
|
||||
if (props.multipleModel === MultipleModel.Comma) {
|
||||
if (Array.isArray(processedValue)) {
|
||||
emitValue = processedValue.length > 0 ? processedValue.sort().join(',') : [];
|
||||
} else if (processedValue === null || processedValue === undefined) {
|
||||
emitValue = undefined;
|
||||
}
|
||||
} else {
|
||||
if (Array.isArray(processedValue)) {
|
||||
emitValue = processedValue.length > 0 ? processedValue.sort() : [];
|
||||
} else if (processedValue === null || processedValue === undefined) {
|
||||
emitValue = undefined;
|
||||
}
|
||||
}
|
||||
console.log('更新值:', { newValue, processedValue, emitValue });
|
||||
|
||||
state.value = processedValue;
|
||||
emit('update:modelValue', emitValue === '' || emitValue.length === 0 ? undefined : emitValue);
|
||||
emit('change', state.value, currentDictItems, state.dictData);
|
||||
state.value = processedValue;
|
||||
emit('update:modelValue', emitValue === '' || emitValue?.length === 0 ? undefined : emitValue);
|
||||
emit('change', state.value, currentDictItems, state.dictData);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -501,7 +502,7 @@ const updateValue = (newValue: any) => {
|
||||
* @returns {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} - 显示文本
|
||||
*/
|
||||
const getDisplayText = (dict?: DictItem): string => {
|
||||
if (!dict) return String(state.value || '');
|
||||
const formattedText = props.onItemFormatter?.(dict);
|
||||
return formattedText ?? dict[props.propLabel] ?? '';
|
||||
if (!dict) return String(state.value || '');
|
||||
const formattedText = props.onItemFormatter?.(dict);
|
||||
return formattedText ?? dict[props.propLabel] ?? '';
|
||||
};
|
||||
|
||||
/**
|
||||
@ -521,20 +522,20 @@ const getDisplayText = (dict?: DictItem): string => {
|
||||
* @function
|
||||
*/
|
||||
const initData = () => {
|
||||
// 验证 code 和 data 不能同时为空
|
||||
if (!props.code && (!props.data || props.data.length === 0)) {
|
||||
console.error('[g-sys-dict] code和data不能同时为空');
|
||||
state.dictData = [];
|
||||
state.value = props.multiple ? [] : null;
|
||||
return;
|
||||
}
|
||||
// 验证 code 和 data 不能同时为空
|
||||
if (!props.code && (!props.data || props.data.length === 0)) {
|
||||
console.error('[g-sys-dict] code和data不能同时为空');
|
||||
state.dictData = [];
|
||||
state.value = props.multiple ? [] : null;
|
||||
return;
|
||||
}
|
||||
|
||||
state.dictData = getDataList();
|
||||
processNumericValues(props.modelValue);
|
||||
const initialValue = parseMultipleValue(props.modelValue);
|
||||
if (initialValue !== state.value) {
|
||||
state.value = initialValue;
|
||||
}
|
||||
state.dictData = getDataList();
|
||||
processNumericValues(props.modelValue);
|
||||
const initialValue = parseMultipleValue(props.modelValue);
|
||||
if (initialValue !== state.value) {
|
||||
state.value = initialValue;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -542,78 +543,78 @@ const initData = () => {
|
||||
* @function
|
||||
*/
|
||||
const validateInitialValue = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (props.renderAs === 'tag' || !state.value) return resolve(undefined);
|
||||
if (Array.isArray(state.value)) {
|
||||
const errorValues = state.value.filter((val) => state.dictData.find((e) => e[props.propValue] == val) === undefined);
|
||||
if (errorValues && errorValues.length > 0) {
|
||||
reject(`[g-sys-dict] 未匹配到选项值:${JSON.stringify(errorValues)}`);
|
||||
}
|
||||
} else if (state.value) {
|
||||
if (!state.dictData.find((e) => e[props.propValue] == state.value)) {
|
||||
reject(`[g-sys-dict] 未匹配到选项值:${state.value}`);
|
||||
}
|
||||
}
|
||||
resolve(undefined);
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
if (props.renderAs === 'tag' || !state.value) return resolve(undefined);
|
||||
if (Array.isArray(state.value)) {
|
||||
const errorValues = state.value.filter((val) => state.dictData.find((e) => e[props.propValue] == val) === undefined);
|
||||
if (errorValues && errorValues.length > 0) {
|
||||
reject(`[g-sys-dict] 未匹配到选项值:${JSON.stringify(errorValues)}`);
|
||||
}
|
||||
} else if (state.value) {
|
||||
if (!state.dictData.find((e) => e[props.propValue] == state.value)) {
|
||||
reject(`[g-sys-dict] 未匹配到选项值:${state.value}`);
|
||||
}
|
||||
}
|
||||
resolve(undefined);
|
||||
});
|
||||
};
|
||||
|
||||
// 监听数据变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
state.value = parseMultipleValue(newValue);
|
||||
validateInitialValue();
|
||||
}
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
state.value = parseMultipleValue(newValue);
|
||||
validateInitialValue();
|
||||
}
|
||||
);
|
||||
watch(() => [userStore.dictList, userStore.constList, props.data, state], initData, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 渲染标签 -->
|
||||
<template v-if="props.renderAs === 'tag'">
|
||||
<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">
|
||||
{{ getDisplayText(item) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag v-if="currentDictItems" v-bind="$attrs" :type="ensureTagType(currentDictItems)" :style="currentDictItems.styleSetting" :class="currentDictItems.classSetting">
|
||||
{{ getDisplayText(currentDictItems) }}
|
||||
</el-tag>
|
||||
<span v-else>{{ getDisplayText() }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 渲染标签 -->
|
||||
<template v-if="props.renderAs === 'tag'">
|
||||
<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">
|
||||
{{ getDisplayText(item) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag v-if="currentDictItems" v-bind="$attrs" :type="ensureTagType(currentDictItems)" :style="currentDictItems.styleSetting" :class="currentDictItems.classSetting">
|
||||
{{ getDisplayText(currentDictItems) }}
|
||||
</el-tag>
|
||||
<span v-else>{{ getDisplayText() }}</span>
|
||||
</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-option v-for="(item, index) in formattedDictData" :key="index" :label="getDisplayText(item)" :value="item.value" :disabled="item.disabled" />
|
||||
</el-select>
|
||||
<!-- 渲染选择器 -->
|
||||
<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-select>
|
||||
|
||||
<!-- 多选框(多选) -->
|
||||
<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-group>
|
||||
<!-- 多选框(多选) -->
|
||||
<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-group>
|
||||
|
||||
<!-- 多选框-按钮(多选) -->
|
||||
<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">
|
||||
{{ getDisplayText(item) }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
<!-- 多选框-按钮(多选) -->
|
||||
<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">
|
||||
{{ getDisplayText(item) }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
|
||||
<!-- 渲染单选框 -->
|
||||
<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">
|
||||
{{ getDisplayText(item) }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<!-- 渲染单选框 -->
|
||||
<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">
|
||||
{{ getDisplayText(item) }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
|
||||
<!-- 渲染单选框按钮 -->
|
||||
<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">
|
||||
{{ getDisplayText(item) }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
<!-- 渲染单选框按钮 -->
|
||||
<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">
|
||||
{{ getDisplayText(item) }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss"></style>
|
||||
|
||||
@ -56,6 +56,7 @@ import { Session } from '/@/utils/storage';
|
||||
import { isObjectValueEqual } from '/@/utils/arrayOperation';
|
||||
import other from '/@/utils/other';
|
||||
import mittBus from '/@/utils/mitt';
|
||||
import { RefType, RouteItem, RouteItems, RouteToFrom, WheelEventType } from '/@/types/global';
|
||||
|
||||
// 引入组件
|
||||
const Contextmenu = defineAsyncComponent(() => import('/@/layout/navBars/tagsView/contextmenu.vue'));
|
||||
|
||||
@ -364,39 +364,41 @@
|
||||
}
|
||||
|
||||
.el-tree {
|
||||
--el-tree-node-content-height: 30px;
|
||||
--el-tree-node-content-height: 30px;
|
||||
}
|
||||
|
||||
.el-table .el-table__cell {
|
||||
&:has(.cell .el-button, .el-tag, .el-switch, .el-avatar) {
|
||||
padding: 0;
|
||||
.cell { text-overflow: clip; }
|
||||
}
|
||||
&:has(.cell .el-button, .el-tag, .el-switch, .el-avatar) {
|
||||
padding: 0;
|
||||
.cell {
|
||||
text-overflow: clip;
|
||||
}
|
||||
}
|
||||
|
||||
.el-button.is-text {
|
||||
height: 20px;
|
||||
}
|
||||
.el-button.is-text {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.el-text--large {
|
||||
font-size: var(--el-font-size-base);;
|
||||
}
|
||||
.el-button--large {
|
||||
--el-button-size: 32px;
|
||||
}
|
||||
.el-tag--large {
|
||||
height: 28px;
|
||||
}
|
||||
.el-button--default {
|
||||
height: 28px;
|
||||
}
|
||||
.el-button [class*=el-icon]+span{
|
||||
margin-left: 4px;
|
||||
}
|
||||
.el-text--large {
|
||||
font-size: var(--el-font-size-base);
|
||||
}
|
||||
.el-button--large {
|
||||
--el-button-size: 32px;
|
||||
}
|
||||
.el-tag--large {
|
||||
height: 28px;
|
||||
}
|
||||
.el-button--default {
|
||||
height: 28px;
|
||||
}
|
||||
.el-button [class*='el-icon'] + span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
.el-table [class*=el-table__row--level] .el-table__expand-icon {
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
width: 14px;
|
||||
.el-table [class*='el-table__row--level'] .el-table__expand-icon {
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
/* Card 卡片
|
||||
@ -418,6 +420,8 @@
|
||||
.el-table {
|
||||
// 表头背景色
|
||||
--el-table-header-bg-color: var(--next-bg-main-color);
|
||||
// 当前行背景色
|
||||
// --el-table-current-row-bg-color: #cbd8e4;
|
||||
|
||||
.el-button.is-text {
|
||||
padding: 0;
|
||||
|
||||
402
Web/src/utils/formRule.ts
Normal file
402
Web/src/utils/formRule.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
436
Web/src/utils/formValidator.ts
Normal file
436
Web/src/utils/formValidator.ts
Normal 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 };
|
||||
Loading…
Reference in New Issue
Block a user