基於 abp vNext 和 .NET Core 開發部落格專案 - Blazor 實戰系列(八)

阿星Plus發表於2020-06-17

系列文章

  1. 基於 abp vNext 和 .NET Core 開發部落格專案 - 使用 abp cli 搭建專案
  2. 基於 abp vNext 和 .NET Core 開發部落格專案 - 給專案瘦身,讓它跑起來
  3. 基於 abp vNext 和 .NET Core 開發部落格專案 - 完善與美化,Swagger登場
  4. 基於 abp vNext 和 .NET Core 開發部落格專案 - 資料訪問和程式碼優先
  5. 基於 abp vNext 和 .NET Core 開發部落格專案 - 自定義倉儲之增刪改查
  6. 基於 abp vNext 和 .NET Core 開發部落格專案 - 統一規範API,包裝返回模型
  7. 基於 abp vNext 和 .NET Core 開發部落格專案 - 再說Swagger,分組、描述、小綠鎖
  8. 基於 abp vNext 和 .NET Core 開發部落格專案 - 接入GitHub,用JWT保護你的API
  9. 基於 abp vNext 和 .NET Core 開發部落格專案 - 異常處理和日誌記錄
  10. 基於 abp vNext 和 .NET Core 開發部落格專案 - 使用Redis快取資料
  11. 基於 abp vNext 和 .NET Core 開發部落格專案 - 整合Hangfire實現定時任務處理
  12. 基於 abp vNext 和 .NET Core 開發部落格專案 - 用AutoMapper搞定物件對映
  13. 基於 abp vNext 和 .NET Core 開發部落格專案 - 定時任務最佳實戰(一)
  14. 基於 abp vNext 和 .NET Core 開發部落格專案 - 定時任務最佳實戰(二)
  15. 基於 abp vNext 和 .NET Core 開發部落格專案 - 定時任務最佳實戰(三)
  16. 基於 abp vNext 和 .NET Core 開發部落格專案 - 部落格介面實戰篇(一)
  17. 基於 abp vNext 和 .NET Core 開發部落格專案 - 部落格介面實戰篇(二)
  18. 基於 abp vNext 和 .NET Core 開發部落格專案 - 部落格介面實戰篇(三)
  19. 基於 abp vNext 和 .NET Core 開發部落格專案 - 部落格介面實戰篇(四)
  20. 基於 abp vNext 和 .NET Core 開發部落格專案 - 部落格介面實戰篇(五)
  21. 基於 abp vNext 和 .NET Core 開發部落格專案 - Blazor 實戰系列(一)
  22. 基於 abp vNext 和 .NET Core 開發部落格專案 - Blazor 實戰系列(二)
  23. 基於 abp vNext 和 .NET Core 開發部落格專案 - Blazor 實戰系列(三)
  24. 基於 abp vNext 和 .NET Core 開發部落格專案 - Blazor 實戰系列(四)
  25. 基於 abp vNext 和 .NET Core 開發部落格專案 - Blazor 實戰系列(五)
  26. 基於 abp vNext 和 .NET Core 開發部落格專案 - Blazor 實戰系列(六)
  27. 基於 abp vNext 和 .NET Core 開發部落格專案 - Blazor 實戰系列(七)

上一篇完成了標籤模組和友情連結模組的所有功能,本篇來繼續完成部落格最後的模組,文章的管理。

文章列表 & 刪除

1

先將分頁查詢的列表給整出來,這塊和首頁的分頁列表是類似的,就是多了個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,最後重新渲染資料即可。

2

新增 & 更新文章

完成了後臺文章列表的查詢和刪除,現在整個部落格模組功能就差新增和更新文章了,勝利就在前方,衝啊。

這塊的開發工作耗費了我太多時間,因為想使用 markdown 來寫文章,找了一圈下來沒有一個合適的元件,所以退而求次只能選擇現有的markdown編輯器來實現了。

我這裡選擇了開源的編輯器Editor.md,有需要的可以去 Github 自己下載,https://github.com/pandao/editor.md

將下載的資源包解壓放在 wwwroot 資料夾下,預設是比較大的,而且還有很多示例檔案,我已經將其精簡了一番,可以去我 Github 下載使用。

先來看下最終的成品效果吧。

3

是不是感覺還可以,廢話不多說,接下里告訴大家如何實現。

在 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想要呼叫元件內的方法,方法必須是靜態的,那麼只能通過這種方式去實現了,在靜態方法是不能夠直接改變彈窗的狀態值的。

其實也可以不用這麼麻煩,因為我在編輯器上自定義了一個按鈕,為了好看一些所以只能曲折一點,嫌麻煩的可以直接在頁面上搞個按鈕執行儲存資料邏輯也是一樣的。

使用JSInvokableAttribute需要在_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);
    else
        responseMessage = 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是否有值去執行新增資料或者更新資料,最終成功後跳轉到文章列表頁。

4

5

本片到這裡就結束了,主要攻克了在Blazor中使用Markdown編輯器實現新增和更新文章,這個系列差不多就快結束了,預計還有2篇的樣子,感謝各位的支援。

開源地址:https://github.com/Meowv/Blog/tree/blog_tutorial

相關文章