系列文章
使用 abp cli 搭建项目
给项目瘦身,让它跑起来
完善与美化,Swagger登场
数据访问和代码优先
自定义仓储之增删改查
统一规范API,包装返回模型
再说Swagger,分组、描述、小绿锁
接入GitHub,用JWT保护你的API
异常处理和日志记录
使用Redis缓存数据
集成Hangfire实现定时任务处理
用AutoMapper搞定对象映射
定时任务最佳实战(一)
定时任务最佳实战(二)
定时任务最佳实战(三)
博客接口实战篇(一)
博客接口实战篇(二)
博客接口实战篇(三)
博客接口实战篇(四)
博客接口实战篇(五)
Blazor实战系列(一)
Blazor实战系列(二)
Blazor实战系列(三)
Blazor实战系列(四)
Blazor实战系列(五)
Blazor实战系列(六)
Blazor实战系列(七)
上一篇完成了标签模块和友情链接模块的所有功能,本篇来继续完成博客最后的模块,文章的管理。
文章列表&删除
先将分页查询的列表给整出来,这块和首页的分页列表是类似的,就是多了个Id字段。
添加两条路由规则。
@page "/admin/posts"
@page "/admin/posts/{page:int}"
新建返回数据默认QueryPostForAdminDto.cs
。
//QueryPostForAdminDto.cs
using System.Collections.Generic;namespace Meowv.Blog.BlazorApp.Response.Blog
{public class QueryPostForAdminDto{/// <summary>/// 年份/// </summary>public int Year { get; set; }/// <summary>/// Posts/// </summary>public IEnumerable<PostBriefForAdminDto> Posts { get; set; }}
}//PostBriefForAdminDto.cs
namespace Meowv.Blog.BlazorApp.Response.Blog
{public class PostBriefForAdminDto : PostBriefDto{/// <summary>/// 主键/// </summary>public int Id { get; set; }}
}
然后添加所需的参数:当前页码、限制条数、总页码、文章列表返回数据模型。
/// <summary>
/// 当前页码
/// </summary>
[Parameter]
public int? page { get; set; }/// <summary>
/// 限制条数
/// </summary>
private int Limit = 15;/// <summary>
/// 总页码
/// </summary>
private int TotalPage;/// <summary>
/// 文章列表数据
/// </summary>
private ServiceResult<PagedList<QueryPostForAdminDto>> posts;
然后在初始化函数OnInitializedAsync()
中调用API获取文章数据.
/// <summary>
/// 初始化
/// </summary>
protected override async Task OnInitializedAsync()
{var token = await Common.GetStorageAsync("token");Http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");// 设置默认值page = page.HasValue ? page : 1;await RenderPage(page);
}/// <summary>
/// 点击页码重新渲染数据
/// </summary>
/// <param name="page"></param>
/// <returns></returns>
private async Task RenderPage(int? page)
{// 获取数据posts = await Http.GetFromJsonAsync<ServiceResult<PagedList<QueryPostForAdminDto>>>($"/blog/admin/posts?page={page}&limit={Limit}");// 计算总页码TotalPage = (int)Math.Ceiling((posts.Result.Total / (double)Limit));
}
在初始化中判断page参数,如果没有值给他设置一个默认值1。RenderPage(int? page)
方法是调用API返回数据,并计算出总页码值。
最后在页面上进行数据绑定。
<AdminLayout>@if (posts == null){<Loading />}else{<div class="post-wrap archive"><NavLink style="float:right" href="/admin/post"><h3>????~~~ 新增文章 ~~~????</h3></NavLink>@if (posts.Success && posts.Result.Item.Any()){@foreach (var item in posts.Result.Item){<h3>@item.Year</h3>@foreach (var post in item.Posts){<article class="archive-item"><NavLink title="❌删除" @onclick="@(async () => await DeleteAsync(post.Id))">❌</NavLink><NavLink title="????编辑" @onclick="@(async () => await Common.NavigateTo($"/admin/post/{post.Id}"))">????</NavLink><NavLink target="_blank" class="archive-item-link" href="@("/post" + post.Url)">@post.Title</NavLink><span class="archive-item-date">@post.CreationTime</span></article>}}<nav class="pagination">@for (int i = 1; i <= TotalPage; i++){var _page = i;if (page == _page){<span class="page-number current">@_page</span>}else{<a class="page-number" @onclick="@(() => RenderPage(_page))" href="/admin/posts/@_page">@_page</a>}}</nav>}else{<ErrorTip />}</div>}
</AdminLayout>
HTML内容放在组件AdminLayout
中,当 posts 没加载完数据的时候显示加载组件<Loading />
。
在页面上循环遍历文章数据和翻页页码,每篇文章标题前面添加两个按钮删除和编辑,同时单独加了一个新增文章的按钮。
删除文章调用DeleteAsync(int id)
方法,需要传递参数,当前文章的id。
新增和编辑按钮都跳转到"/admin/post"页面,当编辑的时候将id也传过去即可,路由规则为:"/admin/post/{id}"。
删除文章``方法如下:
/// <summary>
/// 删除文章
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
private async Task DeleteAsync(int id)
{// 弹窗确认bool confirmed = await Common.InvokeAsync<bool>("confirm", "\n????????真的要干掉这篇该死的文章吗????????");if (confirmed){var response = await Http.DeleteAsync($"/blog/post?id={id}");var result = await response.Content.ReadFromJsonAsync<ServiceResult>();if (result.Success){await RenderPage(page);}}
}
删除之前进行二次确认,避免误删,当确认删除之后调用删除文章API,最后重新渲染数据即可。
新增&更新文章
完成了后台文章列表的查询和删除,现在整个博客模块功能就差新增和更新文章了,胜利就在前方,冲啊。
这块的开发工作耗费了我太多时间,因为想使用 markdown 来写文章,找了一圈下来没有一个合适的组件,所以退而求次只能选择现有的markdown编辑器来实现了。
我这里选择了开源的编辑器Editor.md
,有需要的可以去 Github 自己下载,https://github.com/pandao/editor.md 。
将下载的资源包解压放在 wwwroot 文件夹下,默认是比较大的,而且还有很多示例文件,我已经将其精简了一番,可以去我 Github 下载使用。
先来看下最终的成品效果吧。
是不是感觉还可以,废话不多说,接下里告诉大家如何实现。
在 Admin 文件夹下添加post.razor
组件,设置路由,并且引用一个样式文件,在页面中引用样式文件好像不太符合标准,不过无所谓了,这个后台就自己用,而且还就这一个页面用得到。
@page "/admin/post"
@page "/admin/post/{id:int}"<link href="./editor.md/css/editormd.css" rel="stylesheet" /><AdminLayout>...
</AdminLayout>
把具体HTML内容放在组件AdminLayout
中。
因为新增和编辑放在同一个页面上,所以当id参数不为空的时候需要添加一个id参数,同时默认一进来就让页面显示加载中的组件,当页面和数据加载完成后在显示具体的内容,所以在指定一个布尔类型的是否加载参数isLoading
。
我们的编辑器主要依赖JavaScript实现的,所以这里不可避免要使用到JavaScript了。
在app.js
中添加几个全局函数。
switchEditorTheme: function () {editor.setTheme(localStorage.editorTheme || 'default');editor.setEditorTheme(localStorage.editorTheme === 'dark' ? 'pastel-on-dark' : 'default');editor.setPreviewTheme(localStorage.editorTheme || 'default');
},
renderEditor: async function () {await this._loadScript('./editor.md/lib/zepto.min.js').then(function () {func._loadScript('./editor.md/editormd.js').then(function () {editor = editormd("editor", {width: "100%",height: 700,path: './editor.md/lib/',codeFold: true,saveHTMLToTextarea: true,emoji: true,atLink: false,emailLink: false,theme: localStorage.editorTheme || 'default',editorTheme: localStorage.editorTheme === 'dark' ? 'pastel-on-dark' : 'default',previewTheme: localStorage.editorTheme || 'default',toolbarIcons: function () {return ["bold", "del", "italic", "quote", "ucwords", "uppercase", "lowercase", "h1", "h2", "h3", "h4", "h5", "h6", "list-ul", "list-ol", "hr", "link", "image", "code", "preformatted-text", "code-block", "table", "datetime", "html-entities", "emoji", "watch", "preview", "fullscreen", "clear", "||", "save"]},toolbarIconsClass: {save: "fa-check"},toolbarHandlers: {save: function () {func._shoowBox();}},onload: function () {this.addKeyMap({"Ctrl-S": function () {func._shoowBox();}});}});});});
},
_shoowBox: function () {DotNet.invokeMethodAsync('Meowv.Blog.BlazorApp', 'showbox');
},
_loadScript: async function (url) {let response = await fetch(url);var js = await response.text();eval(js);
}
renderEditor
主要实现了动态加载JavaScript代码,将markdown编辑器渲染出来。这里不多说,都是Editor.md
示例里面的代码。
为了兼容暗黑色主题,这里还加了一个切换编辑器主题的JavaScript方法,switchEditorTheme
。
_shoowBox
就厉害了,这个方法是调用的.NET组件中的方法,前面我们用过了在Blazor中调用JavaScript,这里演示了JavaScript中调用Blazor中的组件方法。
现在将所需的几个参数都添加到代码中。
/// <summary>
/// 定义一个委托方法,用于组件实例方法调用
/// </summary>
private static Func<Task> action;/// <summary>
/// 默认隐藏Box
/// </summary>
private bool Open { get; set; } = false;/// <summary>
/// 修改时的文章Id
/// </summary>
[Parameter]
public int? Id { get; set; }/// <summary>
/// 格式化的标签
/// </summary>
private string tags { get; set; }/// <summary>
/// 默认显示加载中
/// </summary>
private bool isLoading = true;/// <summary>
/// 文章新增或者修改输入参数
/// </summary>
private PostForAdminDto input;/// <summary>
/// API返回的分类列表数据
/// </summary>
private ServiceResult<IEnumerable<QueryCategoryForAdminDto>> categories;
大家看看注释就知道参数是做什么的了。
现在我们在初始化函数中将所需的数据通过API获取到。
/// <summary>
/// 初始化
/// </summary>
/// <returns></returns>
protected override async Task OnInitializedAsync()
{action = ChangeOpenStatus;var token = await Common.GetStorageAsync("token");Http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");if (Id.HasValue){var post = await Http.GetFromJsonAsync<ServiceResult<PostForAdminDto>>($"/blog/admin/post?id={Id}");if (post.Success){var _post = post.Result;input = new PostForAdminDto{Title = _post.Title,Author = _post.Author,Url = _post.Url,Html = _post.Html,Markdown = _post.Markdown,CategoryId = _post.CategoryId,Tags = _post.Tags,CreationTime = _post.CreationTime};tags = string.Join(",", input.Tags);}}else{input = new PostForAdminDto(){Author = "阿星Plus",CreationTime = DateTime.Now};}categories = await Http.GetFromJsonAsync<ServiceResult<IEnumerable<QueryCategoryForAdminDto>>>("/blog/admin/categories");// 渲染编辑器await Common.InvokeAsync("window.func.renderEditor");// 关闭加载isLoading = !isLoading;
}
action是一个异步的委托,在初始化中执行了ChangeOpenStatus
方法,这个方法等会说,然后获取localStorage
中token的值。
通过参数Id是否有值来判断当前是新增文章还是更新文章,如果有值就是更新文章,这时候需要根据id去将文章的数据拿到赋值给PostForAdminDto
对象展示在页面上,如果没有可以添加几个默认值给PostForAdminDto
对象。
因为文章需要分类和标签的数据,同时这里将分类的数据也查出来,标签默认是List列表,将其转换成字符串类型。
但完成上面操作后,调用JavaScript方法renderEditor
渲染渲染编辑器,最后关闭加载,显示页面。
现在来看看页面。
<AdminLayout>@if (isLoading){<Loading />}else{<div class="post-box"><div class="post-box-item"><input type="text" placeholder="标题" autocomplete="off" @bind="@input.Title" @bind:event="oninput" @onclick="@(() => { Open = false; })" /><input type="text" placeholder="作者" autocomplete="off" @bind="@input.Author" @bind:event="oninput" @onclick="@(() => { Open = false; })" /></div><div class="post-box-item"><input type="text" placeholder="URL" autocomplete="off" @bind="@input.Url" @bind:event="oninput" @onclick="@(() => { Open = false; })" /><input type="text" placeholder="时间" autocomplete="off" @bind="@input.CreationTime" @bind:format="yyyy-MM-dd HH:mm:sss" @bind:event="oninput" @onclick="@(() => { Open = false; })" /></div><div id="editor"><textarea style="display:none;">@input.Markdown</textarea></div><Box OnClickCallback="@SubmitAsync" Open="@Open" ButtonText="发布"><div class="box-item"><b>分类:</b>@if (categories.Success && categories.Result.Any()){@foreach (var item in categories.Result){<label><input type="radio" name="category" value="@item.Id" @onchange="@(() => { input.CategoryId = item.Id; })" checked="@(item.Id == input.CategoryId)" />@item.CategoryName</label>}}</div><div class="box-item"></div><div class="box-item"><b>标签:</b><input type="text" @bind="@tags" @bind:event="oninput" /></div></Box></div>}
</AdminLayout>
添加了四个input框,分别用来绑定标题、作者、URL、时间,<div id="editor"></div>
中为编辑器所需。
然后我这里还是把之前的弹窗组件搞出来了,执行逻辑不介绍了,在弹窗组件中自定义显示分类和标签的内容,将获取到的分类和标签绑定到具体位置。
每个分类都是一个radio标签,并且对应一个点击事件,点哪个就把当前分类的Id赋值给PostForAdminDto
对象。
所有的input框都使用@bind
和@bind:event
绑定数据和获取数据。
Box
弹窗组件这里自定义了按钮文字,ButtonText="发布"
。
/// <summary>
/// 改变Open状态,通知组件渲染
/// </summary>
private async Task ChangeOpenStatus()
{Open = true;var markdown = await Common.InvokeAsync<string>("editor.getMarkdown");var html = await Common.InvokeAsync<string>("editor.getHTML");if (string.IsNullOrEmpty(input.Title) || string.IsNullOrEmpty(input.Url) ||string.IsNullOrEmpty(input.Author) || string.IsNullOrEmpty(markdown) ||string.IsNullOrEmpty(html)){await Alert();}input.Html = html;input.Markdown = markdown;StateHasChanged();
}/// <summary>
/// 暴漏给JS执行,弹窗确认框
/// </summary>
[JSInvokable("showbox")]
public static void ShowBox()
{action.Invoke();
}
/// <summary>
/// alert提示
/// </summary>
/// <returns></returns>
private async Task Alert()
{Open = false;await Common.InvokeAsync("alert", "\n????????好像漏了点什么吧????????");return;
}
现在可以来看看ChangeOpenStatus
方法了,这个是改变当前弹窗状态的一个方法。为什么需要这个方法呢?
因为在Blazor中JavaScript想要调用组件内的方法,方法必须是静态的,那么只能通过这种方式去实现了,在静态方法是不能够直接改变弹窗的状态值的。
其实也可以不用这么麻烦,因为我在编辑器上自定义了一个按钮,为了好看一些所以只能曲折一点,嫌麻烦的可以直接在页面上搞个按钮执行保存数据逻辑也是一样的。
使用JSInvokable
Attribute需要在_Imports.razor
中添加命名空间@using Microsoft.JSInterop
。
ChangeOpenStatus
中获取到文章内容:HTML和markdown,赋值给PostForAdminDto
对象,要先进行判断页面上的几个参数是否有值,没值的话给出提示执行Alert()
方法,最后使用StateHasChanged()
通知组件其状态已更改。
Alert
方法就是调用原生的JavaScriptalert
方法,给出一个提示。
ShowBox
就是暴漏给JavaScript的方法,使用DotNet.invokeMethodAsync('Meowv.Blog.BlazorApp', 'showbox');
进行调用。
那么现在一切都正常进行的情况下,点击编辑器上自定义的保存按钮,页面上值不为空的情况下就会弹出我们的弹窗组件Box
。
最后在弹窗组件的回调方法中执行新增文章还是更新文章。
/// <summary>
/// 确认按钮点击事件
/// </summary>
/// <returns></returns>
private async Task SubmitAsync()
{if (string.IsNullOrEmpty(tags) || input.CategoryId == 0){await Alert();}input.Tags = tags.Split(",");var responseMessage = new HttpResponseMessage();if (Id.HasValue)responseMessage = await Http.PutAsJsonAsync($"/blog/post?id={Id}", input);elseresponseMessage = await Http.PostAsJsonAsync("/blog/post", input);var result = await responseMessage.Content.ReadFromJsonAsync<ServiceResult>();if (result.Success){await Common.NavigateTo("/admin/posts");}
}
打开弹窗后执行回调事件之前还是要判断值是否为空,为空的情况下还是给出alert
提示,此时将tags标签还是转换成List列表,根据Id是否有值去执行新增数据或者更新数据,最终成功后跳转到文章列表页。
本篇到这里就结束了,主要攻克了在Blazor中使用Markdown编辑器实现新增和更新文章,这个系列差不多就快结束了,预计还有2篇的样子,感谢各位的支持。
开源地址:https://github.com/Meowv/Blog/tree/blog_tutorial