在C#中计算相对时间

1651

给定一个特定的DateTime值,如何显示相对时间,例如:

  • 2小时前
  • 3天前
  • 一个月前

87
如何计算从现在到未来的相对时间? - Jhonny D. Cano -Leftware-
3
moment.js 是一个非常不错的日期解析库。根据您的需求,可以考虑在服务器端或客户端使用它。只是提示一下,因为这里没有人提到过它。 - Matej
5
这个项目对于日期格式化来说非常棒。https://github.com/Humanizr/Humanizer#humanize-datetime - Aaron Hudon
42个回答

1071

Jeff,您的代码很好,但是可以使用常量来使代码更清晰(如Code Complete所建议的)。

const int SECOND = 1;
const int MINUTE = 60 * SECOND;
const int HOUR = 60 * MINUTE;
const int DAY = 24 * HOUR;
const int MONTH = 30 * DAY;

var ts = new TimeSpan(DateTime.UtcNow.Ticks - yourDate.Ticks);
double delta = Math.Abs(ts.TotalSeconds);

if (delta < 1 * MINUTE)
  return ts.Seconds == 1 ? "one second ago" : ts.Seconds + " seconds ago";

if (delta < 2 * MINUTE)
  return "a minute ago";

if (delta < 45 * MINUTE)
  return ts.Minutes + " minutes ago";

if (delta < 90 * MINUTE)
  return "an hour ago";

if (delta < 24 * HOUR)
  return ts.Hours + " hours ago";

if (delta < 48 * HOUR)
  return "yesterday";

if (delta < 30 * DAY)
  return ts.Days + " days ago";

if (delta < 12 * MONTH)
{
  int months = Convert.ToInt32(Math.Floor((double)ts.Days / 30));
  return months <= 1 ? "one month ago" : months + " months ago";
}
else
{
  int years = Convert.ToInt32(Math.Floor((double)ts.Days / 365));
  return years <= 1 ? "one year ago" : years + " years ago";
}

231
我非常讨厌这种常量。有人觉得这个看起来不对吗?Thread.Sleep(1 * MINUTE)?因为它的值错了1000倍。 - Roman Starkov
39
const int SECOND = 1; 看起来很奇怪,一个秒只有一秒钟长。 - seriousdev
67
这种代码几乎不可能本地化。如果你的应用程序只需要保持英文状态,那就没问题。但是如果你要将其扩展到其他语言,那么编写这样的逻辑就会让你自己后悔。只是让大家知道一下... - Nik Reiman
78
如果将常量重命名为准确描述其值的名称,那么理解起来会更容易。例如,SecondsPerMinute = 60; MinutesPerHour = 60; SecondsPerHour = MinutesPerHour * SecondsPerMinute等等。仅仅称其为MINUTE=60并不能让读者确定这个值是什么。 - slolife
18
为什么除了Joe以外没有人关心“昨天”或“几天前”的错误数值?“昨天”不是一个小时的计算,而是从一天到另一天的计算。因此,至少在两种常见情况下,这是错误的代码。 - CtrlX
显示剩余11条评论

372

jQuery.timeago插件

Jeff,由于Stack Overflow广泛使用jQuery,我建议使用jQuery.timeago插件

好处:

  • 避免时间戳显示为“1分钟前”,即使页面在10分钟前打开;timeago会自动刷新。
  • 您可以充分利用网页应用程序中的页面和/或片段缓存,因为时间戳不是在服务器上计算的。
  • 您可以像酷孩子们一样使用微格式。

只需在DOM准备就绪时将其附加到您的时间戳上即可:

jQuery(document).ready(function() {
    jQuery('abbr.timeago').timeago();
});

这将会把所有class为timeago且title中包含ISO 8601时间戳的abbr元素转换:

<abbr class="timeago" title="2008-07-17T09:24:17Z">July 17, 2008</abbr>

将其翻译为中文:

转变成像这样的东西:

<abbr class="timeago" title="July 17, 2008">4 months ago</abbr>

这将产生:4个月前。随着时间的推移,时间戳将自动更新。

免责声明:我编写了此插件,因此我有偏见。


39
如果你禁用了JavaScript,那么最初放置在缩略语标签之间的字符串将被显示出来。通常,这只是一个任意格式的日期或时间。Timeago能够很好地降级,它非常简单易用。 - Ryan McGeary
23
Ryan,我建议SO一段时间前使用timeago。Jeff的回应让我哭了,我建议你坐下来看一下:http://stackoverflow.uservoice.com/pages/1722-general/suggestions/96770-auto-update-the-fuzzy-timestamps-with-jquery-timeago- - Rob Fonseca-Ensor
7
嘿,谢谢Rob。没事的,尤其是当只有一个数字在过渡期间发生变化时,几乎不会被注意到,尽管SO页面有很多时间戳。我本以为他至少会欣赏页面缓存的好处,即使他选择避免自动更新。我相信Jeff也可以提供反馈来改进插件。我安慰自己知道像http://arstechnica.com/这样的网站正在使用它。 - Ryan McGeary
19
@Rob Fonseca-Ensor - 现在这也让我感到难过。每分钟更新一次以显示准确信息,与文本每秒闪烁一次有什么关系呢?请问? - Daniel Earwicker
36
这个问题涉及到 C#,我不明白 jQuery 插件如何相关。请问需要翻译什么其他内容吗? - BartoszKP
显示剩余5条评论

348

以下是我的做法

var ts = new TimeSpan(DateTime.UtcNow.Ticks - dt.Ticks);
double delta = Math.Abs(ts.TotalSeconds);

if (delta < 60)
{
  return ts.Seconds == 1 ? "one second ago" : ts.Seconds + " seconds ago";
}
if (delta < 60 * 2)
{
  return "a minute ago";
}
if (delta < 45 * 60)
{
  return ts.Minutes + " minutes ago";
}
if (delta < 90 * 60)
{
  return "an hour ago";
}
if (delta < 24 * 60 * 60)
{
  return ts.Hours + " hours ago";
}
if (delta < 48 * 60 * 60)
{
  return "yesterday";
}
if (delta < 30 * 24 * 60 * 60)
{
  return ts.Days + " days ago";
}
if (delta < 12 * 30 * 24 * 60 * 60)
{
  int months = Convert.ToInt32(Math.Floor((double)ts.Days / 30));
  return months <= 1 ? "one month ago" : months + " months ago";
}
int years = Convert.ToInt32(Math.Floor((double)ts.Days / 365));
return years <= 1 ? "one year ago" : years + " years ago";

有什么建议?评论?可以改进这个算法的方法?


114
“< 486060s”这个定义有些不寻常,用来表示“昨天”。如果是周三上午9点,你真的会认为周一上午9:01就是“昨天”吗?我认为一个“昨天”或“n天前”的算法应该考虑到午夜之前/之后。 - Joe
141
编译器通常能够很好地预先计算常量表达式,比如24 * 60 * 60,因此你可以直接使用它们,而不是自己计算出86400并将原始表达式放在注释中。 - Andriy Volkov
11
我想我在我的一个项目中这样做了。我这里的动机是提醒其他人在这段代码示例中省略了几周。至于如何做到这一点,对我来说似乎非常简单明了。 - jray
9
我认为提高算法的好方法是显示两个单位,比如“2个月21天前”、“1小时40分钟前”,以增加准确度。 - David Levin
6
@ Jeffy,你错过了闰年的计算和相关检查。 - Saboor Awan
显示剩余5条评论

100
public static string RelativeDate(DateTime theDate)
{
    Dictionary<long, string> thresholds = new Dictionary<long, string>();
    int minute = 60;
    int hour = 60 * minute;
    int day = 24 * hour;
    thresholds.Add(60, "{0} seconds ago");
    thresholds.Add(minute * 2, "a minute ago");
    thresholds.Add(45 * minute, "{0} minutes ago");
    thresholds.Add(120 * minute, "an hour ago");
    thresholds.Add(day, "{0} hours ago");
    thresholds.Add(day * 2, "yesterday");
    thresholds.Add(day * 30, "{0} days ago");
    thresholds.Add(day * 365, "{0} months ago");
    thresholds.Add(long.MaxValue, "{0} years ago");
    long since = (DateTime.Now.Ticks - theDate.Ticks) / 10000000;
    foreach (long threshold in thresholds.Keys) 
    {
        if (since < threshold) 
        {
            TimeSpan t = new TimeSpan((DateTime.Now.Ticks - theDate.Ticks));
            return string.Format(thresholds[threshold], (t.Days > 365 ? t.Days / 365 : (t.Days > 0 ? t.Days : (t.Hours > 0 ? t.Hours : (t.Minutes > 0 ? t.Minutes : (t.Seconds > 0 ? t.Seconds : 0))))).ToString());
        }
    }
    return "";
}

我更喜欢这个版本,因为它更加简洁,并且可以添加新的刻度点。 这可以通过将 Latest() 扩展到 Timespan 来进行封装,而不是那个冗长的一行代码,但出于发布的简洁性考虑,这样做就足够了。 这修复了一个小时前、1小时前的问题,提供了在2小时过去之前的一个小时


我在使用这个函数时遇到了各种问题,例如如果您模拟“theDate = DateTime.Now.AddMinutes(-40);”,我会得到“40小时前”的结果,但是使用Michael的refactormycode响应后,它将返回正确的“40分钟前”? - GONeale
我认为你少了一个零,试试这个: long since = (DateTime.Now.Ticks - theDate.Ticks) / 100000000; - robnardo
9
虽然这段代码可能有效,但假设字典中键的顺序是特定的是错误和无效的。字典使用Object.GetHashCode()方法,该方法返回的是int类型而不是long类型。如果要进行排序,则应该使用SortedList<long, string>。为什么在一系列if/else if/.../else语句中评估阈值会有问题?你将得到相同数量的比较。顺便说一下,long.MaxValue的哈希值和int.MinValue相同! - CodeMonkeyKing
OP 忘记了 t.Days > 30 ? t.Days / 30 : - Lars Holm Jensen
为了解决@CodeMonkeyKing提到的问题,您可以使用**SortedDictionary而不是普通的Dictionary:用法相同,但它确保键已排序。但即使如此,该算法仍有缺陷,因为RelativeDate(DateTime.Now.AddMonths(-3).AddDays(-3))返回“95个月前”**,无论您使用哪种字典类型,这是不正确的(它应该返回“3个月前”或“4个月前”,具体取决于您使用的阈值)- 即使-3不会在过去一年内创建日期(我在12月份进行了测试,所以在这种情况下不应发生)。 - Matt

75
public static string ToRelativeDate(DateTime input)
{
    TimeSpan oSpan = DateTime.Now.Subtract(input);
    double TotalMinutes = oSpan.TotalMinutes;
    string Suffix = " ago";

    if (TotalMinutes < 0.0)
    {
        TotalMinutes = Math.Abs(TotalMinutes);
        Suffix = " from now";
    }

    var aValue = new SortedList<double, Func<string>>();
    aValue.Add(0.75, () => "less than a minute");
    aValue.Add(1.5, () => "about a minute");
    aValue.Add(45, () => string.Format("{0} minutes", Math.Round(TotalMinutes)));
    aValue.Add(90, () => "about an hour");
    aValue.Add(1440, () => string.Format("about {0} hours", Math.Round(Math.Abs(oSpan.TotalHours)))); // 60 * 24
    aValue.Add(2880, () => "a day"); // 60 * 48
    aValue.Add(43200, () => string.Format("{0} days", Math.Floor(Math.Abs(oSpan.TotalDays)))); // 60 * 24 * 30
    aValue.Add(86400, () => "about a month"); // 60 * 24 * 60
    aValue.Add(525600, () => string.Format("{0} months", Math.Floor(Math.Abs(oSpan.TotalDays / 30)))); // 60 * 24 * 365 
    aValue.Add(1051200, () => "about a year"); // 60 * 24 * 365 * 2
    aValue.Add(double.MaxValue, () => string.Format("{0} years", Math.Floor(Math.Abs(oSpan.TotalDays / 365))));

    return aValue.First(n => TotalMinutes < n.Key).Value.Invoke() + Suffix;
}

http://refactormycode.com/codes/493-twitter-esque-relative-dates

C# 6 版本:

static readonly SortedList<double, Func<TimeSpan, string>> offsets = 
   new SortedList<double, Func<TimeSpan, string>>
{
    { 0.75, _ => "less than a minute"},
    { 1.5, _ => "about a minute"},
    { 45, x => $"{x.TotalMinutes:F0} minutes"},
    { 90, x => "about an hour"},
    { 1440, x => $"about {x.TotalHours:F0} hours"},
    { 2880, x => "a day"},
    { 43200, x => $"{x.TotalDays:F0} days"},
    { 86400, x => "about a month"},
    { 525600, x => $"{x.TotalDays / 30:F0} months"},
    { 1051200, x => "about a year"},
    { double.MaxValue, x => $"{x.TotalDays / 365:F0} years"}
};

public static string ToRelativeDate(this DateTime input)
{
    TimeSpan x = DateTime.Now - input;
    string Suffix = x.TotalMinutes > 0 ? " ago" : " from now";
    x = new TimeSpan(Math.Abs(x.Ticks));
    return offsets.First(n => x.TotalMinutes < n.Key).Value(x) + Suffix;
}

这在我看来非常不错 :) 这也可以重构为扩展方法?字典是否可以变成静态的,这样它只会被创建一次,然后从那时起被引用? - Pure.Krome
Pure.Krome: https://dev59.com/b3VD5IYBdhLWcg3wXamd#1141237 - Chris Charabaruk
5
你可能希望将那个字典移到一个字段中,这样可以减少实例化和垃圾回收的负担。你需要将 Func<string> 改为 Func<double> - Drew Noakes

74

这是Jeff的PHP脚本重写:

define("SECOND", 1);
define("MINUTE", 60 * SECOND);
define("HOUR", 60 * MINUTE);
define("DAY", 24 * HOUR);
define("MONTH", 30 * DAY);
function relativeTime($time)
{   
    $delta = time() - $time;

    if ($delta < 1 * MINUTE)
    {
        return $delta == 1 ? "one second ago" : $delta . " seconds ago";
    }
    if ($delta < 2 * MINUTE)
    {
      return "a minute ago";
    }
    if ($delta < 45 * MINUTE)
    {
        return floor($delta / MINUTE) . " minutes ago";
    }
    if ($delta < 90 * MINUTE)
    {
      return "an hour ago";
    }
    if ($delta < 24 * HOUR)
    {
      return floor($delta / HOUR) . " hours ago";
    }
    if ($delta < 48 * HOUR)
    {
      return "yesterday";
    }
    if ($delta < 30 * DAY)
    {
        return floor($delta / DAY) . " days ago";
    }
    if ($delta < 12 * MONTH)
    {
      $months = floor($delta / DAY / 30);
      return $months <= 1 ? "one month ago" : $months . " months ago";
    }
    else
    {
        $years = floor($delta / DAY / 365);
        return $years <= 1 ? "one year ago" : $years . " years ago";
    }
}    

19
问题标记为C#,为什么要使用PHP代码? - Kiquenet

55

以下是我作为DateTime类的扩展方法添加的实现,它可以处理过去和未来的日期,并提供了一个近似选项,使您可以指定要查找的详细程度(例如“3小时前”与“3小时23分钟12秒前”):

using System.Text;

/// <summary>
/// Compares a supplied date to the current date and generates a friendly English 
/// comparison ("5 days ago", "5 days from now")
/// </summary>
/// <param name="date">The date to convert</param>
/// <param name="approximate">When off, calculate timespan down to the second.
/// When on, approximate to the largest round unit of time.</param>
/// <returns></returns>
public static string ToRelativeDateString(this DateTime value, bool approximate)
{
    StringBuilder sb = new StringBuilder();

    string suffix = (value > DateTime.Now) ? " from now" : " ago";

    TimeSpan timeSpan = new TimeSpan(Math.Abs(DateTime.Now.Subtract(value).Ticks));

    if (timeSpan.Days > 0)
    {
        sb.AppendFormat("{0} {1}", timeSpan.Days,
          (timeSpan.Days > 1) ? "days" : "day");
        if (approximate) return sb.ToString() + suffix;
    }
    if (timeSpan.Hours > 0)
    {
        sb.AppendFormat("{0}{1} {2}", (sb.Length > 0) ? ", " : string.Empty,
          timeSpan.Hours, (timeSpan.Hours > 1) ? "hours" : "hour");
        if (approximate) return sb.ToString() + suffix;
    }
    if (timeSpan.Minutes > 0)
    {
        sb.AppendFormat("{0}{1} {2}", (sb.Length > 0) ? ", " : string.Empty, 
          timeSpan.Minutes, (timeSpan.Minutes > 1) ? "minutes" : "minute");
        if (approximate) return sb.ToString() + suffix;
    }
    if (timeSpan.Seconds > 0)
    {
        sb.AppendFormat("{0}{1} {2}", (sb.Length > 0) ? ", " : string.Empty, 
          timeSpan.Seconds, (timeSpan.Seconds > 1) ? "seconds" : "second");
        if (approximate) return sb.ToString() + suffix;
    }
    if (sb.Length == 0) return "right now";

    sb.Append(suffix);
    return sb.ToString();
}

51

还有一个叫做Humanizr的Nuget包,它实际上非常好用,并且在.NET基金会中。

DateTime.UtcNow.AddHours(-30).Humanize() => "yesterday"
DateTime.UtcNow.AddHours(-2).Humanize() => "2 hours ago"

DateTime.UtcNow.AddHours(30).Humanize() => "tomorrow"
DateTime.UtcNow.AddHours(2).Humanize() => "2 hours from now"

TimeSpan.FromMilliseconds(1299630020).Humanize() => "2 weeks"
TimeSpan.FromMilliseconds(1299630020).Humanize(3) => "2 weeks, 1 day, 1 hour"

Scott Hanselman在他的博客中写了一篇关于它的文章。


3
友情提示:对于 .NET 4.5 及以上版本,请不要安装完整的 Humanizer,只需安装其核心部分 Humanizer.Core 即可,因为该版本不支持其他语言包。 - Ahmad

37
我建议您也在客户端计算此内容,这样可以减轻服务器的负担。
以下是我使用的版本(来自Zach Leatherman)。
/*
 * Javascript Humane Dates
 * Copyright (c) 2008 Dean Landolt (deanlandolt.com)
 * Re-write by Zach Leatherman (zachleat.com)
 * 
 * Adopted from the John Resig's pretty.js
 * at http://ejohn.org/blog/javascript-pretty-date
 * and henrah's proposed modification 
 * at http://ejohn.org/blog/javascript-pretty-date/#comment-297458
 * 
 * Licensed under the MIT license.
 */

function humane_date(date_str){
        var time_formats = [
                [60, 'just now'],
                [90, '1 minute'], // 60*1.5
                [3600, 'minutes', 60], // 60*60, 60
                [5400, '1 hour'], // 60*60*1.5
                [86400, 'hours', 3600], // 60*60*24, 60*60
                [129600, '1 day'], // 60*60*24*1.5
                [604800, 'days', 86400], // 60*60*24*7, 60*60*24
                [907200, '1 week'], // 60*60*24*7*1.5
                [2628000, 'weeks', 604800], // 60*60*24*(365/12), 60*60*24*7
                [3942000, '1 month'], // 60*60*24*(365/12)*1.5
                [31536000, 'months', 2628000], // 60*60*24*365, 60*60*24*(365/12)
                [47304000, '1 year'], // 60*60*24*365*1.5
                [3153600000, 'years', 31536000], // 60*60*24*365*100, 60*60*24*365
                [4730400000, '1 century'] // 60*60*24*365*100*1.5
        ];

        var time = ('' + date_str).replace(/-/g,"/").replace(/[TZ]/g," "),
                dt = new Date,
                seconds = ((dt - new Date(time) + (dt.getTimezoneOffset() * 60000)) / 1000),
                token = ' ago',
                i = 0,
                format;

        if (seconds < 0) {
                seconds = Math.abs(seconds);
                token = '';
        }

        while (format = time_formats[i++]) {
                if (seconds < format[0]) {
                        if (format.length == 2) {
                                return format[1] + (i > 1 ? token : ''); // Conditional so we don't return Just Now Ago
                        } else {
                                return Math.round(seconds / format[2]) + ' ' + format[1] + (i > 1 ? token : '');
                        }
                }
        }

        // overflow for centuries
        if(seconds > 4730400000)
                return Math.round(seconds / 4730400000) + ' centuries' + token;

        return date_str;
};

if(typeof jQuery != 'undefined') {
        jQuery.fn.humane_dates = function(){
                return this.each(function(){
                        var date = humane_date(this.title);
                        if(date && jQuery(this).text() != date) // don't modify the dom if we don't have to
                                jQuery(this).text(date);
                });
        };
}

11
问题标记为C#,为什么要用JavaScript代码? - Kiquenet

34

@jeff

我的想法是你的方法似乎有点长。不过,它支持“昨天”和“年份”,看起来更加健壮。但是根据我的经验,在使用此功能时,用户最有可能在最初的30天内查看内容。只有真正的铁杆粉丝才会在之后继续关注。因此,我通常选择保持简短明了。

这是我目前在一个网站上使用的方法。它只返回相对日期、小时和时间,然后用户需要在输出中添加“前”字。

public static string ToLongString(this TimeSpan time)
{
    string output = String.Empty;

    if (time.Days > 0)
        output += time.Days + " days ";

    if ((time.Days == 0 || time.Days == 1) && time.Hours > 0)
        output += time.Hours + " hr ";

    if (time.Days == 0 && time.Minutes > 0)
        output += time.Minutes + " min ";

    if (output.Length == 0)
        output += time.Seconds + " sec";

    return output.Trim();
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接