Pārlūkot izejas kodu

访问量图表统计增加对比线

懒得勤快 3 gadi atpakaļ
vecāks
revīzija
58c386b546

+ 86 - 11
src/Masuit.MyBlogs.Core/Controllers/AdvertisementController.cs

@@ -1,5 +1,6 @@
 using AutoMapper.QueryableExtensions;
 using Collections.Pooled;
+using Lucene.Net.Support;
 using Masuit.MyBlogs.Core.Common;
 using Masuit.MyBlogs.Core.Extensions;
 using Masuit.MyBlogs.Core.Infrastructure.Services.Interface;
@@ -41,15 +42,16 @@ public class AdvertisementController : BaseController
         if (!Request.IsRobot() && string.IsNullOrEmpty(HttpContext.Session.Get<string>("ads" + id)))
         {
             HttpContext.Session.Set("ads" + id, id.ToString());
-            ad.ClickRecords.Add(new AdvertisementClickRecord()
+            ClickRecordService.AddEntity(new AdvertisementClickRecord()
             {
                 IP = ClientIP,
                 Location = ClientIP.GetIPLocation(),
                 Referer = Request.Headers[HeaderNames.Referer].ToString(),
-                Time = DateTime.Now
+                Time = DateTime.Now,
+                AdvertisementId = id
             });
-            await AdsService.SaveChangesAsync();
-            var start = DateTime.Today.AddMonths(-6);
+            await ClickRecordService.SaveChangesAsync();
+            var start = DateTime.Today.AddYears(-1);
             await ClickRecordService.GetQuery(a => a.Time < start).DeleteFromQueryAsync();
         }
 
@@ -194,37 +196,110 @@ public class AdvertisementController : BaseController
     /// <summary>
     /// 广告访问记录图表
     /// </summary>
-    /// <param name="id"></param>
-    /// <param name="cancellationToken"></param>
     /// <returns></returns>
     [HttpGet("/partner/{id}/records-chart"), MyAuthorize]
     [ProducesResponseType((int)HttpStatusCode.OK)]
-    public async Task<IActionResult> ClickRecordsChart(int id, CancellationToken cancellationToken)
+    public async Task<IActionResult> ClickRecordsChart(int id, bool compare, uint period, CancellationToken cancellationToken)
     {
+        if (compare)
+        {
+            var start1 = DateTime.Today.AddDays(-period);
+            var list1 = await ClickRecordService.GetQuery(e => e.AdvertisementId == id && e.Time >= start1).Select(e => e.Time).GroupBy(t => t.Date).Select(g => new
+            {
+                Date = g.Key,
+                Count = g.Count()
+            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+            if (list1.Count == 0)
+            {
+                return Ok(Array.Empty<int>());
+            }
+
+            var start2 = start1.AddDays(-period - 1);
+            var list2 = await ClickRecordService.GetQuery(e => e.AdvertisementId == id && e.Time >= start2 && e.Time < start1).Select(e => e.Time).GroupBy(t => t.Date).Select(g => new
+            {
+                Date = g.Key,
+                Count = g.Count()
+            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+
+            // 将数据填充成连续的数据
+            for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
+            {
+                if (list1.All(a => a.Date != i))
+                {
+                    list1.Add(new { Date = i, Count = 0 });
+                }
+            }
+            for (var i = start2; i < start1; i = i.AddDays(1))
+            {
+                if (list2.All(a => a.Date != i))
+                {
+                    list2.Add(new { Date = i, Count = 0 });
+                }
+            }
+            return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
+        }
+
         var list = await ClickRecordService.GetQuery(e => e.AdvertisementId == id).Select(e => e.Time).GroupBy(t => t.Date).Select(g => new
         {
             Date = g.Key,
             Count = g.Count()
         }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-        return Ok(list);
+        return Ok(new[] { list });
     }
 
     /// <summary>
     /// 广告访问记录图表
     /// </summary>
-    /// <param name="cancellationToken"></param>
     /// <returns></returns>
     [HttpGet("/partner/records-chart"), MyAuthorize]
     [ProducesResponseType((int)HttpStatusCode.OK)]
-    public async Task<IActionResult> ClickRecordsChart(CancellationToken cancellationToken)
+    public async Task<IActionResult> ClickRecordsChart(bool compare, uint period, CancellationToken cancellationToken)
     {
+        if (compare)
+        {
+            var start1 = DateTime.Today.AddDays(-period);
+            var list1 = await ClickRecordService.GetQuery(e => e.Time >= start1).Select(e => e.Time).GroupBy(t => t.Date).Select(g => new
+            {
+                Date = g.Key,
+                Count = g.Count()
+            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+            if (list1.Count == 0)
+            {
+                return Ok(Array.Empty<int>());
+            }
+
+            var start2 = start1.AddDays(-period - 1);
+            var list2 = await ClickRecordService.GetQuery(e => e.Time >= start2 && e.Time < start1).Select(e => e.Time).GroupBy(t => t.Date).Select(g => new
+            {
+                Date = g.Key,
+                Count = g.Count()
+            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+
+            // 将数据填充成连续的数据
+            for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
+            {
+                if (list1.All(a => a.Date != i))
+                {
+                    list1.Add(new { Date = i, Count = 0 });
+                }
+            }
+            for (var i = start2; i < start1; i = i.AddDays(1))
+            {
+                if (list2.All(a => a.Date != i))
+                {
+                    list2.Add(new { Date = i, Count = 0 });
+                }
+            }
+            return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
+        }
+
         var start = DateTime.Now.AddMonths(-1);
         var list = await ClickRecordService.GetQuery(e => e.Time >= start).Select(e => e.Time).GroupBy(t => t.Date).Select(g => new
         {
             Date = g.Key,
             Count = g.Count()
         }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-        return Ok(list);
+        return Ok(new[] { list });
     }
 
     /// <summary>

+ 1100 - 1028
src/Masuit.MyBlogs.Core/Controllers/PostController.cs

@@ -44,1210 +44,1282 @@ using System.Text.RegularExpressions;
 using Z.EntityFramework.Plus;
 using SameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode;
 
-namespace Masuit.MyBlogs.Core.Controllers
+namespace Masuit.MyBlogs.Core.Controllers;
+
+/// <summary>
+/// 文章管理
+/// </summary>
+public class PostController : BaseController
 {
-    /// <summary>
-    /// 文章管理
-    /// </summary>
-    public class PostController : BaseController
-    {
-        public IPostService PostService { get; set; }
+    public IPostService PostService { get; set; }
 
-        public ICategoryService CategoryService { get; set; }
+    public ICategoryService CategoryService { get; set; }
 
-        public ISeminarService SeminarService { get; set; }
+    public ISeminarService SeminarService { get; set; }
 
-        public IPostHistoryVersionService PostHistoryVersionService { get; set; }
+    public IPostHistoryVersionService PostHistoryVersionService { get; set; }
 
-        public IWebHostEnvironment HostEnvironment { get; set; }
+    public IWebHostEnvironment HostEnvironment { get; set; }
 
-        public ISearchEngine<DataContext> SearchEngine { get; set; }
+    public ISearchEngine<DataContext> SearchEngine { get; set; }
 
-        public ImagebedClient ImagebedClient { get; set; }
+    public ImagebedClient ImagebedClient { get; set; }
 
-        public IPostVisitRecordService PostVisitRecordService { get; set; }
+    public IPostVisitRecordService PostVisitRecordService { get; set; }
 
-        public ICommentService CommentService { get; set; }
+    public ICommentService CommentService { get; set; }
 
-        public IPostTagService PostTagService { get; set; }
+    public IPostTagService PostTagService { get; set; }
 
-        /// <summary>
-        /// 文章详情页
-        /// </summary>
-        /// <returns></returns>
-        [Route("{id:int}"), Route("{id:int}/comments/{cid:int}"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
-        public async Task<ActionResult> Details(int id, string kw, int cid, string t)
+    /// <summary>
+    /// 文章详情页
+    /// </summary>
+    /// <returns></returns>
+    [Route("{id:int}"), Route("{id:int}/comments/{cid:int}"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
+    public async Task<ActionResult> Details(int id, string kw, int cid, string t)
+    {
+        var notRobot = !Request.IsRobot();
+        if (string.IsNullOrEmpty(t) && notRobot)
         {
-            var notRobot = !Request.IsRobot();
-            if (string.IsNullOrEmpty(t) && notRobot)
+            return RedirectToAction("Details", cid > 0 ? new { id, kw, cid, t = SnowFlake.NewId } : new { id, kw, t = SnowFlake.NewId });
+        }
+
+        var post = await PostService.GetQuery(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)).Include(p => p.Seminar).AsNoTracking().FirstOrDefaultAsync() ?? throw new NotFoundException("文章未找到");
+        CheckPermission(post);
+        if (!string.IsNullOrEmpty(post.Redirect))
+        {
+            if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
             {
-                return RedirectToAction("Details", cid > 0 ? new { id, kw, cid, t = SnowFlake.NewId } : new { id, kw, t = SnowFlake.NewId });
+                BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ClientIP, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
+                HttpContext.Session.Set("post" + id, id.ToString());
             }
 
-            var post = await PostService.GetQuery(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)).Include(p => p.Seminar).AsNoTracking().FirstOrDefaultAsync() ?? throw new NotFoundException("文章未找到");
-            CheckPermission(post);
-            if (!string.IsNullOrEmpty(post.Redirect))
-            {
-                if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
-                {
-                    BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ClientIP, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
-                    HttpContext.Session.Set("post" + id, id.ToString());
-                }
+            return Redirect(post.Redirect);
+        }
 
-                return Redirect(post.Redirect);
-            }
+        post.Category = CategoryService[post.CategoryId];
+        ViewBag.CommentsCount = CommentService.Count(c => c.PostId == id && c.ParentId == null && c.Status == Status.Published);
+        ViewBag.HistoryCount = PostHistoryVersionService.Count(c => c.PostId == id);
+        ViewBag.Keyword = post.Keyword + "," + post.Label;
+        if (Request.Query.ContainsKey("share"))
+        {
+            ViewBag.Desc = await post.Content.GetSummary(200);
+        }
+        else
+        {
+            ViewBag.Desc = "若页面无法访问,可通过搜索引擎网页快照进行浏览。" + await post.Content.GetSummary(200);
+        }
 
-            post.Category = CategoryService[post.CategoryId];
-            ViewBag.CommentsCount = CommentService.Count(c => c.PostId == id && c.ParentId == null && c.Status == Status.Published);
-            ViewBag.HistoryCount = PostHistoryVersionService.Count(c => c.PostId == id);
-            ViewBag.Keyword = post.Keyword + "," + post.Label;
-            if (Request.Query.ContainsKey("share"))
-            {
-                ViewBag.Desc = await post.Content.GetSummary(200);
-            }
-            else
-            {
-                ViewBag.Desc = "若页面无法访问,可通过搜索引擎网页快照进行浏览。" + await post.Content.GetSummary(200);
-            }
+        var modifyDate = post.ModifyDate;
+        ViewBag.Next = await PostService.GetFromCacheAsync<DateTime, PostModelBase>(p => p.ModifyDate > modifyDate && (p.LimitMode ?? 0) == RegionLimitMode.All && (p.Status == Status.Published || CurrentUser.IsAdmin), p => p.ModifyDate);
+        ViewBag.Prev = await PostService.GetFromCacheAsync<DateTime, PostModelBase>(p => p.ModifyDate < modifyDate && (p.LimitMode ?? 0) == RegionLimitMode.All && (p.Status == Status.Published || CurrentUser.IsAdmin), p => p.ModifyDate, false);
+        ViewData[nameof(post.Author)] = post.Author;
+        ViewData[nameof(post.PostDate)] = post.PostDate;
+        ViewData[nameof(post.ModifyDate)] = post.ModifyDate;
+        ViewData["cover"] = post.Content.MatchFirstImgSrc();
+        if (!string.IsNullOrEmpty(kw))
+        {
+            await PostService.Highlight(post, kw);
+        }
 
-            var modifyDate = post.ModifyDate;
-            ViewBag.Next = await PostService.GetFromCacheAsync<DateTime, PostModelBase>(p => p.ModifyDate > modifyDate && (p.LimitMode ?? 0) == RegionLimitMode.All && (p.Status == Status.Published || CurrentUser.IsAdmin), p => p.ModifyDate);
-            ViewBag.Prev = await PostService.GetFromCacheAsync<DateTime, PostModelBase>(p => p.ModifyDate < modifyDate && (p.LimitMode ?? 0) == RegionLimitMode.All && (p.Status == Status.Published || CurrentUser.IsAdmin), p => p.ModifyDate, false);
-            ViewData[nameof(post.Author)] = post.Author;
-            ViewData[nameof(post.PostDate)] = post.PostDate;
-            ViewData[nameof(post.ModifyDate)] = post.ModifyDate;
-            ViewData["cover"] = post.Content.MatchFirstImgSrc();
-            if (!string.IsNullOrEmpty(kw))
-            {
-                await PostService.Highlight(post, kw);
-            }
+        var regex = SearchEngine.LuceneIndexSearcher.CutKeywords(string.IsNullOrWhiteSpace(post.Keyword + post.Label) ? post.Title : post.Keyword + post.Label).Select(Regex.Escape).Join("|");
+        ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, regex);
+        var related = PostService.GetQuery(PostBaseWhere().And(p => p.Id != id && Regex.IsMatch(p.Title + (p.Keyword ?? "") + (p.Label ?? ""), regex, RegexOptions.IgnoreCase)), p => p.AverageViewCount, false).Take(10).Select(p => new { p.Id, p.Title }).FromCache().ToDictionary(p => p.Id, p => p.Title);
+        ViewBag.Related = related;
+        post.ModifyDate = post.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+        post.PostDate = post.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+        post.Content = ReplaceVariables(post.Content);
+        post.ProtectContent = ReplaceVariables(post.ProtectContent);
 
-            var regex = SearchEngine.LuceneIndexSearcher.CutKeywords(string.IsNullOrWhiteSpace(post.Keyword + post.Label) ? post.Title : post.Keyword + post.Label).Select(Regex.Escape).Join("|");
-            ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, regex);
-            var related = PostService.GetQuery(PostBaseWhere().And(p => p.Id != id && Regex.IsMatch(p.Title + (p.Keyword ?? "") + (p.Label ?? ""), regex, RegexOptions.IgnoreCase)), p => p.AverageViewCount, false).Take(10).Select(p => new { p.Id, p.Title }).FromCache().ToDictionary(p => p.Id, p => p.Title);
-            ViewBag.Related = related;
-            post.ModifyDate = post.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            post.PostDate = post.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            post.Content = ReplaceVariables(post.Content);
-            post.ProtectContent = ReplaceVariables(post.ProtectContent);
+        if (CurrentUser.IsAdmin)
+        {
+            return View("Details_Admin", post);
+        }
 
-            if (CurrentUser.IsAdmin)
-            {
-                return View("Details_Admin", post);
-            }
+        if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
+        {
+            BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ClientIP, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
+            HttpContext.Session.Set("post" + id, id.ToString());
+        }
 
-            if (notRobot && string.IsNullOrEmpty(HttpContext.Session.Get<string>("post" + id)))
-            {
-                BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ClientIP, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
-                HttpContext.Session.Set("post" + id, id.ToString());
-            }
+        if (post.LimitMode == RegionLimitMode.OnlyForSearchEngine)
+        {
+            BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ClientIP, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
+        }
 
-            if (post.LimitMode == RegionLimitMode.OnlyForSearchEngine)
-            {
-                BackgroundJob.Enqueue<IHangfireBackJob>(job => job.RecordPostVisit(id, ClientIP, Request.Headers[HeaderNames.Referer].ToString(), Request.GetDisplayUrl()));
-            }
+        return View(post);
+    }
 
-            return View(post);
+    /// <summary>
+    /// 文章历史版本
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="page"></param>
+    /// <param name="size"></param>
+    /// <returns></returns>
+    [Route("{id:int}/history"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "page", "size" }, VaryByHeader = "Cookie")]
+    public async Task<ActionResult> History(int id, [Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 20)
+    {
+        var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
+        CheckPermission(post);
+        ViewBag.Primary = post;
+        var list = await PostHistoryVersionService.GetPagesAsync(page, size, v => v.PostId == id, v => v.ModifyDate, false);
+        foreach (var item in list.Data)
+        {
+            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
         }
 
-        /// <summary>
-        /// 文章历史版本
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="page"></param>
-        /// <param name="size"></param>
-        /// <returns></returns>
-        [Route("{id:int}/history"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "page", "size" }, VaryByHeader = "Cookie")]
-        public async Task<ActionResult> History(int id, [Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 20)
-        {
-            var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
-            CheckPermission(post);
-            ViewBag.Primary = post;
-            var list = await PostHistoryVersionService.GetPagesAsync(page, size, v => v.PostId == id, v => v.ModifyDate, false);
-            foreach (var item in list.Data)
-            {
-                item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            }
+        ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, post.Keyword + "," + post.Label);
+        return View(list);
+    }
+
+    /// <summary>
+    /// 文章历史版本
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="hid"></param>
+    /// <returns></returns>
+    [Route("{id:int}/history/{hid:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "hid" }, VaryByHeader = "Cookie")]
+    public async Task<ActionResult> HistoryVersion(int id, int hid)
+    {
+        var history = await PostHistoryVersionService.GetAsync(v => v.Id == hid && (v.Post.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
+        CheckPermission(history.Post);
+        history.Content = ReplaceVariables(history.Content);
+        history.ProtectContent = ReplaceVariables(history.ProtectContent);
+        history.ModifyDate = history.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+        var next = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate > history.ModifyDate, p => p.ModifyDate);
+        var prev = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate < history.ModifyDate, p => p.ModifyDate, false);
+        ViewBag.Next = next;
+        ViewBag.Prev = prev;
+        ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), history.CategoryId, history.Label);
+        ViewData[nameof(history.Post.Author)] = history.Post.Author;
+        ViewData[nameof(history.Post.PostDate)] = history.Post.PostDate;
+        ViewData[nameof(history.ModifyDate)] = history.ModifyDate;
+        ViewData["cover"] = history.Content.MatchFirstImgSrc();
+        return CurrentUser.IsAdmin ? View("HistoryVersion_Admin", history) : View(history);
+    }
+
+    /// <summary>
+    /// 版本对比
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="v1"></param>
+    /// <param name="v2"></param>
+    /// <returns></returns>
+    [Route("{id:int}/history/{v1:int}-{v2:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "v1", "v2" }, VaryByHeader = "Cookie")]
+    public async Task<ActionResult> CompareVersion(int id, int v1, int v2)
+    {
+        var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin));
+        var main = post.Mapper<PostHistoryVersion>() ?? throw new NotFoundException("文章未找到");
+        CheckPermission(post);
+        var left = v1 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v1) ?? throw new NotFoundException("文章未找到");
+        var right = v2 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v2) ?? throw new NotFoundException("文章未找到");
+        main.Id = id;
+        var diff = new HtmlDiff.HtmlDiff(right.Content, left.Content);
+        var diffOutput = diff.Build();
+        right.Content = ReplaceVariables(Regex.Replace(Regex.Replace(diffOutput, "<ins.+?</ins>", string.Empty), @"<\w+></\w+>", string.Empty));
+        right.ModifyDate = right.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+        left.Content = ReplaceVariables(Regex.Replace(Regex.Replace(diffOutput, "<del.+?</del>", string.Empty), @"<\w+></\w+>", string.Empty));
+        left.ModifyDate = left.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+        ViewBag.Ads = AdsService.GetsByWeightedPrice(2, AdvertiseType.InPage, Request.Location(), main.CategoryId, main.Label);
+        ViewBag.DisableCopy = post.DisableCopy;
+        return View(new[] { main, left, right });
+    }
 
-            ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), post.CategoryId, post.Keyword + "," + post.Label);
-            return View(list);
+    /// <summary>
+    /// 反对
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    public async Task<ActionResult> VoteDown(int id)
+    {
+        if (HttpContext.Session.Get("post-vote" + id) != null)
+        {
+            return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
         }
 
-        /// <summary>
-        /// 文章历史版本
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="hid"></param>
-        /// <returns></returns>
-        [Route("{id:int}/history/{hid:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "hid" }, VaryByHeader = "Cookie")]
-        public async Task<ActionResult> HistoryVersion(int id, int hid)
-        {
-            var history = await PostHistoryVersionService.GetAsync(v => v.Id == hid && (v.Post.Status == Status.Published || CurrentUser.IsAdmin)) ?? throw new NotFoundException("文章未找到");
-            CheckPermission(history.Post);
-            history.Content = ReplaceVariables(history.Content);
-            history.ProtectContent = ReplaceVariables(history.ProtectContent);
-            history.ModifyDate = history.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            var next = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate > history.ModifyDate, p => p.ModifyDate);
-            var prev = await PostHistoryVersionService.GetAsync(p => p.PostId == id && p.ModifyDate < history.ModifyDate, p => p.ModifyDate, false);
-            ViewBag.Next = next;
-            ViewBag.Prev = prev;
-            ViewBag.Ads = AdsService.GetByWeightedPrice(AdvertiseType.InPage, Request.Location(), history.CategoryId, history.Label);
-            ViewData[nameof(history.Post.Author)] = history.Post.Author;
-            ViewData[nameof(history.Post.PostDate)] = history.Post.PostDate;
-            ViewData[nameof(history.ModifyDate)] = history.ModifyDate;
-            ViewData["cover"] = history.Content.MatchFirstImgSrc();
-            return CurrentUser.IsAdmin ? View("HistoryVersion_Admin", history) : View(history);
+        var b = await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
+        {
+            VoteDownCount = p.VoteDownCount + 1
+        }) > 0;
+        if (b)
+        {
+            HttpContext.Session.Set("post-vote" + id, id.GetBytes());
         }
 
-        /// <summary>
-        /// 版本对比
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="v1"></param>
-        /// <param name="v2"></param>
-        /// <returns></returns>
-        [Route("{id:int}/history/{v1:int}-{v2:int}"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "id", "v1", "v2" }, VaryByHeader = "Cookie")]
-        public async Task<ActionResult> CompareVersion(int id, int v1, int v2)
-        {
-            var post = await PostService.GetAsync(p => p.Id == id && (p.Status == Status.Published || CurrentUser.IsAdmin));
-            var main = post.Mapper<PostHistoryVersion>() ?? throw new NotFoundException("文章未找到");
-            CheckPermission(post);
-            var left = v1 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v1) ?? throw new NotFoundException("文章未找到");
-            var right = v2 <= 0 ? main : await PostHistoryVersionService.GetAsync(v => v.Id == v2) ?? throw new NotFoundException("文章未找到");
-            main.Id = id;
-            var diff = new HtmlDiff.HtmlDiff(right.Content, left.Content);
-            var diffOutput = diff.Build();
-            right.Content = ReplaceVariables(Regex.Replace(Regex.Replace(diffOutput, "<ins.+?</ins>", string.Empty), @"<\w+></\w+>", string.Empty));
-            right.ModifyDate = right.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            left.Content = ReplaceVariables(Regex.Replace(Regex.Replace(diffOutput, "<del.+?</del>", string.Empty), @"<\w+></\w+>", string.Empty));
-            left.ModifyDate = left.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            ViewBag.Ads = AdsService.GetsByWeightedPrice(2, AdvertiseType.InPage, Request.Location(), main.CategoryId, main.Label);
-            ViewBag.DisableCopy = post.DisableCopy;
-            return View(new[] { main, left, right });
+        return ResultData(null, b, b ? "投票成功!" : "投票失败!");
+    }
+
+    /// <summary>
+    /// 支持
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    public async Task<ActionResult> VoteUp(int id)
+    {
+        if (HttpContext.Session.Get("post-vote" + id) != null)
+        {
+            return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
         }
 
-        /// <summary>
-        /// 反对
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        public async Task<ActionResult> VoteDown(int id)
+        var b = await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
         {
-            if (HttpContext.Session.Get("post-vote" + id) != null)
-            {
-                return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
-            }
+            VoteUpCount = p.VoteUpCount + 1
+        }) > 0;
+        if (b)
+        {
+            HttpContext.Session.Set("post-vote" + id, id.GetBytes());
+        }
 
-            var b = await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
-            {
-                VoteDownCount = p.VoteDownCount + 1
-            }) > 0;
-            if (b)
-            {
-                HttpContext.Session.Set("post-vote" + id, id.GetBytes());
-            }
+        return ResultData(null, b, b ? "投票成功!" : "投票失败!");
+    }
 
-            return ResultData(null, b, b ? "投票成功!" : "投票失败!");
+    /// <summary>
+    /// 投稿页
+    /// </summary>
+    /// <returns></returns>
+    public ActionResult Publish()
+    {
+        return View();
+    }
+
+    /// <summary>
+    /// 发布投稿
+    /// </summary>
+    /// <param name="post"></param>
+    /// <param name="code"></param>
+    /// <param name="cancellationToken"></param>
+    /// <returns></returns>
+    [HttpPost, ValidateAntiForgeryToken]
+    public async Task<ActionResult> Publish(PostCommand post, [Required(ErrorMessage = "验证码不能为空")] string code, CancellationToken cancellationToken)
+    {
+        if (RedisHelper.Get("code:" + post.Email) != code)
+        {
+            return ResultData(null, false, "验证码错误!");
         }
 
-        /// <summary>
-        /// 支持
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        public async Task<ActionResult> VoteUp(int id)
+        if (PostService.Any(p => p.Status == Status.Forbidden && p.Email == post.Email))
         {
-            if (HttpContext.Session.Get("post-vote" + id) != null)
-            {
-                return ResultData(null, false, "您刚才已经投过票了,感谢您的参与!");
-            }
+            return ResultData(null, false, "由于您曾经恶意投稿,该邮箱已经被标记为黑名单,无法进行投稿,如有疑问,请联系网站管理员进行处理。");
+        }
 
-            var b = await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
-            {
-                VoteUpCount = p.VoteUpCount + 1
-            }) > 0;
-            if (b)
-            {
-                HttpContext.Session.Set("post-vote" + id, id.GetBytes());
-            }
+        var match = Regex.Match(post.Title + post.Author + post.Content, CommonHelper.BanRegex);
+        if (match.Success)
+        {
+            LogManager.Info($"提交内容:{post.Title}/{post.Author}/{post.Content},敏感词:{match.Value}");
+            return ResultData(null, false, "您提交的内容包含敏感词,被禁止发表,请检查您的内容后尝试重新提交!");
+        }
 
-            return ResultData(null, b, b ? "投票成功!" : "投票失败!");
+        if (!CategoryService.Any(c => c.Id == post.CategoryId))
+        {
+            return ResultData(null, message: "请选择一个分类");
         }
 
-        /// <summary>
-        /// 投稿页
-        /// </summary>
-        /// <returns></returns>
-        public ActionResult Publish()
+        post.Label = string.IsNullOrEmpty(post.Label?.Trim()) ? null : post.Label.Replace(",", ",");
+        post.Status = Status.Pending;
+        post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.HtmlSantinizerStandard().ClearImgAttributes(), cancellationToken);
+        Post p = post.Mapper<Post>();
+        p.IP = ClientIP;
+        p.Modifier = p.Author;
+        p.ModifierEmail = p.Email;
+        p.DisableCopy = true;
+        p.Rss = true;
+        PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
         {
-            return View();
+            Name = s,
+            Count = PostService.Count(t => t.Label.Contains(s))
+        }));
+        p = PostService.AddEntitySaved(p);
+        if (p == null)
+        {
+            return ResultData(null, false, "文章发表失败!");
         }
 
-        /// <summary>
-        /// 发布投稿
-        /// </summary>
-        /// <param name="post"></param>
-        /// <param name="code"></param>
-        /// <param name="cancellationToken"></param>
-        /// <returns></returns>
-        [HttpPost, ValidateAntiForgeryToken]
-        public async Task<ActionResult> Publish(PostCommand post, [Required(ErrorMessage = "验证码不能为空")] string code, CancellationToken cancellationToken)
-        {
-            if (RedisHelper.Get("code:" + post.Email) != code)
-            {
-                return ResultData(null, false, "验证码错误!");
-            }
+        RedisHelper.Expire("code:" + p.Email, 1);
+        var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/publish.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
+            .Set("link", Url.Action("Details", "Post", new { id = p.Id }, Request.Scheme))
+            .Set("time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))
+            .Set("title", p.Title).Render();
+        BackgroundJob.Enqueue(() => CommonHelper.SendMail(CommonHelper.SystemSettings["Title"] + "有访客投稿:", content, CommonHelper.SystemSettings["ReceiveEmail"], ClientIP));
+        return ResultData(p.Mapper<PostDto>(), message: "文章发表成功,待站长审核通过以后将显示到列表中!");
+    }
 
-            if (PostService.Any(p => p.Status == Status.Forbidden && p.Email == post.Email))
-            {
-                return ResultData(null, false, "由于您曾经恶意投稿,该邮箱已经被标记为黑名单,无法进行投稿,如有疑问,请联系网站管理员进行处理。");
-            }
+    /// <summary>
+    /// 获取标签
+    /// </summary>
+    /// <returns></returns>
+    [ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
+    public ActionResult GetTag()
+    {
+        return ResultData(PostService.GetTags().Select(x => x.Key).OrderBy(s => s));
+    }
 
-            var match = Regex.Match(post.Title + post.Author + post.Content, CommonHelper.BanRegex);
-            if (match.Success)
-            {
-                LogManager.Info($"提交内容:{post.Title}/{post.Author}/{post.Content},敏感词:{match.Value}");
-                return ResultData(null, false, "您提交的内容包含敏感词,被禁止发表,请检查您的内容后尝试重新提交!");
-            }
+    /// <summary>
+    /// 标签云
+    /// </summary>
+    /// <returns></returns>
+    [Route("all"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
+    public async Task<ActionResult> All()
+    {
+        ViewBag.tags = new Dictionary<string, int>(PostService.GetTags().Where(x => x.Value > 1).OrderBy(x => x.Key));
+        ViewBag.cats = await CategoryService.GetQuery(c => c.Post.Count > 0, c => c.Post.Count, false).Include(c => c.Parent).ThenInclude(c => c.Parent).AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Path()); //category
+        ViewBag.seminars = await SeminarService.GetAll(c => c.Post.Count, false).AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Title); //seminars
+        return View();
+    }
 
-            if (!CategoryService.Any(c => c.Id == post.CategoryId))
-            {
-                return ResultData(null, message: "请选择一个分类");
-            }
+    /// <summary>
+    /// 检查访问密码
+    /// </summary>
+    /// <param name="email"></param>
+    /// <param name="token"></param>
+    /// <returns></returns>
+    [HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
+    public ActionResult CheckViewToken(string email, string token)
+    {
+        if (string.IsNullOrEmpty(token))
+        {
+            return ResultData(null, false, "请输入访问密码!");
+        }
 
-            post.Label = string.IsNullOrEmpty(post.Label?.Trim()) ? null : post.Label.Replace(",", ",");
-            post.Status = Status.Pending;
-            post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.HtmlSantinizerStandard().ClearImgAttributes(), cancellationToken);
-            Post p = post.Mapper<Post>();
-            p.IP = ClientIP;
-            p.Modifier = p.Author;
-            p.ModifierEmail = p.Email;
-            p.DisableCopy = true;
-            p.Rss = true;
-            PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
+        var s = RedisHelper.Get("token:" + email);
+        if (token.Equals(s))
+        {
+            HttpContext.Session.Set("AccessViewToken", token);
+            Response.Cookies.Append("Email", email, new CookieOptions
             {
-                Name = s,
-                Count = PostService.Count(t => t.Label.Contains(s))
-            }));
-            p = PostService.AddEntitySaved(p);
-            if (p == null)
+                Expires = DateTime.Now.AddYears(1),
+                SameSite = SameSiteMode.Lax
+            });
+            Response.Cookies.Append("PostAccessToken", email.MDString3(AppConfig.BaiduAK), new CookieOptions
             {
-                return ResultData(null, false, "文章发表失败!");
-            }
-
-            RedisHelper.Expire("code:" + p.Email, 1);
-            var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/publish.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
-                .Set("link", Url.Action("Details", "Post", new { id = p.Id }, Request.Scheme))
-                .Set("time", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))
-                .Set("title", p.Title).Render();
-            BackgroundJob.Enqueue(() => CommonHelper.SendMail(CommonHelper.SystemSettings["Title"] + "有访客投稿:", content, CommonHelper.SystemSettings["ReceiveEmail"], ClientIP));
-            return ResultData(p.Mapper<PostDto>(), message: "文章发表成功,待站长审核通过以后将显示到列表中!");
+                Expires = DateTime.Now.AddYears(1),
+                SameSite = SameSiteMode.Lax
+            });
+            return ResultData(null);
         }
 
-        /// <summary>
-        /// 获取标签
-        /// </summary>
-        /// <returns></returns>
-        [ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
-        public ActionResult GetTag()
+        return ResultData(null, false, "访问密码不正确!");
+    }
+
+    /// <summary>
+    /// 检查授权邮箱
+    /// </summary>
+    /// <param name="email"></param>
+    /// <returns></returns>
+    [HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
+    public ActionResult GetViewToken(string email)
+    {
+        var validator = new IsEmailAttribute();
+        if (!validator.IsValid(email))
         {
-            return ResultData(PostService.GetTags().Select(x => x.Key).OrderBy(s => s));
+            return ResultData(null, false, validator.ErrorMessage);
         }
 
-        /// <summary>
-        /// 标签云
-        /// </summary>
-        /// <returns></returns>
-        [Route("all"), ResponseCache(Duration = 600, VaryByHeader = "Cookie")]
-        public async Task<ActionResult> All()
-        {
-            ViewBag.tags = new Dictionary<string, int>(PostService.GetTags().Where(x => x.Value > 1).OrderBy(x => x.Key));
-            ViewBag.cats = await CategoryService.GetQuery(c => c.Post.Count > 0, c => c.Post.Count, false).Include(c => c.Parent).ThenInclude(c => c.Parent).AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Path()); //category
-            ViewBag.seminars = await SeminarService.GetAll(c => c.Post.Count, false).AsNoTracking().ToDictionaryAsync(c => c.Id, c => c.Title); //seminars
-            return View();
+        if (RedisHelper.Exists("get:" + email))
+        {
+            RedisHelper.Expire("get:" + email, 120);
+            return ResultData(null, false, "发送频率限制,请在2分钟后重新尝试发送邮件!请检查你的邮件,若未收到,请检查你的邮箱地址或邮件垃圾箱!");
         }
 
-        /// <summary>
-        /// 检查访问密码
-        /// </summary>
-        /// <param name="email"></param>
-        /// <param name="token"></param>
-        /// <returns></returns>
-        [HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
-        public ActionResult CheckViewToken(string email, string token)
+        if (!UserInfoService.Any(b => b.Email.Equals(email)))
         {
-            if (string.IsNullOrEmpty(token))
-            {
-                return ResultData(null, false, "请输入访问密码!");
-            }
+            return ResultData(null, false, "您目前没有权限访问这个链接,请联系站长开通访问权限!");
+        }
 
-            var s = RedisHelper.Get("token:" + email);
-            if (token.Equals(s))
-            {
-                HttpContext.Session.Set("AccessViewToken", token);
-                Response.Cookies.Append("Email", email, new CookieOptions
-                {
-                    Expires = DateTime.Now.AddYears(1),
-                    SameSite = SameSiteMode.Lax
-                });
-                Response.Cookies.Append("PostAccessToken", email.MDString3(AppConfig.BaiduAK), new CookieOptions
-                {
-                    Expires = DateTime.Now.AddYears(1),
-                    SameSite = SameSiteMode.Lax
-                });
-                return ResultData(null);
-            }
+        var token = SnowFlake.GetInstance().GetUniqueShortId(6);
+        RedisHelper.Set("token:" + email, token, 86400);
+        BackgroundJob.Enqueue(() => CommonHelper.SendMail(Request.Host + "博客访问验证码", $"{Request.Host}本次验证码是:<span style='color:red'>{token}</span>,有效期为24h,请按时使用!", email, ClientIP));
+        RedisHelper.Set("get:" + email, token, 120);
+        return ResultData(null);
+    }
 
-            return ResultData(null, false, "访问密码不正确!");
-        }
+    /// <summary>
+    /// 文章合并
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [HttpGet("{id}/merge")]
+    public async Task<ActionResult> PushMerge(int id)
+    {
+        var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
+        CheckPermission(post);
+        return View(post);
+    }
+
+    /// <summary>
+    /// 文章合并
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="mid"></param>
+    /// <returns></returns>
+    [HttpGet("{id}/merge/{mid}")]
+    public async Task<ActionResult> RepushMerge(int id, int mid)
+    {
+        var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
+        CheckPermission(post);
+        var merge = post.PostMergeRequests.FirstOrDefault(p => p.Id == mid && p.MergeState != MergeStatus.Merged) ?? throw new NotFoundException("待合并文章未找到");
+        return View(merge);
+    }
 
-        /// <summary>
-        /// 检查授权邮箱
-        /// </summary>
-        /// <param name="email"></param>
-        /// <returns></returns>
-        [HttpPost, ValidateAntiForgeryToken, AllowAccessFirewall]
-        public ActionResult GetViewToken(string email)
+    /// <summary>
+    /// 文章合并
+    /// </summary>
+    /// <param name="messageService"></param>
+    /// <param name="postMergeRequestService"></param>
+    /// <param name="dto"></param>
+    /// <returns></returns>
+    [HttpPost("{id}/pushmerge")]
+    public async Task<ActionResult> PushMerge([FromServices] IInternalMessageService messageService, [FromServices] IPostMergeRequestService postMergeRequestService, PostMergeRequestCommand dto)
+    {
+        if (RedisHelper.Get("code:" + dto.ModifierEmail) != dto.Code)
         {
-            var validator = new IsEmailAttribute();
-            if (!validator.IsValid(email))
-            {
-                return ResultData(null, false, validator.ErrorMessage);
-            }
+            return ResultData(null, false, "验证码错误!");
+        }
 
-            if (RedisHelper.Exists("get:" + email))
-            {
-                RedisHelper.Expire("get:" + email, 120);
-                return ResultData(null, false, "发送频率限制,请在2分钟后重新尝试发送邮件!请检查你的邮件,若未收到,请检查你的邮箱地址或邮件垃圾箱!");
-            }
+        var post = await PostService.GetAsync(p => p.Id == dto.PostId && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
+        if (post.Title.Equals(dto.Title) && post.Content.HammingDistance(dto.Content) <= 1)
+        {
+            return ResultData(null, false, "内容未被修改或修改的内容过少(无意义修改)!");
+        }
 
-            if (!UserInfoService.Any(b => b.Email.Equals(email)))
-            {
-                return ResultData(null, false, "您目前没有权限访问这个链接,请联系站长开通访问权限!");
-            }
+        #region 合并验证
 
-            var token = SnowFlake.GetInstance().GetUniqueShortId(6);
-            RedisHelper.Set("token:" + email, token, 86400);
-            BackgroundJob.Enqueue(() => CommonHelper.SendMail(Request.Host + "博客访问验证码", $"{Request.Host}本次验证码是:<span style='color:red'>{token}</span>,有效期为24h,请按时使用!", email, ClientIP));
-            RedisHelper.Set("get:" + email, token, 120);
-            return ResultData(null);
+        if (postMergeRequestService.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Block))
+        {
+            return ResultData(null, false, "由于您曾经多次恶意修改文章,已经被标记为黑名单,无法修改任何文章,如有疑问,请联系网站管理员进行处理。");
         }
 
-        /// <summary>
-        /// 文章合并
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [HttpGet("{id}/merge")]
-        public async Task<ActionResult> PushMerge(int id)
-        {
-            var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
-            CheckPermission(post);
-            return View(post);
+        if (post.PostMergeRequests.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Pending))
+        {
+            return ResultData(null, false, "您已经提交过一次修改请求正在待处理,暂不能继续提交修改请求!");
         }
 
-        /// <summary>
-        /// 文章合并
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="mid"></param>
-        /// <returns></returns>
-        [HttpGet("{id}/merge/{mid}")]
-        public async Task<ActionResult> RepushMerge(int id, int mid)
-        {
-            var post = await PostService.GetAsync(p => p.Id == id && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
-            CheckPermission(post);
-            var merge = post.PostMergeRequests.FirstOrDefault(p => p.Id == mid && p.MergeState != MergeStatus.Merged) ?? throw new NotFoundException("待合并文章未找到");
-            return View(merge);
-        }
+        #endregion 合并验证
 
-        /// <summary>
-        /// 文章合并
-        /// </summary>
-        /// <param name="messageService"></param>
-        /// <param name="postMergeRequestService"></param>
-        /// <param name="dto"></param>
-        /// <returns></returns>
-        [HttpPost("{id}/pushmerge")]
-        public async Task<ActionResult> PushMerge([FromServices] IInternalMessageService messageService, [FromServices] IPostMergeRequestService postMergeRequestService, PostMergeRequestCommand dto)
-        {
-            if (RedisHelper.Get("code:" + dto.ModifierEmail) != dto.Code)
-            {
-                return ResultData(null, false, "验证码错误!");
-            }
+        #region 直接合并
 
-            var post = await PostService.GetAsync(p => p.Id == dto.PostId && p.Status == Status.Published && !p.Locked) ?? throw new NotFoundException("文章未找到");
-            if (post.Title.Equals(dto.Title) && post.Content.HammingDistance(dto.Content) <= 1)
-            {
-                return ResultData(null, false, "内容未被修改或修改的内容过少(无意义修改)!");
-            }
+        if (post.Email.Equals(dto.ModifierEmail))
+        {
+            var history = post.Mapper<PostHistoryVersion>();
+            Mapper.Map(dto, post);
+            post.PostHistoryVersion.Add(history);
+            post.ModifyDate = DateTime.Now;
+            return await PostService.SaveChangesAsync() > 0 ? ResultData(null, true, "你是文章原作者,无需审核,文章已自动更新并在首页展示!") : ResultData(null, false, "操作失败!");
+        }
 
-            #region 合并验证
+        #endregion 直接合并
 
-            if (postMergeRequestService.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Block))
-            {
-                return ResultData(null, false, "由于您曾经多次恶意修改文章,已经被标记为黑名单,无法修改任何文章,如有疑问,请联系网站管理员进行处理。");
-            }
+        var merge = post.PostMergeRequests.FirstOrDefault(r => r.Id == dto.Id && r.MergeState != MergeStatus.Merged);
+        if (merge != null)
+        {
+            Mapper.Map(dto, merge);
+            merge.SubmitTime = DateTime.Now;
+            merge.MergeState = MergeStatus.Pending;
+        }
+        else
+        {
+            merge = Mapper.Map<PostMergeRequest>(dto);
+            merge.SubmitTime = DateTime.Now;
+            post.PostMergeRequests.Add(merge);
+        }
+        merge.IP = ClientIP;
+        var b = await PostService.SaveChangesAsync() > 0;
+        if (!b)
+        {
+            return ResultData(null, false, "操作失败!");
+        }
 
-            if (post.PostMergeRequests.Any(p => p.ModifierEmail == dto.ModifierEmail && p.MergeState == MergeStatus.Pending))
-            {
-                return ResultData(null, false, "您已经提交过一次修改请求正在待处理,暂不能继续提交修改请求!");
-            }
+        RedisHelper.Expire("code:" + dto.ModifierEmail, 1);
+        await messageService.AddEntitySavedAsync(new InternalMessage()
+        {
+            Title = $"来自【{dto.Modifier}】对文章《{post.Title}》的修改请求",
+            Content = dto.Title,
+            Link = "#/merge/compare?id=" + merge.Id
+        });
+
+        var htmlDiff = new HtmlDiff.HtmlDiff(post.Content.RemoveHtmlTag(), dto.Content.RemoveHtmlTag());
+        var diff = htmlDiff.Build();
+        var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/merge-request.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
+            .Set("title", post.Title)
+            .Set("link", Url.Action("Index", "Dashboard", new { }, Request.Scheme) + "#/merge/compare?id=" + merge.Id)
+            .Set("diff", diff)
+            .Set("host", "//" + Request.Host)
+            .Set("id", merge.Id.ToString())
+            .Render();
+        BackgroundJob.Enqueue(() => CommonHelper.SendMail("博客文章修改请求:", content, CommonHelper.SystemSettings["ReceiveEmail"], ClientIP));
+        return ResultData(null, true, "您的修改请求已提交,已进入审核状态,感谢您的参与!");
+    }
 
-            #endregion 合并验证
+    #region 后端管理
 
-            #region 直接合并
+    /// <summary>
+    /// 固顶
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> Fixtop(int id)
+    {
+        Post post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+        post.IsFixedTop = !post.IsFixedTop;
+        bool b = await PostService.SaveChangesAsync() > 0;
+        return b ? ResultData(null, true, post.IsFixedTop ? "置顶成功!" : "取消置顶成功!") : ResultData(null, false, "操作失败!");
+    }
 
-            if (post.Email.Equals(dto.ModifierEmail))
-            {
-                var history = post.Mapper<PostHistoryVersion>();
-                Mapper.Map(dto, post);
-                post.PostHistoryVersion.Add(history);
-                post.ModifyDate = DateTime.Now;
-                return await PostService.SaveChangesAsync() > 0 ? ResultData(null, true, "你是文章原作者,无需审核,文章已自动更新并在首页展示!") : ResultData(null, false, "操作失败!");
-            }
+    /// <summary>
+    /// 审核
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> Pass(int id)
+    {
+        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+        post.Status = Status.Published;
+        post.ModifyDate = DateTime.Now;
+        post.PostDate = DateTime.Now;
+        var b = await PostService.SaveChangesAsync() > 0;
+        if (!b)
+        {
+            return ResultData(null, false, "审核失败!");
+        }
 
-            #endregion 直接合并
+        (post.Keyword + "," + post.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
+        SearchEngine.LuceneIndexer.Add(post);
+        return ResultData(null, true, "审核通过!");
+    }
 
-            var merge = post.PostMergeRequests.FirstOrDefault(r => r.Id == dto.Id && r.MergeState != MergeStatus.Merged);
-            if (merge != null)
-            {
-                Mapper.Map(dto, merge);
-                merge.SubmitTime = DateTime.Now;
-                merge.MergeState = MergeStatus.Pending;
-            }
-            else
-            {
-                merge = Mapper.Map<PostMergeRequest>(dto);
-                merge.SubmitTime = DateTime.Now;
-                post.PostMergeRequests.Add(merge);
-            }
-            merge.IP = ClientIP;
-            var b = await PostService.SaveChangesAsync() > 0;
-            if (!b)
-            {
-                return ResultData(null, false, "操作失败!");
-            }
+    /// <summary>
+    /// 删除
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> Delete(int id)
+    {
+        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+        post.Status = Status.Deleted;
+        bool b = await PostService.SaveChangesAsync(true) > 0;
+        SearchEngine.LuceneIndexer.Delete(post);
+        return ResultData(null, b, b ? "删除成功!" : "删除失败!");
+    }
 
-            RedisHelper.Expire("code:" + dto.ModifierEmail, 1);
-            await messageService.AddEntitySavedAsync(new InternalMessage()
-            {
-                Title = $"来自【{dto.Modifier}】对文章《{post.Title}》的修改请求",
-                Content = dto.Title,
-                Link = "#/merge/compare?id=" + merge.Id
-            });
+    /// <summary>
+    /// 还原版本
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> Restore(int id)
+    {
+        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+        post.Status = Status.Published;
+        bool b = await PostService.SaveChangesAsync() > 0;
+        SearchEngine.LuceneIndexer.Add(post);
+        return ResultData(null, b, b ? "恢复成功!" : "恢复失败!");
+    }
 
-            var htmlDiff = new HtmlDiff.HtmlDiff(post.Content.RemoveHtmlTag(), dto.Content.RemoveHtmlTag());
-            var diff = htmlDiff.Build();
-            var content = new Template(await new FileInfo(HostEnvironment.WebRootPath + "/template/merge-request.html").ShareReadWrite().ReadAllTextAsync(Encoding.UTF8))
-                .Set("title", post.Title)
-                .Set("link", Url.Action("Index", "Dashboard", new { }, Request.Scheme) + "#/merge/compare?id=" + merge.Id)
-                .Set("diff", diff)
-                .Set("host", "//" + Request.Host)
-                .Set("id", merge.Id.ToString())
-                .Render();
-            BackgroundJob.Enqueue(() => CommonHelper.SendMail("博客文章修改请求:", content, CommonHelper.SystemSettings["ReceiveEmail"], ClientIP));
-            return ResultData(null, true, "您的修改请求已提交,已进入审核状态,感谢您的参与!");
-        }
+    /// <summary>
+    /// 彻底删除文章
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public ActionResult Truncate(int id)
+    {
+        bool b = PostService - id;
+        return ResultData(null, b, b ? "删除成功!" : "删除失败!");
+    }
 
-        #region 后端管理
+    /// <summary>
+    /// 获取文章
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public ActionResult Get(int id)
+    {
+        Post post = PostService[id] ?? throw new NotFoundException("文章未找到");
+        PostDto model = post.Mapper<PostDto>();
+        model.Seminars = post.Seminar.Select(s => s.Id).Join(",");
+        return ResultData(model);
+    }
 
-        /// <summary>
-        /// 固顶
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> Fixtop(int id)
+    /// <summary>
+    /// 获取文章分页
+    /// </summary>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> GetPageData([FromServices] ICacheManager<HashSet<string>> cacheManager, int page = 1, [Range(1, 200, ErrorMessage = "页大小必须介于{1}-{2}")] int size = 10, OrderBy orderby = OrderBy.ModifyDate, string kw = "", int? cid = null)
+    {
+        Expression<Func<Post, bool>> where = p => true;
+        if (cid.HasValue)
         {
-            Post post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-            post.IsFixedTop = !post.IsFixedTop;
-            bool b = await PostService.SaveChangesAsync() > 0;
-            return b ? ResultData(null, true, post.IsFixedTop ? "置顶成功!" : "取消置顶成功!") : ResultData(null, false, "操作失败!");
+            where = where.And(p => p.CategoryId == cid.Value || p.Category.ParentId == cid.Value || p.Category.Parent.ParentId == cid.Value);
         }
 
-        /// <summary>
-        /// 审核
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> Pass(int id)
+        if (!string.IsNullOrEmpty(kw))
         {
-            var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-            post.Status = Status.Published;
-            post.ModifyDate = DateTime.Now;
-            post.PostDate = DateTime.Now;
-            var b = await PostService.SaveChangesAsync() > 0;
-            if (!b)
-            {
-                return ResultData(null, false, "审核失败!");
-            }
-
-            (post.Keyword + "," + post.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
-            SearchEngine.LuceneIndexer.Add(post);
-            return ResultData(null, true, "审核通过!");
+            kw = Regex.Escape(kw);
+            where = where.And(p => Regex.IsMatch(p.Title + p.Author + p.Email + p.Content, kw, RegexOptions.IgnoreCase));
         }
 
-        /// <summary>
-        /// 删除
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> Delete(int id)
-        {
-            var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-            post.Status = Status.Deleted;
-            bool b = await PostService.SaveChangesAsync(true) > 0;
-            SearchEngine.LuceneIndexer.Delete(post);
-            return ResultData(null, b, b ? "删除成功!" : "删除失败!");
+        var list = orderby switch
+        {
+            OrderBy.Trending => await PostService.GetQuery(where).OrderByDescending(p => p.Status).ThenByDescending(p => p.IsFixedTop).ThenByDescending(p => p.PostVisitRecordStats.Sum(t => t.Count) / p.PostVisitRecordStats.Count).ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig),
+            _ => await PostService.GetQuery(where).OrderBy($"{nameof(Post.Status)} desc,{nameof(Post.IsFixedTop)} desc,{orderby.GetDisplay()} desc").ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig)
+        };
+        foreach (var item in list.Data)
+        {
+            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+            item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+            item.Online = cacheManager.Get(nameof(PostOnline) + ":" + item.Id)?.Count ?? 0;
         }
 
-        /// <summary>
-        /// 还原版本
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> Restore(int id)
-        {
-            var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-            post.Status = Status.Published;
-            bool b = await PostService.SaveChangesAsync() > 0;
-            SearchEngine.LuceneIndexer.Add(post);
-            return ResultData(null, b, b ? "恢复成功!" : "恢复失败!");
+        return Ok(list);
+    }
+
+    /// <summary>
+    /// 获取未审核文章
+    /// </summary>
+    /// <param name="page"></param>
+    /// <param name="size"></param>
+    /// <param name="search"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> GetPending([Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15, string search = "")
+    {
+        Expression<Func<Post, bool>> where = p => p.Status == Status.Pending;
+        if (!string.IsNullOrEmpty(search))
+        {
+            where = where.And(p => p.Title.Contains(search) || p.Author.Contains(search) || p.Email.Contains(search) || p.Label.Contains(search));
         }
 
-        /// <summary>
-        /// 彻底删除文章
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public ActionResult Truncate(int id)
+        var pages = await PostService.GetQuery(where).OrderByDescending(p => p.IsFixedTop).ThenByDescending(p => p.ModifyDate).ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig);
+        foreach (var item in pages.Data)
         {
-            bool b = PostService - id;
-            return ResultData(null, b, b ? "删除成功!" : "删除失败!");
+            item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
+            item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
         }
 
-        /// <summary>
-        /// 获取文章
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public ActionResult Get(int id)
-        {
-            Post post = PostService[id] ?? throw new NotFoundException("文章未找到");
-            PostDto model = post.Mapper<PostDto>();
-            model.Seminars = post.Seminar.Select(s => s.Id).Join(",");
-            return ResultData(model);
+        return Ok(pages);
+    }
+
+    /// <summary>
+    /// 编辑
+    /// </summary>
+    /// <param name="post"></param>
+    /// <param name="cancellationToken"></param>
+    /// <returns></returns>
+    [HttpPost, MyAuthorize]
+    public async Task<ActionResult> Edit([FromBodyOrDefault] PostCommand post, CancellationToken cancellationToken = default)
+    {
+        post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
+        if (!ValidatePost(post, out var resultData))
+        {
+            return resultData;
         }
 
-        /// <summary>
-        /// 获取文章分页
-        /// </summary>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> GetPageData([FromServices] ICacheManager<HashSet<string>> cacheManager, int page = 1, [Range(1, 200, ErrorMessage = "页大小必须介于{1}-{2}")] int size = 10, OrderBy orderby = OrderBy.ModifyDate, string kw = "", int? cid = null)
+        Post p = await PostService.GetByIdAsync(post.Id);
+        if (post.Reserve && p.Status == Status.Published)
         {
-            Expression<Func<Post, bool>> where = p => true;
-            if (cid.HasValue)
+            if (p.Content.HammingDistance(post.Content) > 0)
             {
-                where = where.And(p => p.CategoryId == cid.Value || p.Category.ParentId == cid.Value || p.Category.Parent.ParentId == cid.Value);
+                var history = p.Mapper<PostHistoryVersion>();
+                p.PostHistoryVersion.Add(history);
             }
 
-            if (!string.IsNullOrEmpty(kw))
+            if (p.Title.HammingDistance(post.Title) > 10 && CommentService.Any(c => c.PostId == p.Id && c.ParentId == null))
             {
-                kw = Regex.Escape(kw);
-                where = where.And(p => Regex.IsMatch(p.Title + p.Author + p.Email + p.Content, kw, RegexOptions.IgnoreCase));
+                p.Comment.Add(new Comment
+                {
+                    Status = Status.Published,
+                    NickName = "系统自动评论",
+                    Email = p.Email,
+                    Content = $"<p style=\"color:red\">温馨提示:由于文章发生了重大更新,本条评论之前的所有评论仅作为原文《{p.Title}》的历史评论保留,不作为本文的最新评论参考,请知悉!了解更多信息,请查阅本文的历史修改记录。</p>",
+                    PostId = p.Id,
+                    CommentDate = DateTime.Now,
+                    IsMaster = true,
+                    IsAuthor = true,
+                    IP = "127.0.0.1",
+                    Location = "内网",
+                    GroupTag = SnowFlake.NewId,
+                    Path = SnowFlake.NewId,
+                });
             }
 
-            var list = orderby switch
-            {
-                OrderBy.Trending => await PostService.GetQuery(where).OrderByDescending(p => p.Status).ThenByDescending(p => p.IsFixedTop).ThenByDescending(p => p.PostVisitRecordStats.Sum(t => t.Count) / p.PostVisitRecordStats.Count).ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig),
-                _ => await PostService.GetQuery(where).OrderBy($"{nameof(Post.Status)} desc,{nameof(Post.IsFixedTop)} desc,{orderby.GetDisplay()} desc").ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig)
-            };
-            foreach (var item in list.Data)
-            {
-                item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-                item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-                item.Online = cacheManager.Get(nameof(PostOnline) + ":" + item.Id)?.Count ?? 0;
-            }
+            p.ModifyDate = DateTime.Now;
+            var user = HttpContext.Session.Get<UserInfoDto>(SessionKey.UserInfo);
+            post.Modifier = string.IsNullOrEmpty(post.Modifier) ? user.NickName : post.Modifier;
+            post.ModifierEmail = string.IsNullOrEmpty(post.ModifierEmail) ? user.Email : post.ModifierEmail;
+        }
 
-            return Ok(list);
+        Mapper.Map(post, p);
+        p.IP = ClientIP;
+        p.Seminar.Clear();
+        if (!string.IsNullOrEmpty(post.Seminars))
+        {
+            var tmp = post.Seminars.Split(',', StringSplitOptions.RemoveEmptyEntries).Distinct().Select(int.Parse).ToArray();
+            var seminars = SeminarService.GetQuery(s => tmp.Contains(s.Id)).ToList();
+            p.Seminar.AddRange(seminars);
         }
 
-        /// <summary>
-        /// 获取未审核文章
-        /// </summary>
-        /// <param name="page"></param>
-        /// <param name="size"></param>
-        /// <param name="search"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> GetPending([Range(1, int.MaxValue, ErrorMessage = "页码必须大于0")] int page = 1, [Range(1, 50, ErrorMessage = "页大小必须在0到50之间")] int size = 15, string search = "")
-        {
-            Expression<Func<Post, bool>> where = p => p.Status == Status.Pending;
-            if (!string.IsNullOrEmpty(search))
-            {
-                where = where.And(p => p.Title.Contains(search) || p.Author.Contains(search) || p.Email.Contains(search) || p.Label.Contains(search));
-            }
+        (p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
+        PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
+        {
+            Name = s,
+            Count = PostService.Count(t => t.Label.Contains(s))
+        }));
+        bool b = await SearchEngine.SaveChangesAsync() > 0;
+        if (!b)
+        {
+            return ResultData(null, false, "文章修改失败!");
+        }
 
-            var pages = await PostService.GetQuery(where).OrderByDescending(p => p.IsFixedTop).ThenByDescending(p => p.ModifyDate).ToPagedListAsync<Post, PostDataModel>(page, size, MapperConfig);
-            foreach (var item in pages.Data)
-            {
-                item.ModifyDate = item.ModifyDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-                item.PostDate = item.PostDate.ToTimeZone(HttpContext.Session.Get<string>(SessionKey.TimeZone));
-            }
+        if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
+        {
+            SearchEngine.LuceneIndexer.Delete(p);
+        }
+        return ResultData(p.Mapper<PostDto>(), message: "文章修改成功!");
+    }
 
-            return Ok(pages);
+    /// <summary>
+    /// 发布
+    /// </summary>
+    /// <param name="post"></param>
+    /// <param name="timespan"></param>
+    /// <param name="schedule"></param>
+    /// <param name="cancellationToken"></param>
+    /// <returns></returns>
+    [MyAuthorize, HttpPost]
+    public async Task<ActionResult> Write([FromBodyOrDefault] PostCommand post, [FromBodyOrDefault] DateTime? timespan, [FromBodyOrDefault] bool schedule = false, CancellationToken cancellationToken = default)
+    {
+        post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
+        if (!ValidatePost(post, out var resultData))
+        {
+            return resultData;
         }
 
-        /// <summary>
-        /// 编辑
-        /// </summary>
-        /// <param name="post"></param>
-        /// <param name="cancellationToken"></param>
-        /// <returns></returns>
-        [HttpPost, MyAuthorize]
-        public async Task<ActionResult> Edit([FromBodyOrDefault] PostCommand post, CancellationToken cancellationToken = default)
-        {
-            post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
-            if (!ValidatePost(post, out var resultData))
-            {
-                return resultData;
-            }
+        post.Status = Status.Published;
+        Post p = post.Mapper<Post>();
+        p.Modifier = p.Author;
+        p.ModifierEmail = p.Email;
+        p.IP = ClientIP;
+        p.Rss = p.LimitMode is null or RegionLimitMode.All;
+        if (!string.IsNullOrEmpty(post.Seminars))
+        {
+            var tmp = post.Seminars.Split(',').Distinct().Select(int.Parse).ToArray();
+            p.Seminar.AddRange(SeminarService[s => tmp.Contains(s.Id)]);
+        }
 
-            Post p = await PostService.GetByIdAsync(post.Id);
-            if (post.Reserve && p.Status == Status.Published)
+        if (schedule)
+        {
+            if (!timespan.HasValue || timespan.Value <= DateTime.Now)
             {
-                if (p.Content.HammingDistance(post.Content) > 0)
-                {
-                    var history = p.Mapper<PostHistoryVersion>();
-                    p.PostHistoryVersion.Add(history);
-                }
-
-                if (p.Title.HammingDistance(post.Title) > 10 && CommentService.Any(c => c.PostId == p.Id && c.ParentId == null))
-                {
-                    p.Comment.Add(new Comment
-                    {
-                        Status = Status.Published,
-                        NickName = "系统自动评论",
-                        Email = p.Email,
-                        Content = $"<p style=\"color:red\">温馨提示:由于文章发生了重大更新,本条评论之前的所有评论仅作为原文《{p.Title}》的历史评论保留,不作为本文的最新评论参考,请知悉!了解更多信息,请查阅本文的历史修改记录。</p>",
-                        PostId = p.Id,
-                        CommentDate = DateTime.Now,
-                        IsMaster = true,
-                        IsAuthor = true,
-                        IP = "127.0.0.1",
-                        Location = "内网",
-                        GroupTag = SnowFlake.NewId,
-                        Path = SnowFlake.NewId,
-                    });
-                }
-
-                p.ModifyDate = DateTime.Now;
-                var user = HttpContext.Session.Get<UserInfoDto>(SessionKey.UserInfo);
-                post.Modifier = string.IsNullOrEmpty(post.Modifier) ? user.NickName : post.Modifier;
-                post.ModifierEmail = string.IsNullOrEmpty(post.ModifierEmail) ? user.Email : post.ModifierEmail;
+                return ResultData(null, false, "如果要定时发布,请选择正确的一个将来时间点!");
             }
 
-            Mapper.Map(post, p);
-            p.IP = ClientIP;
-            p.Seminar.Clear();
-            if (!string.IsNullOrEmpty(post.Seminars))
-            {
-                var tmp = post.Seminars.Split(',', StringSplitOptions.RemoveEmptyEntries).Distinct().Select(int.Parse).ToArray();
-                var seminars = SeminarService.GetQuery(s => tmp.Contains(s.Id)).ToList();
-                p.Seminar.AddRange(seminars);
-            }
+            p.Status = Status.Schedule;
+            p.PostDate = timespan.Value.ToUniversalTime();
+            p.ModifyDate = timespan.Value.ToUniversalTime();
+            BackgroundJob.Enqueue<IHangfireBackJob>(job => job.PublishPost(p));
+            return ResultData(p.Mapper<PostDto>(), message: $"文章于{timespan.Value:yyyy-MM-dd HH:mm:ss}将会自动发表!");
+        }
 
-            (p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
-            PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
-            {
-                Name = s,
-                Count = PostService.Count(t => t.Label.Contains(s))
-            }));
-            bool b = await SearchEngine.SaveChangesAsync() > 0;
-            if (!b)
-            {
-                return ResultData(null, false, "文章修改失败!");
-            }
+        PostService.AddEntity(p);
+        (p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
+        PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
+        {
+            Name = s,
+            Count = PostService.Count(t => t.Label.Contains(s))
+        }));
+        bool b = await SearchEngine.SaveChangesAsync() > 0;
+        if (!b)
+        {
+            return ResultData(null, false, "文章发表失败!");
+        }
 
-            if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
-            {
-                SearchEngine.LuceneIndexer.Delete(p);
-            }
-            return ResultData(p.Mapper<PostDto>(), message: "文章修改成功!");
+        if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
+        {
+            SearchEngine.LuceneIndexer.Delete(p);
         }
 
-        /// <summary>
-        /// 发布
-        /// </summary>
-        /// <param name="post"></param>
-        /// <param name="timespan"></param>
-        /// <param name="schedule"></param>
-        /// <param name="cancellationToken"></param>
-        /// <returns></returns>
-        [MyAuthorize, HttpPost]
-        public async Task<ActionResult> Write([FromBodyOrDefault] PostCommand post, [FromBodyOrDefault] DateTime? timespan, [FromBodyOrDefault] bool schedule = false, CancellationToken cancellationToken = default)
-        {
-            post.Content = await ImagebedClient.ReplaceImgSrc(await post.Content.Trim().ClearImgAttributes(), cancellationToken);
-            if (!ValidatePost(post, out var resultData))
-            {
-                return resultData;
-            }
+        return ResultData(null, true, "文章发表成功!");
+    }
 
-            post.Status = Status.Published;
-            Post p = post.Mapper<Post>();
-            p.Modifier = p.Author;
-            p.ModifierEmail = p.Email;
-            p.IP = ClientIP;
-            p.Rss = p.LimitMode is null or RegionLimitMode.All;
-            if (!string.IsNullOrEmpty(post.Seminars))
-            {
-                var tmp = post.Seminars.Split(',').Distinct().Select(int.Parse).ToArray();
-                p.Seminar.AddRange(SeminarService[s => tmp.Contains(s.Id)]);
-            }
+    private bool ValidatePost(PostCommand post, out ActionResult resultData)
+    {
+        if (!CategoryService.Any(c => c.Id == post.CategoryId && c.Status == Status.Available))
+        {
+            resultData = ResultData(null, false, "请选择一个分类");
+            return false;
+        }
 
-            if (schedule)
-            {
-                if (!timespan.HasValue || timespan.Value <= DateTime.Now)
+        switch (post.LimitMode)
+        {
+            case RegionLimitMode.AllowRegion:
+            case RegionLimitMode.ForbidRegion:
+                if (string.IsNullOrEmpty(post.Regions))
                 {
-                    return ResultData(null, false, "如果要定时发布,请选择正确的一个将来时间点!");
+                    resultData = ResultData(null, false, "请输入限制的地区");
+                    return false;
                 }
 
-                p.Status = Status.Schedule;
-                p.PostDate = timespan.Value.ToUniversalTime();
-                p.ModifyDate = timespan.Value.ToUniversalTime();
-                BackgroundJob.Enqueue<IHangfireBackJob>(job => job.PublishPost(p));
-                return ResultData(p.Mapper<PostDto>(), message: $"文章于{timespan.Value:yyyy-MM-dd HH:mm:ss}将会自动发表!");
-            }
+                post.Regions = post.Regions.Replace(",", "|").Replace(",", "|");
+                break;
 
-            PostService.AddEntity(p);
-            (p.Keyword + "," + p.Label).Split(',', StringSplitOptions.RemoveEmptyEntries).ForEach(KeywordsManager.AddWords);
-            PostTagService.AddOrUpdate(t => t.Name, p.Label.AsNotNull().Split(',', StringSplitOptions.RemoveEmptyEntries).Select(s => new PostTag()
-            {
-                Name = s,
-                Count = PostService.Count(t => t.Label.Contains(s))
-            }));
-            bool b = await SearchEngine.SaveChangesAsync() > 0;
-            if (!b)
-            {
-                return ResultData(null, false, "文章发表失败!");
-            }
+            case RegionLimitMode.AllowRegionExceptForbidRegion:
+            case RegionLimitMode.ForbidRegionExceptAllowRegion:
+                if (string.IsNullOrEmpty(post.ExceptRegions))
+                {
+                    resultData = ResultData(null, false, "请输入排除的地区");
+                    return false;
+                }
 
-            if (p.LimitMode == RegionLimitMode.OnlyForSearchEngine)
-            {
-                SearchEngine.LuceneIndexer.Delete(p);
-            }
+                post.ExceptRegions = post.ExceptRegions.Replace(",", "|").Replace(",", "|");
+                goto case RegionLimitMode.AllowRegion;
+        }
 
-            return ResultData(null, true, "文章发表成功!");
+        if (string.IsNullOrEmpty(post.Label?.Trim()) || post.Label.Equals("null"))
+        {
+            post.Label = null;
+        }
+        else if (post.Label.Trim().Length > 50)
+        {
+            post.Label = post.Label.Replace(",", ",");
+            post.Label = post.Label.Trim().Substring(0, 50);
+        }
+        else
+        {
+            post.Label = post.Label.Replace(",", ",");
         }
 
-        private bool ValidatePost(PostCommand post, out ActionResult resultData)
+        if (string.IsNullOrEmpty(post.ProtectContent?.RemoveHtmlTag()) || post.ProtectContent.Equals("null"))
         {
-            if (!CategoryService.Any(c => c.Id == post.CategoryId && c.Status == Status.Available))
-            {
-                resultData = ResultData(null, false, "请选择一个分类");
-                return false;
-            }
+            post.ProtectContent = null;
+        }
 
-            switch (post.LimitMode)
-            {
-                case RegionLimitMode.AllowRegion:
-                case RegionLimitMode.ForbidRegion:
-                    if (string.IsNullOrEmpty(post.Regions))
-                    {
-                        resultData = ResultData(null, false, "请输入限制的地区");
-                        return false;
-                    }
-
-                    post.Regions = post.Regions.Replace(",", "|").Replace(",", "|");
-                    break;
-
-                case RegionLimitMode.AllowRegionExceptForbidRegion:
-                case RegionLimitMode.ForbidRegionExceptAllowRegion:
-                    if (string.IsNullOrEmpty(post.ExceptRegions))
-                    {
-                        resultData = ResultData(null, false, "请输入排除的地区");
-                        return false;
-                    }
-
-                    post.ExceptRegions = post.ExceptRegions.Replace(",", "|").Replace(",", "|");
-                    goto case RegionLimitMode.AllowRegion;
-            }
+        resultData = null;
+        return true;
+    }
 
-            if (string.IsNullOrEmpty(post.Label?.Trim()) || post.Label.Equals("null"))
-            {
-                post.Label = null;
-            }
-            else if (post.Label.Trim().Length > 50)
-            {
-                post.Label = post.Label.Replace(",", ",");
-                post.Label = post.Label.Trim().Substring(0, 50);
-            }
-            else
-            {
-                post.Label = post.Label.Replace(",", ",");
-            }
+    /// <summary>
+    /// 添加专题
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="sid"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> AddSeminar(int id, int sid)
+    {
+        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+        Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
+        post.Seminar.Add(seminar);
+        bool b = await PostService.SaveChangesAsync() > 0;
+        return ResultData(null, b, b ? $"已将文章【{post.Title}】添加到专题【{seminar.Title}】" : "添加失败");
+    }
 
-            if (string.IsNullOrEmpty(post.ProtectContent?.RemoveHtmlTag()) || post.ProtectContent.Equals("null"))
-            {
-                post.ProtectContent = null;
-            }
+    /// <summary>
+    /// 移除专题
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="sid"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> RemoveSeminar(int id, int sid)
+    {
+        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+        Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
+        post.Seminar.Remove(seminar);
+        bool b = await PostService.SaveChangesAsync() > 0;
+        return ResultData(null, b, b ? $"已将文章【{post.Title}】从【{seminar.Title}】专题移除" : "添加失败");
+    }
 
-            resultData = null;
-            return true;
-        }
+    /// <summary>
+    /// 删除历史版本
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> DeleteHistory(int id)
+    {
+        bool b = await PostHistoryVersionService.DeleteByIdAsync(id) > 0;
+        return ResultData(null, b, b ? "历史版本文章删除成功!" : "历史版本文章删除失败!");
+    }
 
-        /// <summary>
-        /// 添加专题
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="sid"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> AddSeminar(int id, int sid)
-        {
-            var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-            Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
-            post.Seminar.Add(seminar);
-            bool b = await PostService.SaveChangesAsync() > 0;
-            return ResultData(null, b, b ? $"已将文章【{post.Title}】添加到专题【{seminar.Title}】" : "添加失败");
+    /// <summary>
+    /// 还原版本
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> Revert(int id)
+    {
+        var history = await PostHistoryVersionService.GetByIdAsync(id) ?? throw new NotFoundException("版本不存在");
+        history.Post.Category = history.Category;
+        history.Post.CategoryId = history.CategoryId;
+        history.Post.Content = history.Content;
+        history.Post.Title = history.Title;
+        history.Post.Label = history.Label;
+        history.Post.ModifyDate = history.ModifyDate;
+        history.Post.Seminar.Clear();
+        foreach (var s in history.Seminar)
+        {
+            history.Post.Seminar.Add(s);
         }
+        bool b = await SearchEngine.SaveChangesAsync() > 0;
+        await PostHistoryVersionService.DeleteByIdAsync(id);
+        return ResultData(null, b, b ? "回滚成功" : "回滚失败");
+    }
 
-        /// <summary>
-        /// 移除专题
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="sid"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> RemoveSeminar(int id, int sid)
-        {
-            var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-            Seminar seminar = await SeminarService.GetByIdAsync(sid) ?? throw new NotFoundException("专题未找到");
-            post.Seminar.Remove(seminar);
-            bool b = await PostService.SaveChangesAsync() > 0;
-            return ResultData(null, b, b ? $"已将文章【{post.Title}】从【{seminar.Title}】专题移除" : "添加失败");
-        }
+    /// <summary>
+    /// 禁用或开启文章评论
+    /// </summary>
+    /// <param name="id">文章id</param>
+    /// <returns></returns>
+    [MyAuthorize]
+    [HttpPost("post/{id}/DisableComment")]
+    public async Task<ActionResult> DisableComment(int id)
+    {
+        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+        post.DisableComment = !post.DisableComment;
+        return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableComment ? $"已禁用【{post.Title}】这篇文章的评论功能!" : $"已启用【{post.Title}】这篇文章的评论功能!");
+    }
+
+    /// <summary>
+    /// 禁用或开启文章评论
+    /// </summary>
+    /// <param name="id">文章id</param>
+    /// <returns></returns>
+    [MyAuthorize]
+    [HttpPost("post/{id}/DisableCopy")]
+    public async Task<ActionResult> DisableCopy(int id)
+    {
+        var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
+        post.DisableCopy = !post.DisableCopy;
+        return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableCopy ? $"已开启【{post.Title}】这篇文章的防复制功能!" : $"已关闭【{post.Title}】这篇文章的防复制功能!");
+    }
 
-        /// <summary>
-        /// 删除历史版本
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> DeleteHistory(int id)
+    /// <summary>
+    /// 修改分类
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="cid"></param>
+    /// <returns></returns>
+    [HttpPost("post/{id}/ChangeCategory/{cid}")]
+    public async Task<ActionResult> ChangeCategory(int id, int cid)
+    {
+        await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
         {
-            bool b = await PostHistoryVersionService.DeleteByIdAsync(id) > 0;
-            return ResultData(null, b, b ? "历史版本文章删除成功!" : "历史版本文章删除失败!");
-        }
+            CategoryId = cid
+        });
+        return Ok();
+    }
 
-        /// <summary>
-        /// 还原版本
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> Revert(int id)
-        {
-            var history = await PostHistoryVersionService.GetByIdAsync(id) ?? throw new NotFoundException("版本不存在");
-            history.Post.Category = history.Category;
-            history.Post.CategoryId = history.CategoryId;
-            history.Post.Content = history.Content;
-            history.Post.Title = history.Title;
-            history.Post.Label = history.Label;
-            history.Post.ModifyDate = history.ModifyDate;
-            history.Post.Seminar.Clear();
-            foreach (var s in history.Seminar)
-            {
-                history.Post.Seminar.Add(s);
-            }
-            bool b = await SearchEngine.SaveChangesAsync() > 0;
-            await PostHistoryVersionService.DeleteByIdAsync(id);
-            return ResultData(null, b, b ? "回滚成功" : "回滚失败");
-        }
+    /// <summary>
+    /// 修改专题
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="sids"></param>
+    /// <returns></returns>
+    [HttpPost("post/{id}/ChangeSeminar/{sids}")]
+    public async Task<ActionResult> ChangeSeminar(int id, string sids)
+    {
+        var post = PostService[id] ?? throw new NotFoundException("文章不存在");
+        var ids = sids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray();
+        post.Seminar.Clear();
+        post.Seminar.AddRange(SeminarService[s => ids.Contains(s.Id)]);
+        await PostService.SaveChangesAsync();
+        return Ok();
+    }
 
-        /// <summary>
-        /// 禁用或开启文章评论
-        /// </summary>
-        /// <param name="id">文章id</param>
-        /// <returns></returns>
-        [MyAuthorize]
-        [HttpPost("post/{id}/DisableComment")]
-        public async Task<ActionResult> DisableComment(int id)
-        {
-            var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-            post.DisableComment = !post.DisableComment;
-            return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableComment ? $"已禁用【{post.Title}】这篇文章的评论功能!" : $"已启用【{post.Title}】这篇文章的评论功能!");
-        }
+    /// <summary>
+    /// 刷新文章
+    /// </summary>
+    /// <param name="id">文章id</param>
+    /// <param name="cancellationToken"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<ActionResult> Refresh(int id, CancellationToken cancellationToken = default)
+    {
+        await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
+        {
+            ModifyDate = DateTime.Now
+        }, cancellationToken);
+        return RedirectToAction("Details", new { id });
+    }
 
-        /// <summary>
-        /// 禁用或开启文章评论
-        /// </summary>
-        /// <param name="id">文章id</param>
-        /// <returns></returns>
-        [MyAuthorize]
-        [HttpPost("post/{id}/DisableCopy")]
-        public async Task<ActionResult> DisableCopy(int id)
-        {
-            var post = await PostService.GetByIdAsync(id) ?? throw new NotFoundException("文章未找到");
-            post.DisableCopy = !post.DisableCopy;
-            return ResultData(null, await PostService.SaveChangesAsync() > 0, post.DisableCopy ? $"已开启【{post.Title}】这篇文章的防复制功能!" : $"已关闭【{post.Title}】这篇文章的防复制功能!");
-        }
+    /// <summary>
+    /// 标记为恶意修改
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="cancellationToken"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    [HttpPost("post/block/{id}")]
+    public async Task<ActionResult> Block(int id, CancellationToken cancellationToken = default)
+    {
+        var b = await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
+        {
+            Status = Status.Forbidden
+        }, cancellationToken) > 0;
+        return b ? ResultData(null, true, "操作成功!") : ResultData(null, false, "操作失败!");
+    }
 
-        /// <summary>
-        /// 修改分类
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="cid"></param>
-        /// <returns></returns>
-        [HttpPost("post/{id}/ChangeCategory/{cid}")]
-        public async Task<ActionResult> ChangeCategory(int id, int cid)
+    /// <summary>
+    /// 切换允许rss订阅
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="cancellationToken"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    [HttpPost("post/{id}/rss-switch")]
+    public async Task<ActionResult> RssSwitch(int id, CancellationToken cancellationToken = default)
+    {
+        await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
         {
-            await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
-            {
-                CategoryId = cid
-            });
-            return Ok();
-        }
+            Rss = !p.Rss
+        }, cancellationToken);
+        return ResultData(null, message: "操作成功");
+    }
 
-        /// <summary>
-        /// 修改专题
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="sids"></param>
-        /// <returns></returns>
-        [HttpPost("post/{id}/ChangeSeminar/{sids}")]
-        public async Task<ActionResult> ChangeSeminar(int id, string sids)
-        {
-            var post = PostService[id] ?? throw new NotFoundException("文章不存在");
-            var ids = sids.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray();
-            post.Seminar.Clear();
-            post.Seminar.AddRange(SeminarService[s => ids.Contains(s.Id)]);
-            await PostService.SaveChangesAsync();
-            return Ok();
-        }
+    /// <summary>
+    /// 切换锁定编辑
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="cancellationToken"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    [HttpPost("post/{id}/locked-switch")]
+    public async Task<ActionResult> LockedSwitch(int id, CancellationToken cancellationToken = default)
+    {
+        await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
+        {
+            Locked = !p.Locked
+        }, cancellationToken);
+        return ResultData(null, message: "操作成功");
+    }
 
-        /// <summary>
-        /// 刷新文章
-        /// </summary>
-        /// <param name="id">文章id</param>
-        /// <param name="cancellationToken"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<ActionResult> Refresh(int id, CancellationToken cancellationToken = default)
+    /// <summary>
+    /// 文章统计
+    /// </summary>
+    /// <returns></returns>
+    [MyAuthorize]
+    public async Task<IActionResult> Statistic(CancellationToken cancellationToken = default)
+    {
+        var keys = RedisHelper.Keys(nameof(PostOnline) + ":*");
+        var sets = keys.Select(s => (Id: s.Split(':')[1].ToInt32(), Clients: RedisHelper.HGet<HashSet<string>>(s, "value")));
+        var ids = sets.Where(t => t.Clients?.Count > 0).OrderByDescending(t => t.Clients.Count).Take(10).Select(t => t.Id).ToArray();
+        var mostHots = await PostService.GetQuery<PostModelBase>(p => ids.Contains(p.Id)).ToListAsync().ContinueWith(t =>
         {
-            await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
+            foreach (var item in t.Result)
             {
-                ModifyDate = DateTime.Now
-            }, cancellationToken);
-            return RedirectToAction("Details", new { id });
-        }
+                item.ViewCount = sets.FirstOrDefault(x => x.Id == item.Id).Clients.Count;
+            }
 
-        /// <summary>
-        /// 标记为恶意修改
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="cancellationToken"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        [HttpPost("post/block/{id}")]
-        public async Task<ActionResult> Block(int id, CancellationToken cancellationToken = default)
-        {
-            var b = await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
-            {
-                Status = Status.Forbidden
-            }, cancellationToken) > 0;
-            return b ? ResultData(null, true, "操作成功!") : ResultData(null, false, "操作失败!");
-        }
+            return t.Result.OrderByDescending(p => p.ViewCount);
+        });
+        var postsQuery = PostService.GetQuery(p => p.Status == Status.Published);
+        var mostView = await postsQuery.OrderByDescending(p => p.TotalViewCount).Take(10).Select(p => new PostModelBase()
+        {
+            Id = p.Id,
+            Title = p.Title,
+            ViewCount = p.TotalViewCount
+        }).ToListAsync(cancellationToken);
+        var mostAverage = await postsQuery.OrderByDescending(p => p.AverageViewCount).Take(10).Select(p => new PostModelBase()
+        {
+            Id = p.Id,
+            Title = p.Title,
+            ViewCount = (int)p.AverageViewCount
+        }).ToListAsync(cancellationToken);
+        var yesterday = DateTime.Now.AddDays(-1);
+        var trending = await postsQuery.Select(p => new PostModelBase()
+        {
+            Id = p.Id,
+            Title = p.Title,
+            ViewCount = p.PostVisitRecords.Count(t => t.Time >= yesterday)
+        }).OrderByDescending(p => p.ViewCount).Take(10).ToListAsync(cancellationToken);
+        var readCount = PostVisitRecordService.Count(e => e.Time >= yesterday);
+        return ResultData(new
+        {
+            mostHots,
+            mostView,
+            mostAverage,
+            trending,
+            readCount
+        });
+    }
 
-        /// <summary>
-        /// 切换允许rss订阅
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="cancellationToken"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        [HttpPost("post/{id}/rss-switch")]
-        public async Task<ActionResult> RssSwitch(int id, CancellationToken cancellationToken = default)
-        {
-            await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
-            {
-                Rss = !p.Rss
-            }, cancellationToken);
-            return ResultData(null, message: "操作成功");
+    /// <summary>
+    /// 文章访问记录
+    /// </summary>
+    /// <param name="id"></param>
+    /// <param name="page"></param>
+    /// <param name="size"></param>
+    /// <returns></returns>
+    [HttpGet("/{id}/records"), MyAuthorize]
+    [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
+    public async Task<IActionResult> PostVisitRecords(int id, int page = 1, int size = 15, string kw = "")
+    {
+        Expression<Func<PostVisitRecord, bool>> where = e => e.PostId == id;
+        if (!string.IsNullOrEmpty(kw))
+        {
+            kw = Regex.Escape(kw);
+            where = where.And(e => Regex.IsMatch(e.IP + e.Location + e.Referer + e.RequestUrl, kw, RegexOptions.IgnoreCase));
         }
 
-        /// <summary>
-        /// 切换锁定编辑
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="cancellationToken"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        [HttpPost("post/{id}/locked-switch")]
-        public async Task<ActionResult> LockedSwitch(int id, CancellationToken cancellationToken = default)
-        {
-            await PostService.GetQuery(p => p.Id == id).UpdateFromQueryAsync(p => new Post()
-            {
-                Locked = !p.Locked
-            }, cancellationToken);
-            return ResultData(null, message: "操作成功");
-        }
+        var pages = await PostVisitRecordService.GetPagesAsync<DateTime, PostVisitRecordViewModel>(page, size, where, e => e.Time, false);
+        return Ok(pages);
+    }
 
-        /// <summary>
-        /// 文章统计
-        /// </summary>
-        /// <returns></returns>
-        [MyAuthorize]
-        public async Task<IActionResult> Statistic(CancellationToken cancellationToken = default)
-        {
-            var keys = RedisHelper.Keys(nameof(PostOnline) + ":*");
-            var sets = keys.Select(s => (Id: s.Split(':')[1].ToInt32(), Clients: RedisHelper.HGet<HashSet<string>>(s, "value")));
-            var ids = sets.Where(t => t.Clients?.Count > 0).OrderByDescending(t => t.Clients.Count).Take(10).Select(t => t.Id).ToArray();
-            var mostHots = await PostService.GetQuery<PostModelBase>(p => ids.Contains(p.Id)).ToListAsync().ContinueWith(t =>
-            {
-                foreach (var item in t.Result)
-                {
-                    item.ViewCount = sets.FirstOrDefault(x => x.Id == item.Id).Clients.Count;
-                }
+    /// <summary>
+    /// 导出文章访问记录
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [HttpGet("/{id}/records-export"), MyAuthorize]
+    [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
+    public IActionResult ExportPostVisitRecords(int id)
+    {
+        using var list = PostVisitRecordService.GetQuery<DateTime, PostVisitRecordViewModel>(e => e.PostId == id, e => e.Time, false).ToPooledList();
+        using var ms = list.ToExcel();
+        var post = PostService[id];
+        return this.ResumeFile(ms.ToArray(), ContentType.Xlsx, post.Title + "访问记录.xlsx");
+    }
 
-                return t.Result.OrderByDescending(p => p.ViewCount);
-            });
-            var postsQuery = PostService.GetQuery(p => p.Status == Status.Published);
-            var mostView = await postsQuery.OrderByDescending(p => p.TotalViewCount).Take(10).Select(p => new PostModelBase()
-            {
-                Id = p.Id,
-                Title = p.Title,
-                ViewCount = p.TotalViewCount
-            }).ToListAsync(cancellationToken);
-            var mostAverage = await postsQuery.OrderByDescending(p => p.AverageViewCount).Take(10).Select(p => new PostModelBase()
+    /// <summary>
+    /// 文章访问记录图表
+    /// </summary>
+    /// <returns></returns>
+    [HttpGet("/{id}/records-chart"), MyAuthorize]
+    [ProducesResponseType((int)HttpStatusCode.OK)]
+    public async Task<IActionResult> PostVisitRecordChart([FromServices] IPostVisitRecordStatsService statsService, int id, bool compare, uint period, CancellationToken cancellationToken)
+    {
+        if (compare)
+        {
+            var start1 = DateTime.Today.AddDays(-period);
+            var list1 = await statsService.GetQuery(e => e.PostId == id && e.Date >= start1).GroupBy(t => t.Date).Select(g => new
             {
-                Id = p.Id,
-                Title = p.Title,
-                ViewCount = (int)p.AverageViewCount
-            }).ToListAsync(cancellationToken);
-            var yesterday = DateTime.Now.AddDays(-1);
-            var trending = await postsQuery.Select(p => new PostModelBase()
+                Date = g.Key,
+                Count = g.Sum(t => t.Count)
+            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+            if (list1.Count == 0)
             {
-                Id = p.Id,
-                Title = p.Title,
-                ViewCount = p.PostVisitRecords.Count(t => t.Time >= yesterday)
-            }).OrderByDescending(p => p.ViewCount).Take(10).ToListAsync(cancellationToken);
-            var readCount = PostVisitRecordService.Count(e => e.Time >= yesterday);
-            return ResultData(new
+                return Ok(Array.Empty<int>());
+            }
+
+            var start2 = start1.AddDays(-period - 1);
+            var list2 = await statsService.GetQuery(e => e.PostId == id && e.Date >= start2 && e.Date < start1).GroupBy(t => t.Date).Select(g => new
             {
-                mostHots,
-                mostView,
-                mostAverage,
-                trending,
-                readCount
-            });
-        }
+                Date = g.Key,
+                Count = g.Sum(t => t.Count)
+            }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
 
-        /// <summary>
-        /// 文章访问记录
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="page"></param>
-        /// <param name="size"></param>
-        /// <returns></returns>
-        [HttpGet("/{id}/records"), MyAuthorize]
-        [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
-        public async Task<IActionResult> PostVisitRecords(int id, int page = 1, int size = 15, string kw = "")
-        {
-            Expression<Func<PostVisitRecord, bool>> where = e => e.PostId == id;
-            if (!string.IsNullOrEmpty(kw))
+            // 将数据填充成连续的数据
+            for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
             {
-                kw = Regex.Escape(kw);
-                where = where.And(e => Regex.IsMatch(e.IP + e.Location + e.Referer + e.RequestUrl, kw, RegexOptions.IgnoreCase));
+                if (list1.All(a => a.Date != i))
+                {
+                    list1.Add(new { Date = i, Count = 0 });
+                }
             }
-
-            var pages = await PostVisitRecordService.GetPagesAsync<DateTime, PostVisitRecordViewModel>(page, size, where, e => e.Time, false);
-            return Ok(pages);
-        }
-
-        /// <summary>
-        /// 导出文章访问记录
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [HttpGet("/{id}/records-export"), MyAuthorize]
-        [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
-        public IActionResult ExportPostVisitRecords(int id)
-        {
-            using var list = PostVisitRecordService.GetQuery<DateTime, PostVisitRecordViewModel>(e => e.PostId == id, e => e.Time, false).ToPooledList();
-            using var ms = list.ToExcel();
-            var post = PostService[id];
-            return this.ResumeFile(ms.ToArray(), ContentType.Xlsx, post.Title + "访问记录.xlsx");
+            for (var i = start2; i < start1; i = i.AddDays(1))
+            {
+                if (list2.All(a => a.Date != i))
+                {
+                    list2.Add(new { Date = i, Count = 0 });
+                }
+            }
+            return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
         }
+        var list = await statsService.GetQuery(e => e.PostId == id).GroupBy(t => t.Date).Select(g => new
+        {
+            Date = g.Key,
+            Count = g.Sum(t => t.Count)
+        }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+        return Ok(new[] { list });
+    }
 
-        /// <summary>
-        /// 文章访问记录图表
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="cancellationToken"></param>
-        /// <returns></returns>
-        [HttpGet("/{id}/records-chart"), MyAuthorize]
-        [ProducesResponseType((int)HttpStatusCode.OK)]
-        public async Task<IActionResult> PostVisitRecordChart(int id, CancellationToken cancellationToken)
-        {
-            var list = await PostVisitRecordService.GetQuery(e => e.PostId == id).Select(e => e.Time).GroupBy(t => t.Date).Select(g => new
+    /// <summary>
+    /// 文章访问记录图表
+    /// </summary>
+    /// <returns></returns>
+    [HttpGet("/post/records-chart"), MyAuthorize]
+    [ProducesResponseType((int)HttpStatusCode.OK)]
+    public async Task<IActionResult> PostVisitRecordChart(bool compare, uint period, CancellationToken cancellationToken)
+    {
+        if (compare)
+        {
+            var start1 = DateTime.Today.AddDays(-period);
+            var list1 = await PostVisitRecordService.GetQuery(e => e.Time >= start1).Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
             {
                 Date = g.Key,
-                Count = g.Count()
+                Count = g.Count(),
+                UV = g.Select(e => e.IP).Distinct().Count()
             }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-            return Ok(list);
-        }
+            if (list1.Count == 0)
+            {
+                return Ok(Array.Empty<int>());
+            }
 
-        /// <summary>
-        /// 文章访问记录图表
-        /// </summary>
-        /// <param name="id"></param>
-        /// <param name="cancellationToken"></param>
-        /// <returns></returns>
-        [HttpGet("/post/records-chart"), MyAuthorize]
-        [ProducesResponseType((int)HttpStatusCode.OK)]
-        public async Task<IActionResult> PostVisitRecordChart(CancellationToken cancellationToken)
-        {
-            var list = await PostVisitRecordService.GetAll().Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
+            var start2 = start1.AddDays(-period - 1);
+            var list2 = await PostVisitRecordService.GetQuery(e => e.Time >= start2 && e.Time < start1).Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
             {
                 Date = g.Key,
                 Count = g.Count(),
                 UV = g.Select(e => e.IP).Distinct().Count()
             }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
-            return Ok(list);
-        }
 
-        /// <summary>
-        /// 文章访问记录分析
-        /// </summary>
-        /// <param name="id"></param>
-        /// <returns></returns>
-        [HttpGet("/{id}/insight"), MyAuthorize]
-        [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
-        public IActionResult PostVisitRecordInsight(int id)
-        {
-            return View(PostService[id]);
+            // 将数据填充成连续的数据
+            for (var i = start1; i <= DateTime.Today; i = i.AddDays(1))
+            {
+                if (list1.All(a => a.Date != i))
+                {
+                    list1.Add(new { Date = i, Count = 0, UV = 0 });
+                }
+            }
+            for (var i = start2; i < start1; i = i.AddDays(1))
+            {
+                if (list2.All(a => a.Date != i))
+                {
+                    list2.Add(new { Date = i, Count = 0, UV = 0 });
+                }
+            }
+            return Ok(new[] { list1.OrderBy(a => a.Date), list2.OrderBy(a => a.Date) });
         }
 
-        /// <summary>
-        /// 获取地区集
-        /// </summary>
-        /// <param name="name"></param>
-        /// <returns></returns>
-        [MyAuthorize]
-        [ProducesResponseType(typeof(List<string>), (int)HttpStatusCode.OK)]
-        public async Task<IActionResult> GetRegions(string name)
+        var list = await PostVisitRecordService.GetAll().Select(e => new { e.Time.Date, e.IP }).GroupBy(t => t.Date).Select(g => new
         {
-            return ResultData(await PostService.GetAll().Select(name).Distinct().ToDynamicListAsync());
-        }
+            Date = g.Key,
+            Count = g.Count(),
+            UV = g.Select(e => e.IP).Distinct().Count()
+        }).OrderBy(a => a.Date).ToListAsync(cancellationToken);
+        return Ok(new[] { list });
+    }
+
+    /// <summary>
+    /// 文章访问记录分析
+    /// </summary>
+    /// <param name="id"></param>
+    /// <returns></returns>
+    [HttpGet("/{id}/insight"), MyAuthorize]
+    [ProducesResponseType(typeof(PagedList<PostVisitRecordViewModel>), (int)HttpStatusCode.OK)]
+    public IActionResult PostVisitRecordInsight(int id)
+    {
+        return View(PostService[id]);
+    }
 
-        #endregion 后端管理
+    /// <summary>
+    /// 获取地区集
+    /// </summary>
+    /// <param name="name"></param>
+    /// <returns></returns>
+    [MyAuthorize]
+    [ProducesResponseType(typeof(List<string>), (int)HttpStatusCode.OK)]
+    public async Task<IActionResult> GetRegions(string name)
+    {
+        return ResultData(await PostService.GetAll().Select(name).Distinct().ToDynamicListAsync());
     }
-}
+
+    #endregion 后端管理
+}

+ 2 - 2
src/Masuit.MyBlogs.Core/Extensions/Hangfire/HangfireBackJob.cs

@@ -97,12 +97,12 @@ namespace Masuit.MyBlogs.Core.Extensions.Hangfire
         public void RecordPostVisit(int pid, string ip, string refer, string url)
         {
             var lastQuarter = DateTime.Now.AddMonths(-6);
-            var lastYear = DateTime.Now.AddYears(-1);
+            var last3Year = DateTime.Now.AddYears(-3);
             var recordService = _serviceScope.ServiceProvider.GetRequiredService<IPostVisitRecordService>();
             var recordStatsService = _serviceScope.ServiceProvider.GetRequiredService<IPostVisitRecordStatsService>();
             var postService = _serviceScope.ServiceProvider.GetRequiredService<IPostService>();
             recordService.GetQuery(b => b.Time < lastQuarter).DeleteFromQuery();
-            recordStatsService.GetQuery(b => b.Date < lastYear).DeleteFromQuery();
+            recordStatsService.GetQuery(b => b.Date < last3Year).DeleteFromQuery();
             var post = postService.GetById(pid);
             if (post == null)
             {

+ 4 - 4
src/Masuit.MyBlogs.Core/Masuit.MyBlogs.Core.csproj

@@ -38,7 +38,7 @@
 
     <ItemGroup>
         <PackageReference Include="Autofac.Extensions.DependencyInjection" Version="8.0.0" />
-        <PackageReference Include="AutoMapper.Extensions.ExpressionMapping" Version="6.0.0" />
+        <PackageReference Include="AutoMapper.Extensions.ExpressionMapping" Version="6.0.1" />
         <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" />
         <PackageReference Include="Ben.Demystifier" Version="0.4.1" />
         <PackageReference Include="CacheManager.Serialization.Json" Version="1.2.0" />
@@ -59,7 +59,7 @@
         <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.10" />
         <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="6.0.10" />
         <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="6.0.10" />
-        <PackageReference Include="Microsoft.Graph" Version="4.45.0" />
+        <PackageReference Include="Microsoft.Graph" Version="4.46.0" />
         <PackageReference Include="Microsoft.Graph.Auth" Version="1.0.0-preview.7" />
         <PackageReference Include="MiniProfiler.AspNetCore.Mvc" Version="4.2.22" />
         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.7" />
@@ -67,10 +67,10 @@
         <PackageReference Include="MiniProfiler.EntityFrameworkCore" Version="4.2.22" />
         <PackageReference Include="PanGu.HighLight" Version="1.0.0" />
         <PackageReference Include="SixLabors.ImageSharp.Web" Version="2.0.2" />
-        <PackageReference Include="System.Linq.Dynamic.Core" Version="1.2.20" />
+        <PackageReference Include="System.Linq.Dynamic.Core" Version="1.2.21" />
         <PackageReference Include="TimeZoneConverter" Version="6.0.1" />
         <PackageReference Include="WilderMinds.RssSyndication" Version="1.7.0" />
-        <PackageReference Include="Z.EntityFramework.Plus.EFCore" Version="6.16.0" />
+        <PackageReference Include="Z.EntityFramework.Plus.EFCore" Version="6.16.1" />
     </ItemGroup>
     <ItemGroup>
         <Content Update="appsettings.json">

+ 118 - 61
src/Masuit.MyBlogs.Core/Views/Advertisement/ClickRecordsInsight.cshtml

@@ -29,7 +29,19 @@
         <button class="layui-btn" data-type="reload">搜索</button>
         <a class="layui-btn" asp-controller="Advertisement" asp-action="ExportClickRecords" asp-route-id="@Model.Id">导出</a>
     </div>
-    <table class="layui-hide" id="table" lay-filter="tableEvent"></table>
+	<table class="layui-hide" id="table" lay-filter="tableEvent"></table>
+	<form class="layui-form">
+		<label class="layui-form-label">对比最近</label>
+		<div class="layui-input-inline">
+			<select id="period" name="period" lay-filter="period">
+				<option value="0">不对比</option>
+				<option value="15">15天</option>
+				<option value="30" selected="selected">一个月</option>
+				<option value="60">两个月</option>
+				<option value="90">三个月</option>
+			</select>
+		</div>
+	</form>
     <div id="chart" style="height: 500px"></div>
     <mini-profiler max-traces="5" />
 </body>
@@ -88,64 +100,109 @@
             });
         });
     });
-    window.fetch("/partner/@Model.Id/records-chart", {
-        credentials: 'include',
-        method: 'GET',
-        mode: 'cors'
-    }).then(function(response) {
-        return response.json();
-    }).then(function(res) {
-        var data = [];
-        for (let item of res) {
-            data.push([Date.parse(item.Date), item.Count]);
-        }
-        var chartDom = document.getElementById('chart');
-        var myChart = echarts.init(chartDom);
-        var option = {
-            tooltip: {
-                trigger: 'axis',
-                position: function(pt) {
-                    return [pt[0], '10%'];
-                }
-            },
-            title: {
-                left: 'center',
-                text: '最近30天访问趋势,日均:' + (res.reduce((acr, cur) => acr + cur.Count, 0) / (new Date() - new Date(res[0].Date)) * (1000 * 60 * 60 * 24)).toFixed(2)
-            },
-            xAxis: {
-                type: 'time',
-                axisLabel: {
-                    formatter:function (value){
-                        var dt=new Date(value);
-                        return dt.toLocaleDateString();
-                    }
-                }
-            },
-            yAxis: {
-                type: 'value'
-            },
-            series: [
-                {
-                    name: '访问量',
-                    type: 'line',
-                    smooth: true,
-                    symbol: 'none',
-                    areaStyle: {},
-                    data: data,
-                    markPoint: {
-                        data: [
-                            { type: 'max', name: '最大值' },
-                            { type: 'min', name: '最小值' }
-                        ]
-                    },
-                    markLine: {
-                        data: [
-                            { type: 'average', name: '平均值' }
-                        ]
-                    }
-                }
-            ]
-        };
-        myChart.setOption(option);
-    });
+	layui.use("form", function () {
+		var form = layui.form;
+		form.on("select(period)", function (data) {
+			var chartDom = document.getElementById('chart');
+			echarts.init(chartDom).dispose();
+			showCharts();
+		});
+	});
+	showCharts();
+	function showCharts() {
+		var period = document.getElementById("period").value;
+		window.fetch(`/partner/@Model.Id/records-chart?compare=${period > 0}&period=${period}`, {
+			credentials: 'include',
+			method: 'GET',
+			mode: 'cors'
+		}).then(function (response) {
+			return response.json();
+		}).then(function (res) {
+			var xSeries = [];
+			var ySeries = [];
+			for (let series of res) {
+				var x = [];
+				var y = [];
+				for (let item of series) {
+					x.push(new Date(Date.parse(item.Date)).toLocaleDateString());
+					y.push(item.Count);
+				}
+				xSeries.push(x);
+				ySeries.push(y);
+			}
+			var chartDom = document.getElementById('chart');
+			var myChart = echarts.init(chartDom);
+			const colors = ['#5470C6', '#EE6666'];
+			var option = {
+				color: colors,
+				tooltip: {
+					trigger: 'none',
+					axisPointer: {
+						type: 'cross'
+					}
+				},
+				legend: {},
+				grid: {
+					top: 70,
+					bottom: 50
+				},
+				title: {
+					left: 'center',
+					text: '最近访问趋势'
+				},
+				xAxis: xSeries.map(function (item, index) {
+					return {
+						type: 'category',
+						axisTick: {
+							alignWithLabel: true
+						},
+						axisLine: {
+							onZero: false,
+							lineStyle: {
+								color: colors[index]
+							}
+						},
+						axisPointer: {
+							label: {
+								formatter: function (params) {
+									return params.value + (params.seriesData.length ? ':' + params.seriesData[0].data : '');
+								}
+							}
+						},
+						data: item
+					}
+				}),
+				yAxis: [
+					{
+						type: 'value'
+					}
+				],
+				series: ySeries.map(function (item, index) {
+					return {
+						type: 'line',
+						smooth: true,
+						symbol: 'none',
+						xAxisIndex: index,
+						areaStyle: {},
+						data: item,
+						lineStyle: {
+							type: index === 1 ? 'dashed' : ""
+						},
+						markPoint: {
+							data: [
+								{ type: 'max', name: '最大值' },
+								{ type: 'min', name: '最小值' }
+							]
+						},
+						markLine: {
+							data: [
+								{ type: 'average', name: '平均值' }
+							]
+						}
+					}
+				})
+			};
+			myChart.setOption(option);
+		});
+	}
 </script>

+ 115 - 57
src/Masuit.MyBlogs.Core/Views/Post/PostVisitRecordInsight.cshtml

@@ -30,6 +30,19 @@
 		<a class="layui-btn" asp-controller="Post" asp-action="ExportPostVisitRecords" asp-route-id="@Model.Id">导出</a>
 	</div>
 	<table class="layui-hide" id="table" lay-filter="tableEvent"></table>
+	<form class="layui-form">
+		<label class="layui-form-label">对比最近</label>
+		<div class="layui-input-inline">
+			<select id="period" name="period" lay-filter="period">
+				<option value="0">不对比</option>
+				<option value="15">15天</option>
+			    <option value="30" selected="selected">一个月</option>
+				<option value="60">两个月</option>
+				<option value="90">三个月</option>
+				<option value="180">半年</option>
+			</select>
+		</div>
+    </form>
 	<div id="chart" style="height: 500px"></div>
 	<mini-profiler max-traces="5" />
 </body>
@@ -89,64 +102,109 @@
 			});
 		});
 	});
-	window.fetch("/@Model.Id/records-chart", {
-		credentials: 'include',
-		method: 'GET',
-		mode: 'cors'
-	}).then(function(response) {
-		return response.json();
-	}).then(function(res) {
-		var data = [];
-		for (let item of res) {
-			data.push([Date.parse(item.Date), item.Count]);
-		}
-		var chartDom = document.getElementById('chart');
-		var myChart = echarts.init(chartDom);
-		var option = {
-			tooltip: {
-				trigger: 'axis',
-				position: function(pt) {
-					return [pt[0], '10%'];
+	layui.use("form", function() {
+		var form = layui.form;
+		form.on("select(period)", function (data) {
+			var chartDom = document.getElementById('chart');
+			echarts.init(chartDom).dispose();
+			showCharts();
+		});
+    });
+	showCharts();
+    function showCharts() {
+		var period=document.getElementById("period").value;
+		window.fetch(`/@Model.Id/records-chart?compare=${period>0}&period=${period}`, {
+			credentials: 'include',
+			method: 'GET',
+			mode: 'cors'
+		}).then(function (response) {
+			return response.json();
+		}).then(function (res) {
+			var xSeries = [];
+			var ySeries = [];
+			for (let series of res) {
+				var x = [];
+				var y = [];
+				for (let item of series) {
+					x.push(new Date(Date.parse(item.Date)).toLocaleDateString());
+					y.push(item.Count);
 				}
-			},
-			title: {
-				left: 'center',
-				text: '最近90天访问趋势,日均:' + (res.reduce((acr, cur) => acr + cur.Count, 0) / (new Date() - new Date(res[0].Date)) * (1000 * 60 * 60 * 24)).toFixed(2)
-			},
-			xAxis: {
-				type: 'time',
-				axisLabel: {
-					formatter:function (value){
-						var dt=new Date(value);
-						return dt.toLocaleDateString();
+				xSeries.push(x);
+				ySeries.push(y);
+			}
+			var chartDom = document.getElementById('chart');
+			var myChart = echarts.init(chartDom);
+			const colors = ['#5470C6', '#EE6666'];
+			var option = {
+				color: colors,
+				tooltip: {
+					trigger: 'none',
+					axisPointer: {
+						type: 'cross'
 					}
-				}
-			},
-			yAxis: {
-				type: 'value'
-			},
-			series: [
-				{
-					name: '访问量',
-					type: 'line',
-					smooth: true,
-					symbol: 'none',
-					areaStyle: {},
-					data: data,
-					markPoint: {
-						data: [
-							{ type: 'max', name: '最大值' },
-							{ type: 'min', name: '最小值' }
-						]
-					},
-					markLine: {
-						data: [
-							{ type: 'average', name: '平均值' }
-						]
+				},
+				legend: {},
+				grid: {
+					top: 70,
+					bottom: 50
+				},
+				title: {
+					left: 'center',
+					text: '最近访问趋势' //+ (res.reduce((acr, cur) => acr + cur.Count, 0) / (new Date() - new Date(res[0].Date)) * (1000 * 60 * 60 * 24)).toFixed(2)
+				},
+				xAxis: xSeries.map(function (item, index) {
+					return {
+						type: 'category',
+						axisTick: {
+							alignWithLabel: true
+						},
+						axisLine: {
+							onZero: false,
+							lineStyle: {
+								color: colors[index]
+							}
+						},
+						axisPointer: {
+							label: {
+								formatter: function (params) {
+									return params.value + (params.seriesData.length ? ':' + params.seriesData[0].data : '');
+								}
+							}
+						},
+						data: item
 					}
-				}
-			]
-		};
-		myChart.setOption(option);
-	});
+				}),
+				yAxis: [
+					{
+						type: 'value'
+					}
+				],
+				series: ySeries.map(function (item, index) {
+					return {
+						type: 'line',
+						smooth: true,
+						symbol: 'none',
+						xAxisIndex: index,
+						areaStyle: {},
+						data: item,
+						lineStyle: {
+							type: index === 1 ? 'dashed' : ""
+						},
+						markPoint: {
+							data: [
+								{ type: 'max', name: '最大值' },
+								{ type: 'min', name: '最小值' }
+							]
+						},
+						markLine: {
+							data: [
+								{ type: 'average', name: '平均值' }
+							]
+						}
+					}
+				})
+			};
+			myChart.setOption(option);
+		});
+    }
 </script>

+ 98 - 59
src/Masuit.MyBlogs.Core/wwwroot/ng-views/controllers/partner.js

@@ -417,64 +417,103 @@
         }));
     }
     
-    window.fetch("/partner/records-chart", {
-        credentials: 'include',
-        method: 'GET',
-        mode: 'cors'
-    }).then(function(response) {
-        return response.json();
-    }).then(function(res) {
-        var data = [];
-        for (let item of res) {
-            data.push([Date.parse(item.Date), item.Count]);
-        }
+	showCharts=function() {
         var chartDom = document.getElementById('chart');
-        var myChart = echarts.init(chartDom);
-        var option = {
-            tooltip: {
-                trigger: 'axis',
-                position: function(pt) {
-                    return [pt[0], '10%'];
-                }
-            },
-            title: {
-                left: 'center',
-                text: '最近30天每日总点击趋势,日均:' + (res.reduce((acr, cur) => acr + cur.Count, 0) / (new Date() - new Date(res[0].Date)) * (1000 * 60 * 60 * 24)).toFixed(2)
-            },
-            xAxis: {
-                type: 'time',
-                axisLabel: {
-                    formatter:function (value){
-                        var dt=new Date(value);
-                        return dt.toLocaleDateString();
-                    }
-                }
-            },
-            yAxis: {
-                type: 'value'
-            },
-            series: [
-                {
-                    name: '点击量',
-                    type: 'line',
-                    smooth: true,
-                    symbol: 'none',
-                    areaStyle: {},
-                    data: data,
-                    markPoint: {
-                        data: [
-                            { type: 'max', name: '最大值' },
-                            { type: 'min', name: '最小值' }
-                        ]
-                    },
-                    markLine: {
-                        data: [
-                            { type: 'average', name: '平均值' }
-                        ]
-                    }
-                }
-            ]
-        };
-        myChart.setOption(option);
-    });
+        echarts.init(chartDom).dispose();
+		var period = document.getElementById("period").value;
+		window.fetch(`/partner/records-chart?compare=${period > 0}&period=${period}`, {
+			credentials: 'include',
+			method: 'GET',
+			mode: 'cors'
+		}).then(function (response) {
+			return response.json();
+		}).then(function (res) {
+			var xSeries = [];
+			var ySeries = [];
+			for (let series of res) {
+				var x = [];
+				var y = [];
+				for (let item of series) {
+					x.push(new Date(Date.parse(item.Date)).toLocaleDateString());
+					y.push(item.Count);
+				}
+				xSeries.push(x);
+				ySeries.push(y);
+			}
+			var chartDom = document.getElementById('chart');
+			var myChart = echarts.init(chartDom);
+			const colors = ['#5470C6', '#EE6666'];
+			var option = {
+				color: colors,
+				tooltip: {
+					trigger: 'none',
+					axisPointer: {
+						type: 'cross'
+					}
+				},
+				legend: {},
+				grid: {
+					top: 70,
+					bottom: 50
+				},
+				title: {
+					left: 'center',
+					text: '最近访问趋势'
+				},
+				xAxis: xSeries.map(function (item, index) {
+					return {
+						type: 'category',
+						axisTick: {
+							alignWithLabel: true
+						},
+						axisLine: {
+							onZero: false,
+							lineStyle: {
+								color: colors[index]
+							}
+						},
+						axisPointer: {
+							label: {
+								formatter: function (params) {
+									return params.value + (params.seriesData.length ? ':' + params.seriesData[0].data : '');
+								}
+							}
+						},
+						data: item
+					}
+				}),
+				yAxis: [
+					{
+						type: 'value'
+					}
+				],
+				series: ySeries.map(function (item, index) {
+					return {
+						type: 'line',
+						smooth: true,
+						symbol: 'none',
+						xAxisIndex: index,
+						areaStyle: {},
+						data: item,
+                          lineStyle: {
+                            type: index===1?'dashed':""
+                          },
+						markPoint: {
+							data: [
+								{ type: 'max', name: '最大值' },
+								{ type: 'min', name: '最小值' }
+							]
+						},
+						markLine: {
+							data: [
+								{ type: 'average', name: '平均值' }
+							]
+						}
+					}
+				})
+			};
+			myChart.setOption(option);
+		});
+	}
+	showCharts();
 }]);

+ 120 - 77
src/Masuit.MyBlogs.Core/wwwroot/ng-views/controllers/post.js

@@ -342,87 +342,130 @@
         }));
     }
 
-    window.fetch("/post/records-chart", {
-		credentials: 'include',
-		method: 'GET',
-		mode: 'cors'
-	}).then(function(response) {
-		return response.json();
-	}).then(function(res) {
-		var pv = [];
-		var uv = [];
-		for (let item of res) {
-			pv.push([Date.parse(item.Date), item.Count]);
-			uv.push([Date.parse(item.Date),item.UV]);
-		}
-		var chartDom = document.getElementById('chart');
-		var myChart = echarts.init(chartDom);
-		var option = {
-			tooltip: {
-				trigger: 'axis',
-				position: function(pt) {
-					return [pt[0], '10%'];
+    showCharts=function() {
+        echarts.init(document.getElementById('chart')).dispose();
+		var period=document.getElementById("period").value;
+		window.fetch(`/post/records-chart?compare=${period>0}&period=${period}`, {
+			credentials: 'include',
+			method: 'GET',
+			mode: 'cors'
+		}).then(function (response) {
+			return response.json();
+		}).then(function (res) {
+			var xSeries = [];
+			var yCountSeries = [];
+			var yUvSeries = [];
+			for (let series of res) {
+				var x = [];
+				var yCount = [];
+				var yUV = [];
+				for (let item of series) {
+					x.push(new Date(Date.parse(item.Date)).toLocaleDateString());
+					yCount.push(item.Count);
+					yUV.push(item.UV);
 				}
-			},
-			title: {
-				left: 'center',
-				text: '最近90天阅读量趋势,日均:' + (res.reduce((acr, cur) => acr + cur.Count, 0) / (new Date() - new Date(res[0].Date)) * (1000 * 60 * 60 * 24)).toFixed(2)
-			},
-			xAxis: {
-				type: 'time',
-				axisLabel: {
-					formatter:function (value){
-						var dt=new Date(value);
-						return dt.toLocaleDateString();
-					}
-				}
-			},
-			yAxis: {
-				type: 'value'
-			},
-			series: [
-				{
-					name: 'PV',
-					type: 'line',
-					smooth: true,
-					symbol: 'none',
-					areaStyle: {},
-					data: pv,
-					markPoint: {
-						data: [
-							{ type: 'max', name: '最大值' },
-							{ type: 'min', name: '最小值' }
-						]
-					},
-					markLine: {
-						data: [
-							{ type: 'average', name: '平均值' }
-						]
+				xSeries.push(x);
+				yCountSeries.push(yCount);
+                yUvSeries.push(yUV);
+			}
+			var chartDom = document.getElementById('chart');
+			var myChart = echarts.init(chartDom);
+			const colors = ['#5470C6', '#EE6666'];
+			var option = {
+				color: colors,
+				tooltip: {
+					trigger: 'none',
+					axisPointer: {
+						type: 'cross'
 					}
 				},
-				{
-					name: 'UV',
-					type: 'line',
-					smooth: true,
-					symbol: 'none',
-					areaStyle: {},
-					data: uv,
-					markPoint: {
-						data: [
-							{ type: 'max', name: '最大值' },
-							{ type: 'min', name: '最小值' }
-						]
-					},
-					markLine: {
-						data: [
-							{ type: 'average', name: '平均值' }
-						]
+				legend: {},
+				grid: {
+					top: 70,
+					bottom: 50
+				},
+				title: {
+					left: 'center',
+					text: '最近访问趋势'
+				},
+				xAxis: xSeries.map(function (item, index) {
+					return {
+						type: 'category',
+						axisTick: {
+							alignWithLabel: true
+						},
+						axisLine: {
+							onZero: false,
+							lineStyle: {
+								color: colors[index]
+							}
+						},
+						axisPointer: {
+							label: {
+								formatter: function (params) {
+									return params.value + (params.seriesData.length ? ' 访问量:' + params.seriesData[0].data+",UV:"+ params.seriesData[1].data : '');
+								}
+							}
+						},
+						data: item
 					}
-				}
-			]
-		};
-		myChart.setOption(option);
-	});
+				}),
+				yAxis: [
+					{
+						type: 'value'
+					}
+				],
+				series: yCountSeries.map(function (item, index) {
+					return {
+						type: 'line',
+						smooth: true,
+						symbol: 'none',
+						xAxisIndex: index,
+						data: item,
+                          lineStyle: {
+                            type: index===1?'dashed':""
+                          },
+						markPoint: {
+							data: [
+								{ type: 'max', name: '最大值' },
+								{ type: 'min', name: '最小值' }
+							]
+						},
+						markLine: {
+							data: [
+								{ type: 'average', name: '平均值' }
+							]
+						}
+					}
+				}).concat(yUvSeries.map(function (item, index) {
+					return {
+						type: 'line',
+						smooth: true,
+						symbol: 'none',
+						xAxisIndex: index,
+						areaStyle: {},
+						data: item,
+                          lineStyle: {
+                            type: index===1?'dashed':""
+                          },
+						markPoint: {
+							data: [
+								{ type: 'max', name: '最大值' },
+								{ type: 'min', name: '最小值' }
+							]
+						},
+						markLine: {
+							data: [
+								{ type: 'average', name: '平均值' }
+							]
+						}
+					}
+				}))
+			};
+			myChart.setOption(option);
+		});
+    }
+    showCharts();
 }]);
 myApp.controller("writeblog", ["$scope", "$http", "$timeout","$location", function ($scope, $http, $timeout,$location) {
     UEDITOR_CONFIG.initialFrameHeight=null;

+ 8 - 0
src/Masuit.MyBlogs.Core/wwwroot/ng-views/views/partner.html

@@ -306,6 +306,14 @@
 
 <div class="row">
     <div class="col-md-12">
+        <label>对比最近</label>
+        <select id="period" onchange="showCharts()">
+            <option value="0">不对比</option>
+            <option value="15">15天</option>
+            <option value="30" selected="selected">一个月</option>
+            <option value="60">两个月</option>
+            <option value="90">三个月</option>
+        </select>
         <div id="chart" style="height: 500px"></div>
     </div>
 </div>

+ 8 - 0
src/Masuit.MyBlogs.Core/wwwroot/ng-views/views/post/postlist.html

@@ -220,5 +220,13 @@
     </div>
 </div>
 <div class="row">
+    <label>对比最近</label>
+    <select id="period" onchange="showCharts()">
+        <option value="0">不对比</option>
+        <option value="15">15天</option>
+        <option value="30" selected="selected">一个月</option>
+        <option value="60">两个月</option>
+        <option value="90">三个月</option>
+    </select>
     <div id="chart" style="height: 500px"></div>
 </div>