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

阿星Plus發表於2020-06-13

系列文章

  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 實戰系列(四)

上一篇完成了分類標籤友鏈的列表查詢頁面資料繫結,還剩下一個文章詳情頁的資料沒有綁,現在簡單的解決掉。

文章詳情

之前已經新增了四個引數:year、month、day、name,用來組成我們最終的URL,繼續新增一個引數用來接收API返回的資料。

[Parameter]
public int year { get; set; }

[Parameter]
public int month { get; set; }

[Parameter]
public int day { get; set; }

[Parameter]
public string name { get; set; }

/// <summary>
/// URL
/// </summary>
private string url => $"/{year}/{(month >= 10 ? month.ToString() : $"0{month}")}/{(day >= 10 ? day.ToString() : $"0{day}")}/{name}/";

/// <summary>
/// 文章詳情資料
/// </summary>
private ServiceResult<PostDetailDto> post;

然後在初始化方法OnInitializedAsync()中請求資料。

/// <summary>
/// 初始化
/// </summary>
protected override async Task OnInitializedAsync()
{
    // 獲取資料
    post = await Http.GetFromJsonAsync<ServiceResult<PostDetailDto>>($"/blog/post?url={url}");
}

現在拿到了post資料,然後在HTML中繫結即可。

@if (post == null)
{
    <Loading />
}
else
{
    @if (post.Success)
    {
        var _post = post.Result;

        <article class="post-wrap">
            <header class="post-header">
                <h1 class="post-title">@_post.Title</h1>
                <div class="post-meta">
                    Author: <a itemprop="author" rel="author" href="javascript:;">@_post.Author</a>
                    <span class="post-time">
                        Date: <a href="javascript:;">@_post.CreationTime</a>
                    </span>
                    <span class="post-category">
                        Category:<a href="/category/@_post.Category.DisplayName/">@_post.Category.CategoryName</a>
                    </span>
                </div>
            </header>
            <div class="post-content" id="content">
                @((MarkupString)_post.Html)
            </div>
            <section class="post-copyright">
                <p class="copyright-item">
                    <span>Author:</span>
                    <span>@_post.Author</span>
                </p>
                <p class="copyright-item">
                    <span>Permalink:</span>
                    <span><a href="/post@_post.Url">https://meowv.com/post@_post.Url</a></span>
                </p>
                <p class="copyright-item">
                    <span>License:</span>
                    <span>本文采用<a target="_blank" href="http://creativecommons.org/licenses/by-nc-nd/4.0/"> 知識共享 署名-非商業性使用-禁止演繹(CC BY-NC-ND)國際許可協議 </a>進行許可</span>
                </p>
            </section>
            <section class="post-tags">
                <div>
                    <span>Tag(s):</span>
                    <span class="tag">
                        @if (_post.Tags.Any())
                        {
                            @foreach (var tag in _post.Tags)
                            {
                                <a href="/tag/@tag.DisplayName/"># @tag.TagName</a>
                            }
                        }
                    </span>
                </div>
                <div>
                    <a @onclick="async () => await Common.BaskAsync()">back</a>
                    <span>· </span>
                    <a href="/">home</a>
                </div>
            </section>
            <section class="post-nav">
                @if (_post.Previous != null)
                {
                    <a class="prev"
                       rel="prev"
                       @onclick="@(async () => await Common.NavigateTo($"/post{_post.Previous.Url}, true))"
                       href="/post@_post.Previous.Url">@_post.Previous.Title</a>
                }
                @if (_post.Next != null)
                {
                    <a class="next"
                       rel="next"
                       @onclick="@(async () => await Common.NavigateTo($"/post{_post.Next.Url}", true))"
                       href="/post@_post.Next.Url">
                        @_post.Next.Title
                    </a>
                }
            </section>
        </article>
    }
    else
    {
        <ErrorTip />
    }
}

其中有幾個地方需要注意一下:

我們從post物件中取到的文章內容HTML,直接顯示是不行了,需要將其解析為HTML標籤,需要用到MarkupString

然後頁面上有一個後退按鈕,這裡我在Common.cs中寫了一個方法來實現。

/// <summary>
/// 後退
/// </summary>
/// <returns></returns>
public async Task BaskAsync()
{
    await InvokeAsync("window.history.back");
}

還有就是上一篇和下一篇的問題,將具體的URL傳遞給NavigateTo()方法,然後跳轉過去即可。

Common.cs中將之前文章建立RenderPage()方法修改成NavigateTo()。這個命名更好一點。

/// <summary>
/// 跳轉指定URL
/// </summary>
/// <param name="uri"></param>
/// <param name="forceLoad">true,繞過路由重新整理頁面</param>
/// <returns></returns>
public async Task NavigateTo(string url, bool forceLoad = false)
{
    _navigationManager.NavigateTo(url, forceLoad);

    await Task.CompletedTask;
}

現在資料算是繫結完了,但是遇到了一個大問題,就是詳情頁面的樣式問題,因為用到了Markdown,所以之前是載入了許多JS檔案來處理的。那麼現在肯定行不通了,所以關於詳情頁的樣式問題暫時擱淺,讓我尋找一下好多解決方式。

現在顯示是沒有問題了,就是不太好看,還有關於新增文章的功能,不知道有什麼好的 Markdown 編輯器可以推薦我使用。

1

到這裡Blazor的前端展示頁面已經全部弄完了,接下來開始寫後臺相關的頁面。

後臺首頁

關於後臺管理的所有頁面都放在Admin資料夾下,在Pages資料夾下新建Admin資料夾,然後先新增兩個元件頁面:Admin.razorAuth.razor

Admin.razor為後臺管理的首頁入口,我們在裡面直接新增幾個預知的連結並設定其路由。

@page "/admin"

<div class="post-wrap">
    <h2 class="post-title">-&nbsp;部落格內容管理&nbsp;-</h2>
    <ul>
        <li>
            <a href="/admin/post"><h3>?~~~ 新增文章 ~~~?</h3></a>
        </li>
        <li>
            <a href="/admin/posts"><h3>?~~~ 文章管理 ~~~?</h3></a>
        </li>
        <li>
            <a href="/admin/categories"><h3>?~~~ 分類管理 ~~~?</h3></a>
        </li>
        <li>
            <a href="/admin/tags"><h3>?~~~ 標籤管理 ~~~?</h3></a>
        </li>
        <li>
            <a href="/admin/friendlinks"><h3>?~~~ 友鏈管理 ~~~?</h3></a>
        </li>
    </ul>
</div>

裡面的a標籤所對應的頁面還沒有新增,等做到的時候再加,先手動訪問這個頁面看看,當成功授權後就跳到這個頁面來。

2

認證授權

關於授權,因為之前在API中已經完成了基於Github的JWT模式的認證授權模式,所以這裡我想做一個無感的授權功能,為什麼說無感呢,因為在我使用GitHub登入的過程中,如果之前已經登入過且沒有清除瀏覽器cookie資料,下次再登入的時候會預設直接登入成功,從而達到無感的。

實現邏輯其實也很簡單,我這裡用到了Common.cs中之前新增的公共方法設定和獲取localStorage的方法,我會將token等資訊放入localStorage中。

我設定的路由是:/auth。這個路由需要和 GitHub OAuth App 的回撥地址一致,當登入成功,會回撥跳到配置的頁面並攜帶code引數。

在獲取請求引數這塊需要引用一個包:Microsoft.AspNetCore.WebUtilities,新增好後在_Imports.razor新增引用:@using Meowv.Blog.BlazorApp.Shared

預設還是顯示載入中的元件:<Loading />

然後在@code{}中編寫程式碼,新增頁面初始化函式。

/// <summary>
/// 初始化
/// </summary>
/// <returns></returns>
protected override async Task OnInitializedAsync()
{
    // localStorage中access_token值
    var access_token = await Common.GetStorageAsync("access_token");

    // access_token有值
    if (!string.IsNullOrEmpty(access_token))
    {
        // 獲取token
        var _token = await Http.GetFromJsonAsync<ServiceResult<string>>($"/auth/token?access_token={access_token}");
        if (_token.Success)
        {
            // 將token存入localStorage
            await Common.SetStorageAsync("token", _token.Result);

            // 跳轉至後臺首頁
            await Common.NavigateTo("/admin");
        }
        else
        {
            // access_token失效,或者請求失敗的情況下,重新執行一次驗證流程
            await AuthProcessAsync();
        }
    }
    else //access_token為空
    {
        await AuthProcessAsync();
    }
}

先去獲取localStorage中的access_token值,肯定會有兩種情況,有或者沒有,然後分別去走不同的邏輯。

當access_token有值,就可以直接拿access_token去取token的值,理想情況請求成功拿到了token,這時候可以將token存到瀏覽器中,然後正常跳轉至後臺管理首頁,還有就是取token失敗了,失敗了就有可能是access_token過期了或者出現異常情況,這時候我們不去提示錯誤,直接拋棄所有,重新來一遍認證授權的流程,放在一個單獨的方法中AuthProcessAsync()

而當access_token沒值那就好辦了,也去來一遍認證授權的流程即可。

驗證流程AuthProcessAsync()的程式碼。

/// <summary>
/// 驗證流程
/// </summary>
/// <returns></returns>
private async Task AuthProcessAsync()
{
    // 當前URI物件
    var uri = await Common.CurrentUri();

    // 是否回撥攜帶了code引數
    bool hasCode = QueryHelpers.ParseQuery(uri.Query).TryGetValue("code", out Microsoft.Extensions.Primitives.StringValues code);

    if (hasCode)
    {
        var access_token = await Http.GetFromJsonAsync<ServiceResult<string>>($"/auth/access_token?code={code}");
        if (access_token.Success)
        {
            // 將access_token存入localStorage
            await Common.SetStorageAsync("access_token", access_token.Result);

            var token = await Http.GetFromJsonAsync<ServiceResult<string>>($"/auth/token?access_token={access_token.Result}");
            if (token.Success)
            {
                // 將token存入localStorage
                await Common.SetStorageAsync("token", token.Result);

                // 成功認證授權,跳轉至後臺管理首頁
                await Common.NavigateTo("/admin");
            }
            else
            {
                // 沒有許可權的人,回到首頁去吧
                await Common.NavigateTo("/");

                // 輸出提示資訊
                Console.WriteLine(token.Message);
            }
        }
        else
        {
            // 出錯了,回到首頁去吧
            await Common.NavigateTo("/");

            // 輸出提示資訊
            Console.WriteLine(access_token.Message);
        }
    }
    else
    {
        // 獲取第三方登入地址
        var loginAddress = await Http.GetFromJsonAsync<ServiceResult<string>>("/auth/url");

        // 跳轉到登入頁面
        await Common.NavigateTo(loginAddress.Result);
    }
}

驗證流程的邏輯先獲取當前URI物件,判斷URI中是否攜帶了code引數,從而可以知道當前頁面是回撥的過來的還是直接請求的,獲取當前URI物件放在Common.cs中。

/// <summary>
/// 獲取當前URI物件
/// </summary>
/// <returns></returns>
public async Task<Uri> CurrentUri()
{
    var uri = _navigationManager.ToAbsoluteUri(_navigationManager.Uri);

    return await Task.FromResult(uri);
}

在剛才新增的包Microsoft.AspNetCore.WebUtilities中為我們封裝好了解析URI引數的方法。

使用QueryHelpers.ParseQuery(...)獲取code引數的值。

當沒有值的時候,直接取請求登入地址,然後如果登入成功就會跳轉到攜帶code引數的回撥頁面。這樣流程就又回到了 驗證流程 開始的地方了。

登入成功,此時code肯定就有值了,那麼直接根據code獲取access_token,存入localStorage,正常情況拿到access_token就去生成token,然後也存入localStorage,成功授權可以跳到後臺管理首頁了。

其中如果有任何一個環節出現問題,直接跳轉到網站首頁去。如果授權不成功肯定是你在瞎搞(不接受任何反駁??),趕緊回到首頁去吧。

現在流程走完,去看看效果。

3

GitHub在國內的情況大家知道,有時候慢甚至打不開,有時候還是挺快的,還好今天沒掉鏈子,我遇到過好幾次壓根打不開的情況,獲取可以針對網路不好的時候我們換成其它的驗證方式,這個以後有機會再優化吧。

驗證元件

這個時候會發現,其實我們壓根不需要開啟/auth走驗證流程,直接訪問/admin就可以進來管理首頁,這是極其不合理的。那豈不是誰知道地址誰都能進來瞎搞了。所以我們可以在 Shared 資料夾下新增一個許可權驗證的元件:AdminLayout.razor。用來判斷是否真的登入了。

新建一個bool型別的變數 isLogin。預設肯定是false,此時可以讓頁面轉圈圈,使用<Loading />元件。當isLogin = true的時候我們才展示具體的HTML內容。

那麼就需要用到服務端元件RenderFragment,他有一個固定的引數名稱ChildContent

判斷是否登入的方法可以寫在初始化方法中,這裡還少了一個API,就是判斷當前token的值是否合法,合法就表示已經成功執行了驗證流程了。token不存在或者不合法,直接拒絕請求返回到首頁去吧。

整個程式碼如下:

@if (!isLogin)
{
    <Loading />
}
else
{
    @ChildContent
}

@code {
    /// <summary>
    /// 展示內容
    /// </summary>
    [Parameter]
    public RenderFragment ChildContent { get; set; }

    /// <summary>
    /// 是否登入
    /// </summary>
    private bool isLogin { get; set; }

    /// <summary>
    /// 初始化
    /// </summary>
    /// <returns></returns>
    protected override async Task OnInitializedAsync()
    {
        var token = await Common.GetStorageAsync("token");

        if (string.IsNullOrEmpty(token))
        {
            isLogin = false;

            await Common.NavigateTo("/");
        }
        else
        {
            // TODO:判斷token是否合法,先預設都是正確的
            isLogin = true;
        }
    }
}

使用這個元件也很方便了,我們後臺所有頁面都引用AdminLayout,將展示內容傳遞給就行了,成功驗證後就會展示HTM內容。

Admin.razor中使用。

@page "/admin"

<AdminLayout>
    <div class="post-wrap">
        <h2 class="post-title">-&nbsp;部落格內容管理&nbsp;-</h2>
        <ul>
            <li>
                <a href="/admin/post"><h3>?~~~ 新增文章 ~~~?</h3></a>
            </li>
            <li>
                <a href="/admin/posts"><h3>?~~~ 文章管理 ~~~?</h3></a>
            </li>
            <li>
                <a href="/admin/categories"><h3>?~~~ 分類管理 ~~~?</h3></a>
            </li>
            <li>
                <a href="/admin/tags"><h3>?~~~ 標籤管理 ~~~?</h3></a>
            </li>
            <li>
                <a href="/admin/friendlinks"><h3>?~~~ 友鏈管理 ~~~?</h3></a>
            </li>
        </ul>
    </div>
</AdminLayout>

現在清除掉瀏覽器快取,去請求/admin試試。

4

完美,比較簡單的實現了驗證是否登入的元件。其中還有許多地方可以優化,就交給大家去自行完成了?。

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

相關文章