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);
+ }
+
///
/// 时间转换简易字符串
///