C# cơ bản .NET Core

Trong phần này sẽ tạo ra các trang trình bày, duyệt và hiện thị các bài Post của Blog đến người dùng cuối.

Bài thực hành này tiếp tục trên ví dụ cũ mvcblog: Quản lý bài viết của Blog, tải mã nguồn về mở ra tiếp tục phát triển ex068-post

Tạo Layout hiện thị bài viết

Phần hiện thị bài viết, danh sách bài viết sẽ dử dụng một Layout riêng, tạo ra một Layout đặt tên là _PostLayout.cshtml trong đường dẫn đầy đủ Views/Shared/_PostLayout.cshtml, nội dung của layout này như sau:

Xem chi tiết tại: _PostLayout.cshtml

Bố cục chính thể hiện ở đoạn code:

<div class="container">
    <div class="row">
        <main role="main" class="col-8 pb-3">
            @RenderBody()
        </main>
        <div class="col-4 pb-3">
            @RenderSection("Sidebar", false)
        </div>
    </div>
</div>

Tức là có hai cột, trái sẽ dùng hiện thị nội dung và phải là điều hướng danh mục (tạo ra từ sectionn Sidebar).

Tạo ViewPostController

Tạo và xây dựng Controller - ViewPostController với các Action để truy cập các danh mục và hiện thị các bài viết thuộc danh mục, xem nội dung chi tiết một bài post.

Tạo ban đầu như sau:

Controllers/ViewPostController.cs

[Route ("/posts")]
public class ViewPostController : Controller {

    private readonly ILogger<ViewPostController> _logger;
    private readonly AppDbContext _context;
    private IMemoryCache _cache;


    // Số bài hiện thị viết trên một trang danh mục
    public const int ITEMS_PER_PAGE = 4;

    public ViewPostController (ILogger<ViewPostController> logger,
        AppDbContext context,
        IMemoryCache cache) {
        _logger = logger;
        _context = context;
        _cache = cache;
    }

    [Route ("{slug?}", Name = "listpost")]
    public async Task<IActionResult> Index ([Bind (Prefix = "page")] int pageNumber)
        return View ();
    }
}

Controller trên được Inject vào các dịch vụ: ILogger (để in log nếu muốn), AppDbContext (để truy cập Db), IMemoryCache (lưu cache vào bộ nhớ).

Action Index khai báo trên để hiện thị danh sách các bài viết thuộc 1 danh mục, danh mục được xác định bởi tham số slug, danh sách bài viết sẽ tách ra thành nhiều trang, trang hiện tại được binding với tham số query là page

[Route ("{slug?}", Name = "listpost")] tạo ra một route đặt tên là listpost, với định nghĩa template "{slug?} trên, kết với với route controller thì Action này thi hành khi truy cập các Url ví dụ nhứ:

  • /posts
  • /posts/slug-chuyen-muc
  • /posts/slug-chuyen-muc1?page=5
  • ...

Các View của Controller này sẽ tương ứng ở thư mục: Views/ViewPost, trước tiên trong thư mục này tạo ra file Views/_ViewStart.cshtml với nội dung:

@{
    Layout = "_PostLayout";
}

Nó sẽ thiết lập tất các các View dưới thư mục này để sử dụng layout _PostLayout.cshtml

Tạo ra file Views/ViewPost/Index.cshtml với nội dung ban đầu

@{
    ViewData["Title"] = "Các chuyên mục";
}
<div class="index-page">
    Các bài viết của chuyên mục
</div>


@section Sidebar {
   Nội dung phần Sidebar
}

Truy cập địa chỉ /posts ở thời điểm này trang hiện thị như sau, có hai cột - bên phải là sidebar:

Thêm danh mục vào sidebar

Trước tiên xây dựng một View Component, nhận dữ liệu là cây danh mục List<Category> để sinh ra HTML các danh mục. Component này tên CategorySidebar tạo ra tại thư mục Views/Shared/Components/CategorySidebar gồm 2 file sau:

Views/Shared/Components/CategorySidebar/CategorySidebar.cs

[ViewComponent]
public class CategorySidebar : ViewComponent
{
    public class CategorySidebarData {
        public List<Category> categories {set;get;}
        public int level {set; get;}
        public string slugCategory {set; get;}
    }

    public const string COMPONENTNAME = "CategorySidebar";
    public CategorySidebar() {}
    public IViewComponentResult Invoke(CategorySidebarData data) {
        return View(data);
    }
}

Views/Shared/Components/CategorySidebar/Default.cshtml

@using XTLASPNET
@model CategorySidebar.CategorySidebarData
@{
    List<Category> categories = Model.categories;
    int level = Model.level;
}
@if (categories.Count > 0)
{
    if (level == 0) {
        @Html.Raw("<div class=\"categorysidebar\">")
        <h3><a asp-controller="ViewPost" asp-action="Index" 
            asp-route-slug="">Các chủ đề</a></h3>
    }
     <ul>
        @foreach (var item in categories)
        {
            var cssClass = (item.Slug == Model.slugCategory) ? "active" : null;
            <li><a asp-controller="ViewPost" asp-action="Index" asp-route-slug="@item.Slug" 
                   class="@cssClass">@item.Title</a></li>
            
            @if (item.CategoryChildren?.Count > 0) {

                @await Component.InvokeAsync(CategorySidebar.COMPONENTNAME, 
                    new CategorySidebar.CategorySidebarData() {
                        categories = item.CategoryChildren.ToList(),
                        level = Model.level + 1,
                        slugCategory = Model.slugCategory
                    })
            }
        }
     </ul>

    if (level == 0) {
        @Html.Raw("</div>")
    }
}

Trong ViewPostController thêm vào hai phương thức GetCategoriesFindCategoryBySlug

public class ViewPostController : Controller {

    /...

    /// Lấy danh các Categories - có dùng cache
    [NonAction]
    List<Category> GetCategories () {

        List<Category> categories;

        string keycacheCategories = "_listallcategories";

        // Phục hồi categories từ Memory cache, không có thì truy vấn Db
        if (!_cache.TryGetValue (keycacheCategories, out categories)) {

            categories =  _context.Categories
                .Include (c => c.CategoryChildren)
                .AsEnumerable()
                .Where (c => c.ParentCategory == null)
                .ToList();

            // Thiết lập cache - lưu vào cache
            var cacheEntryOptions = new MemoryCacheEntryOptions ()
                .SetSlidingExpiration (TimeSpan.FromMinutes (300));
            _cache.Set ("_GetCategories", categories, cacheEntryOptions);
        }

        return categories;
    }


    // Tìm (đệ quy) trong cây, một Category theo Slug
    [NonAction]
    Category FindCategoryBySlug (List<Category> categories, string Slug) {

        foreach (var c in categories) {
            if (c.Slug == Slug) return c;
            var c1 = FindCategoryBySlug (c.CategoryChildren.ToList (), Slug);
            if (c1 != null)
                return c1;
        }

        return null;
    }

/...

Cập nhật Action Index như sau:

[Route ("{slug?}", Name = "listpost")]
public async Task<IActionResult> Index ([Bind (Prefix = "page")] int pageNumber,
                                        [FromRoute(Name = "slug")]string slugCategory)
{

    var categories = GetCategories();

    Category category = null;
    if (!string.IsNullOrEmpty (slugCategory)) {

        category = FindCategoryBySlug (categories, slugCategory);
        if (category == null) {
            return NotFound("Không thấy Category");
        }

    }

    ViewData["categories"]      = categories;
    ViewData["slugCategory"]    = slugCategory;
    ViewData["CurrentCategory"] = category;
    return View ();
}

Cập nhật index.cshtml

@using XTLASPNET
@{
    ViewData["Title"] = "Các chuyên mục";
}
<div class="index-page">
    Các bài viết của chuyên mục
</div>


@section Sidebar {
    @{
        @await Component.InvokeAsync(CategorySidebar.COMPONENTNAME, 
            new CategorySidebar.CategorySidebarData() {
                level = 0,
                categories = ViewBag.categories,
                slugCategory = ViewBag.slugCategory
            })
    }
}

Thêm danh sách bài viết vào trang danh mục

Phần này thêm chức năng truy vấn lấy danh sách các bài viết (thuộc một danh mục nào đó) rồi chuyển đến View như là Model để trình bày danh sách. Ở đây có áp dụng lấy các bài viết theo từng trang, mỗi trang có lấy là ITEMS_PER_PAGE mục.

Action Index sẽ sửa lại như sau:

[Route ("{slug?}", Name = "listpost")]
public async Task<IActionResult> Index ([Bind (Prefix = "page")] int pageNumber, 
                                        [FromRoute (Name = "slug")] string slugCategory) {

    var categories = GetCategories ();

    Category category = null;
    if (!string.IsNullOrEmpty (slugCategory)) {

        category = FindCategoryBySlug (categories, slugCategory);
        if (category == null) {
            return NotFound ("Không thấy Category");
        }

    }
    ViewData["categories"] = categories;
    ViewData["slugCategory"] = slugCategory;
    ViewData["CurrentCategory"] = category;


    // ........................................
    // Truy vấn lấy các post
    var posts = _context.Posts
        .Include (p => p.Author) // Load Author cho post  
        .Include (p => p.PostCategories) // Load các Category của Post
        .ThenInclude (c => c.Category)
        .AsQueryable ();

    if (category != null) {

        var ids = category.ChildCategoryIDs ();
        ids.Add (category.Id);

        // Lọc các Post có trong category (và con của nó)
        posts = posts.Where (p => p.PostCategories
            .Where (c => ids.Contains (c.CategoryID)).Any ());

    }
    // Lấy tổng số dòng dữ liệu
    var totalItems = posts.Count ();
    // Tính số trang hiện thị (mỗi trang hiện thị ITEMS_PER_PAGE mục)
    int totalPages = (int) Math.Ceiling ((double) totalItems / ITEMS_PER_PAGE);
    if (totalPages < 1) totalPages = 1;
    if (pageNumber == 0) pageNumber = 1;

    if (pageNumber > totalPages) {
        var vals = new Dictionary<string, string> () { { "slug", slugCategory }};
        if (totalPages > 1) 
            vals["page"] = totalPages.ToString ();
        return RedirectToRoute ("listpost", vals);
    }


    // Chỉ lấy các Post trang hiện tại  (theo pageNumber)
    posts = posts
        .Skip (ITEMS_PER_PAGE * (pageNumber - 1))
        .Take (ITEMS_PER_PAGE)
        .OrderByDescending (p => p.DateUpdated);

    ViewData["pageNumber"] = pageNumber;
    ViewData["totalPages"] = totalPages;

    // Thực hiện truy vấn lấy List các Post và chuyển cho View
    return View (await posts.ToListAsync());
}

Trình bày trang View (Index.cshtml)

@using XTLASPNET
@{
    ViewData["Title"] = "Các chuyên mục";
    Category category = ViewBag.CurrentCategory;
}
<div class="index-page">

    <nav aria-label="breadcrumb">
    <ol class="breadcrumb">
        <li class="breadcrumb-item"><a href="/">Home</a></li>
        <li class="breadcrumb-item"><a asp-controller="ViewPost" 
            asp-action="Index" asp-route-slug="">Blog</a></li>
        @if (category != null) {
            var lis = category.ListParents();
            foreach (var li in lis)
            {
                <li class="breadcrumb-item"><a asp-controller="ViewPost" 
                    asp-action="Index" asp-route-slug="@li.Slug">@li.Title</a></li>
            }
        }
    </ol>
    </nav>


    @if (category != null) {
        <h2>@category.Title</h2>
        ViewData["Title"] = category.Title;
    }
    @foreach (var item in Model)
    {
        <div class="media py-1">
            <div class="media-body">
                <h5 class="mt-0 mb-1">
                    <a asp-action="DisplayPost" 
                        asp-route-slug="@item.Slug">@item.Title</a>
                </h5>
                @item.Description
                <div class="author-row">
                    <strong>@item.Author.UserName</strong>
                    <i>@item.DateUpdated.ToShortDateString()</i>
                </div>
            </div>
            
        </div>
    }
    
     @{
        
        Func<int?,string> generateUrl = (int? _pagenumber)  => {
            if (_pagenumber == 1) _pagenumber = null;
            return Url.Link("listpost", new {page = _pagenumber, slug = ViewBag.slugCategory});  
        };

        var datapaging = new {
            currentPage = ViewBag.pageNumber,
            countPages  = ViewBag.totalPages,
            generateUrl =  generateUrl
        };

    }
    <partial name="_Paging" model="@datapaging" />

</div>

@section Sidebar {
    @{
        @await Component.InvokeAsync(CategorySidebar.COMPONENTNAME, 
            new CategorySidebar.CategorySidebarData() {
                level = 0,
                categories = ViewBag.categories,
                slugCategory = ViewBag.slugCategory
            })
    }
}

Trong code View trên, có thiết lập link cho từng bài viết - gọi đến Action DisplayPost, để hiện thị chi tiết một bài viết (sẽ xây dựng sau đây).

Trong View cũng sử dụng partial Paging để tạo HTML phân trang (xem thêm Paging)

Truy cập trang thời điểm này kết quả như sau:

Thêm Breadcrumb vào trang

Breadcrumb là thành phần giao diện điều hướng, cho người dùng biết đang ở đâu trong cây danh mục. Thêm đoạn code sau vào Index View để xuất hiện breadcrumb

<nav aria-label="breadcrumb">
    <ol class="breadcrumb">
        <li class="breadcrumb-item"><a href="/">Home</a></li>
        <li class="breadcrumb-item"><a asp-controller="ViewPost" 
            asp-action="Index" asp-route-slug="">Blog</a></li>
        @if (category != null) {
            var lis = category.ListParents();
            foreach (var li in lis)
            {
                <li class="breadcrumb-item"><a asp-controller="ViewPost" 
                    asp-action="Index" asp-route-slug="@li.Slug">@li.Title</a></li>
            }
        }
    </ol>
</nav>

Trong đó có gọi ListParents() của Category để các danh mục cha dẫn đến danh mục hiện tại. Phương thức này xây dựng trong Category như sau:

public List<Category> ListParents()
{
    List<Category> li = new List<Category>();
    var parent = this.ParentCategory;
    while (parent != null)
    {
        li.Add(parent);
        parent = parent.ParentCategory;
    }


    li.Reverse();
    return li;
}

Trang chi tiết trình bày bài viết Post

Các bài viết được truy vấn và xác định qua Slug, ta sẽ xây dựng Action DisplayPost để lấy dữ liệu bài Post và chuyển cho View, Action này xây dựng như sau:

[Route ("{slug}.html", Name = "viewonepost")]
public async Task<IActionResult> DisplayPost () {

    string Slug = (string) Request.RouteValues["slug"];

    if (string.IsNullOrEmpty (Slug)) {
        return NotFound("Không thấy trang");
    }

    // Truy vấn lấy bài viết theo Slug
    var post = await _context.Posts
        .Where (p => p.Slug == Slug)
        .Include (p => p.Author)
        .Include (p => p.PostCategories)
        .ThenInclude (c => c.Category)
        .FirstOrDefaultAsync ();

    if (post == null) {
        return NotFound ("Không thấy trang");
    }

    var categories = GetCategories ();
    ViewData["categories"] = categories;

    return View (post);
}

View (DisplayPost.cshtml) trình bày như sau

@using XTLASPNET
@model mvcblog.Models.Post
@{
    ViewData["Title"] = Model.Title;
    List<Category> categories = ViewBag.categories;
    Category category = Category.Find(categories,
        Model.PostCategories.FirstOrDefault().CategoryID); 
}

<nav aria-label="breadcrumb">
  <ol class="breadcrumb">
    <li class="breadcrumb-item"><a href="/">Home</a></li>
    <li class="breadcrumb-item"><a asp-controller="ViewPost" 
        asp-action="Index" asp-route-slug="">Blog</a></li>
    @if (category != null) {
        var lis = category.ListParents();
        foreach (var li in lis)
        {
            <li class="breadcrumb-item"><a asp-controller="ViewPost" 
                asp-action="Index" asp-route-slug="@li.Slug">@li.Title</a></li>
        }
    }
  </ol>
</nav>

<div class="detailpost">
    <h1>@Model.Title</h1>
    <div class="sapo">@Model.Description</div>
    @Html.Raw(Model.Content)
</div>

@section Sidebar {
    @{
        @await Component.InvokeAsync(CategorySidebar.COMPONENTNAME, 
            new CategorySidebar.CategorySidebarData() {
                level = 0,
                categories = ViewBag.categories,
                slugCategory = ViewBag.slugCategory
            })
    }
}

Mã nguồn tham khảo ASP_NET_CORE/mvcblog, hoặc tải về bản bài này ex068-fontendpost


Đăng ký nhận bài viết mới