From b7ad4a5294cc885a8e6787d873a3a68e0d7ac600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=96=B5=E4=BD=A0=E4=B8=AA=E6=B1=AA=E5=91=80?= Date: Thu, 21 Aug 2025 08:52:32 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8D=92=20feat(core):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20DateTimeRange=20=E8=87=AA=E5=AE=9A=E4=B9=89=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=89=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Attribute/DateTimeRangeAttribute.cs | 277 ++++++++++++++++++ .../DateTime/DateTimeFormatExtensions.cs | 50 ++++ 2 files changed, 327 insertions(+) create mode 100644 Admin.NET/Admin.NET.Core/Attribute/DateTimeRangeAttribute.cs diff --git a/Admin.NET/Admin.NET.Core/Attribute/DateTimeRangeAttribute.cs b/Admin.NET/Admin.NET.Core/Attribute/DateTimeRangeAttribute.cs new file mode 100644 index 00000000..1be389fa --- /dev/null +++ b/Admin.NET/Admin.NET.Core/Attribute/DateTimeRangeAttribute.cs @@ -0,0 +1,277 @@ +// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。 +// +// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。 +// +// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任! + +namespace Admin.NET.Core; + +/// +/// 用于校验 List<DateTime> 时间范围参数的自定义验证特性 +/// 要求列表包含两个有效 DateTime 值,构成一个时间范围 +/// +/// +/// 示例 1:基础时间范围(起始 < 结束) +/// +/// public class QueryModel +/// { +/// [DateTimeRange] +/// public List<DateTime> TimeRange { get; set; } +/// } +/// // 合法值: [2025-01-01 00:00:00, 2025-01-07 23:59:59] +/// +/// +/// 示例 2:仅允许日期(时间部分必须为 00:00:00) +/// +/// [DateTimeRange(DateOnly = true)] +/// public List<DateTime> DateRange { get; set; } +/// // 合法值: [2025-01-01 00:00:00, 2025-01-31 00:00:00] +/// // 非法值: [2025-01-01 08:00:00, ...] → 报错 +/// +/// +/// 示例 3:最小间隔 1 小时(60分钟) +/// +/// [DateTimeRange(MinInterval = 60)] +/// public List<DateTime> Duration { get; set; } +/// // 合法值: 间隔 ≥ 1小时(如 2小时 → "2小时") +/// // 错误提示: 时间间隔必须至少为 1小时 +/// +/// +/// 示例 4:最大间隔 7 天 +/// +/// [DateTimeRange(MaxInterval = 7 * 24 * 60)] // 7天 = 10080分钟 +/// public List<DateTime> WeeklyRange { get; set; } +/// // 合法值: 间隔 ≤ 7天 +/// // 错误提示: 时间间隔不能超过 7天 +/// +/// +/// 示例 5:时间范围必须在过去(且可包含当前时间) +/// +/// [DateTimeRange(DirectionEnum = TimeDirectionEnum.Past, AllowNow = true)] +/// public List<DateTime> PastEvents { get; set; } +/// // 合法值: [2025-08-01, 2025-08-20](若今天是 2025-08-20) +/// // 非法值: [2025-08-21, ...] → 完全在未来 +/// +/// +/// 示例 6:时间范围必须在未来(不能包含当前时间) +/// +/// [DateTimeRange(DirectionEnum = TimeDirectionEnum.Future, AllowNow = false)] +/// public List<DateTime> FutureTasks { get; set; } +/// // 合法值: [2025-08-21 00:00, 2025-08-30 23:59] +/// // 非法值: [2025-08-20 10:00, ...] → 包含当前时间 +/// +/// +/// 示例 7:允许起止时间相等(单日查询) +/// +/// [DateTimeRange(AllowEqual = true, DateOnly = true)] +/// public List<DateTime> SingleDay { get; set; } +/// // 合法值: [2025-08-20 00:00:00, 2025-08-20 00:00:00] +/// +/// +/// 示例 8:单元测试中固定“当前时间” +/// +/// // 用于测试未来/过去逻辑 +/// var attribute = new DateTimeRangeAttribute +/// { +/// DirectionEnum = TimeDirectionEnum.Future, +/// NowProvider = () => new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) +/// }; +/// +/// +[SuppressSniffer] +[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)] +public class DateTimeRangeAttribute : ValidationAttribute +{ + /// + /// 获取或设置时间方向约束 + /// + public TimeDirectionEnum DirectionEnum { get; set; } = TimeDirectionEnum.None; + + /// + /// 最小时间间隔(分钟) 0表示无限制 + /// + public int MinInterval { get; set; } = 0; + + /// + /// 最大时间间隔(分钟) 0表示无限制 + /// + public int MaxInterval { get; set; } = 0; + + /// + /// 是否仅允许日期类型(时间部分必须为 00:00:00) + /// + public bool DateOnly { get; set; } = false; + + /// + /// 是否允许起始时间等于结束时间(即零间隔) + /// 默认为 false,即起始时间必须小于结束时间 + /// + public bool AllowEqual { get; set; } = false; + + /// + /// 是否允许范围包含当前时间 + /// 当 Direction 为 Future 或 Past 时,此属性可覆盖默认行为 + /// + public bool AllowNow { get; set; } = true; + + /// + /// 获取或设置自定义的“现在”时间提供器,用于单元测试或特定时区场景 + /// 默认为 DateTimeOffset.Now + /// + public Func NowProvider { get; set; } = () => DateTimeOffset.Now; + + /// + /// 获取格式化的当前时间字符串(用于错误消息) + /// + private string NowString => NowProvider().ToString("yyyy-MM-dd HH:mm:ss"); + + /// + /// 执行验证逻辑 + /// + protected override ValidationResult IsValid(object? value, ValidationContext validationContext) + { + if (value == null) return ValidationResult.Success; + + var list = value as List; + if (list == null) return new ValidationResult($"'{validationContext.DisplayName}' 必须是日期时间列表类型"); + + if (list.Count != 2) return new ValidationResult($"'{validationContext.DisplayName}' 必须包含两个日期时间值"); + + var start = list[0]; + var end = list[1]; + + // 验证 DateTime 值的有效性 + if (!IsValidDateTime(start) || !IsValidDateTime(end)) return new ValidationResult($"'{validationContext.DisplayName}' 包含无效的日期时间值"); + + // 检查 DateOnly 约束 + if (DateOnly) + { + if (start.TimeOfDay != TimeSpan.Zero) return new ValidationResult($"'{validationContext.DisplayName}' 起始时间需为日期格式(00:00:00),当前为 {start:yyyy-MM-dd HH:mm:ss}"); + + if (end.TimeOfDay != TimeSpan.Zero) return new ValidationResult($"'{validationContext.DisplayName}' 结束时间需为日期格式(00:00:00),当前为 {end:yyyy-MM-dd HH:mm:ss}"); + } + + // 检查时间顺序 + if (AllowEqual) + { + if (start > end) return new ValidationResult($"'{validationContext.DisplayName}' 起始时间不能晚于结束时间"); + } + else + { + if (start >= end) return new ValidationResult($"'{validationContext.DisplayName}' 起始时间必须早于结束时间"); + } + + // 检查时间方向 + var now = NowProvider().DateTime; + var directionResult = ValidateDirection(start, end, now, validationContext.DisplayName); + if (directionResult != null) return directionResult; + + // 检查最小和最大间隔 + var intervalResult = ValidateTimeIntervals(start, end, validationContext.DisplayName); + if (intervalResult != null) return intervalResult; + + return ValidationResult.Success; + } + + /// + /// 验证时间方向约束 + /// + private ValidationResult? ValidateDirection(DateTime start, DateTime end, DateTime now, string? displayName) + { + return DirectionEnum switch + { + TimeDirectionEnum.Future when !AllowNow && (start <= now || end <= now) => + new ValidationResult($"'{displayName}' 必须在 {NowString} 之后"), + + TimeDirectionEnum.Future when AllowNow && (start < now && end < now) => + new ValidationResult($"'{displayName}' 必须是未来时间"), + + TimeDirectionEnum.Past when !AllowNow && (start >= now || end >= now) => + new ValidationResult($"'{displayName}' 必须在 {NowString} 之前"), + + TimeDirectionEnum.Past when AllowNow && (start > now && end > now) => + new ValidationResult($"'{displayName}' 必须是过去时间"), + + _ => null + }; + } + + /// + /// 验证最小和最大时间间隔 + /// + private ValidationResult? ValidateTimeIntervals(DateTime start, DateTime end, string? displayName) + { + var interval = end - start; + var totalMinutes = (int)interval.TotalMinutes; + + // 验证最小间隔 + if (MinInterval > 0) + { + if (MinInterval < 0) return new ValidationResult($"'{displayName}' 最小间隔不能为负数"); + + if (totalMinutes < MinInterval) + { + var required = TimeSpan.FromMinutes(MinInterval); + var actual = TimeSpan.FromMinutes(totalMinutes); + return new ValidationResult($"'{displayName}'至少间隔 {required.FormatTimeSpanText(2)}"); + } + } + + // 验证最大间隔 + if (MaxInterval > 0) + { + if (MaxInterval < 0) return new ValidationResult($"'{displayName}'最大间隔不能为负数"); + + if (totalMinutes > MaxInterval) + { + var allowed = TimeSpan.FromMinutes(MaxInterval); + var actual = TimeSpan.FromMinutes(totalMinutes); + return new ValidationResult($"'{displayName}'最多间隔 {allowed.FormatTimeSpanText(2)}"); + } + } + + // 验证最小不能大于最大 + if (MinInterval > 0 && MaxInterval > 0 && MinInterval > MaxInterval) + { + var minSpan = TimeSpan.FromMinutes(MinInterval); + var maxSpan = TimeSpan.FromMinutes(MaxInterval); + return new ValidationResult($"'{displayName}' 最小间隔({minSpan.FormatTimeSpanText(2)})不能大于最大间隔({maxSpan.FormatTimeSpanText(2)})"); + } + + return null; + } + + /// + /// 验证 DateTime 值是否在合理范围内 + /// + private static bool IsValidDateTime(DateTime dt) + { + return dt >= new DateTime(1900, 1, 1) && dt <= new DateTime(2100, 12, 31); + } +} + +/// +/// 指定范围必须是未来时间、过去时间,还是不限制 +/// +[SuppressSniffer] +[Description("时间方向枚举")] +public enum TimeDirectionEnum +{ + /// + /// 不限制时间方向 + /// + [Description("不限")] + None, + + /// + /// 范围必须在当前时间之后(未来) + /// + [Description("未来")] + Future, + + /// + /// 范围必须在当前时间之前(过去) + /// + [Description("过去")] + Past +} \ No newline at end of file diff --git a/Admin.NET/Admin.NET.Core/Utils/DateTime/DateTimeFormatExtensions.cs b/Admin.NET/Admin.NET.Core/Utils/DateTime/DateTimeFormatExtensions.cs index f98bd2e7..db2c948f 100644 --- a/Admin.NET/Admin.NET.Core/Utils/DateTime/DateTimeFormatExtensions.cs +++ b/Admin.NET/Admin.NET.Core/Utils/DateTime/DateTimeFormatExtensions.cs @@ -231,6 +231,56 @@ public static class DateTimeFormatExtensions return $"{sDay} 天 {sHour} 小时 {sMinute} 分 {sSecond} 秒 {sMilliSecond} 毫秒"; } + /// + /// 将 TimeSpan 格式化为 y年M月d天h小时m分钟 的字符串,值为0的单位不显示 + /// 注意:此方法对年、月的计算是基于近似值(1年≈365.25天,1月≈30.44天),适用于显示目的,不保证绝对精确 + /// + /// 要格式化的 TimeSpan + /// 保留的最大时间单位数量 + /// 格式化后的字符串 + public static string FormatTimeSpanText(this TimeSpan timeSpan, int maxUnits = int.MaxValue) + { + if (timeSpan < TimeSpan.Zero) timeSpan = TimeSpan.Zero; // 确保非负 + if (maxUnits <= 0) throw new ArgumentException("maxUnits 必须大于0", nameof(maxUnits)); + + long totalMinutes = (long)timeSpan.TotalMinutes; + if (totalMinutes == 0) return "0分钟"; + + // 计算年、月、日、小时、分钟 + // 使用近似值进行计算 + const double minutesInYear = 365 * 24 * 60; + const double minutesInMonth = 30 * 24 * 60; // 平均每月天数 + const long minutesInDay = 24 * 60; + const long minutesInHour = 60; + + int years = (int)(totalMinutes / minutesInYear); + totalMinutes %= (long)minutesInYear; + + int months = (int)(totalMinutes / minutesInMonth); + totalMinutes %= (long)minutesInMonth; + + int days = (int)(totalMinutes / minutesInDay); + totalMinutes %= minutesInDay; + + int hours = (int)(totalMinutes / minutesInHour); + int minutes = (int)(totalMinutes % minutesInHour); + + var parts = new List(); + if (years > 0) parts.Add($"{years}年"); + if (months > 0) parts.Add($"{months}个月"); + if (days > 0) parts.Add($"{days}天"); + if (hours > 0) parts.Add($"{hours}小时"); + if (minutes > 0) parts.Add($"{minutes}分钟"); + + // 如果指定了最大单位数量,则截取前 maxUnits 个非零单位 + if (maxUnits < int.MaxValue && parts.Count > maxUnits) + { + parts = parts.Take(maxUnits).ToList(); + } + + return parts.Count == 0 ? "0分钟" : string.Join("", parts); + } + /// /// 时间转换简易字符串 ///