Refactor categories view with tree structure and improve product caching
This commit is contained in:
@@ -31,36 +31,48 @@
|
||||
<MudText Class="mt-2 mud-text-secondary">در حال بارگذاری دستهبندیها...</MudText>
|
||||
</MudStack>
|
||||
}
|
||||
else if (_rows.Count == 0)
|
||||
else if (_treeItems.Count == 0)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">هنوز دستهبندیای ثبت نشده است.</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="_rows" Elevation="0" Bordered="true" Hover="true">
|
||||
<HeaderContent>
|
||||
<MudTh>عنوان</MudTh>
|
||||
<MudTh>مسیر دستهبندی</MudTh>
|
||||
<MudTh>اقدامات</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="d-flex" Style="@($"padding-left:{context.Depth * 1.5}rem")">
|
||||
<MudText Typo="Typo.subtitle2">@context.Category.Title</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(context.Category.ImagePath))
|
||||
{
|
||||
<MudAvatar Size="Size.Small" Image="@context.Category.ImagePath" Class="me-2" />
|
||||
}
|
||||
</MudStack>
|
||||
</MudTd>
|
||||
<MudTd>@context.Path</MudTd>
|
||||
<MudTd>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="() => NavigateToCategory(context.Category.Id)">
|
||||
مشاهده محصولات
|
||||
<MudPaper Elevation="0" Class="pa-0" Style="max-width:600px;margin:auto;">
|
||||
<MudStack AlignItems="AlignItems.Stretch" Spacing="2">
|
||||
<MudTextField T="string"
|
||||
Label="جستجو در دستهها"
|
||||
Adornment="Adornment.Start"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
TextChanged="OnSearchChanged"
|
||||
Immediate="true"
|
||||
Clearable="true" />
|
||||
<MudTreeView Items="@_treeItems"
|
||||
@ref="_treeView"
|
||||
FilterFunc="MatchesName"
|
||||
SelectedValueChanged="@((long id) => OnSelectedValueChanged(id))"
|
||||
Style="max-height:420px;overflow:auto;border:1px solid var(--mud-palette-lines-default);border-radius:var(--mud-default-borderradius);padding:0.5rem;">
|
||||
<ItemTemplate>
|
||||
<MudTreeViewItem @bind-Expanded="@context.Expanded"
|
||||
Items="@context.Children"
|
||||
Value="@context.Value"
|
||||
Icon="@context.Icon"
|
||||
Text="@context.Text"
|
||||
Visible="@context.Visible" />
|
||||
</ItemTemplate>
|
||||
</MudTreeView>
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudText Typo="Typo.subtitle2">
|
||||
@(_selectedNode is null ? "یک دسته را انتخاب کنید" : $"دسته انتخابی: {_selectedNode.Text}")
|
||||
</MudText>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
Disabled="@(_selectedNode is null)"
|
||||
OnClick="NavigateToSelectedCategory">
|
||||
مشاهده محصولات این دسته
|
||||
</MudButton>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
|
||||
@@ -2,8 +2,9 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using FrontOffice.Main.Utilities;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using MudBlazor;
|
||||
|
||||
namespace FrontOffice.Main.Pages.Store;
|
||||
|
||||
@@ -13,49 +14,82 @@ public partial class Categories : ComponentBase
|
||||
|
||||
private bool _loading;
|
||||
private List<CategoryItem> _items = new();
|
||||
private List<CategoryRow> _rows = new();
|
||||
private List<TreeItemData<long>> _treeItems = new();
|
||||
private MudTreeView<long>? _treeView;
|
||||
private string _searchPhrase = string.Empty;
|
||||
private CategoryTreeItem? _selectedNode;
|
||||
private Dictionary<long, CategoryTreeItem> _nodeLookup = new();
|
||||
private long? _selectedCategoryId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_loading = true;
|
||||
_items = await CategoryService.GetAllAsync();
|
||||
BuildRows();
|
||||
BuildTreeItems();
|
||||
_loading = false;
|
||||
}
|
||||
|
||||
private void BuildRows()
|
||||
private void BuildTreeItems()
|
||||
{
|
||||
var lookup = _items.ToDictionary(item => item.Id);
|
||||
var rows = new List<CategoryRow>();
|
||||
foreach (var item in _items)
|
||||
_nodeLookup = new Dictionary<long, CategoryTreeItem>();
|
||||
|
||||
var categoryIds = _items.Select(item => item.Id).ToHashSet();
|
||||
|
||||
var childrenLookup = _items
|
||||
.Where(item => item.ParentId.HasValue && categoryIds.Contains(item.ParentId.Value))
|
||||
.GroupBy(item => item.ParentId!.Value)
|
||||
.ToDictionary(group => group.Key, group => group.OrderBy(child => child.Title).ToList());
|
||||
|
||||
var roots = _items
|
||||
.Where(item => item.ParentId is null || !categoryIds.Contains(item.ParentId.Value))
|
||||
.OrderBy(item => item.Title)
|
||||
.Select(CreateNode)
|
||||
.Cast<TreeItemData<long>>()
|
||||
.ToList();
|
||||
|
||||
_treeItems = roots;
|
||||
_selectedNode = null;
|
||||
_selectedCategoryId = null;
|
||||
|
||||
CategoryTreeItem CreateNode(CategoryItem category)
|
||||
{
|
||||
var path = BuildPath(item, lookup);
|
||||
var pathLabel = path.Count > 0 ? string.Join(" › ", path.Select(x => x.Title)) : item.Title;
|
||||
rows.Add(new CategoryRow(item, pathLabel, Math.Max(0, path.Count - 1)));
|
||||
var node = new CategoryTreeItem(category)
|
||||
{
|
||||
Icon = category.ParentId is null ? Icons.Material.Filled.Category : Icons.Material.Outlined.Category,
|
||||
Expanded = category.ParentId is null
|
||||
};
|
||||
|
||||
_nodeLookup[category.Id] = node;
|
||||
|
||||
if (childrenLookup.TryGetValue(category.Id, out var children))
|
||||
{
|
||||
node.Children = children
|
||||
.Select(CreateNode)
|
||||
.Cast<TreeItemData<long>>()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
_rows = rows.OrderBy(row => row.Depth).ThenBy(row => row.Category.Title).ToList();
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<CategoryItem> BuildPath(CategoryItem category, IReadOnlyDictionary<long, CategoryItem> lookup)
|
||||
private async Task OnSearchChanged(string searchPhrase)
|
||||
{
|
||||
var path = new List<CategoryItem>();
|
||||
var current = category;
|
||||
var visited = new HashSet<long>();
|
||||
while (current is not null && visited.Add(current.Id))
|
||||
_searchPhrase = searchPhrase;
|
||||
if (_treeView is not null)
|
||||
{
|
||||
path.Add(current);
|
||||
if (current.ParentId is { } parentId && lookup.TryGetValue(parentId, out var parent))
|
||||
{
|
||||
current = parent;
|
||||
continue;
|
||||
await _treeView.FilterAsync();
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
private Task<bool> MatchesName(TreeItemData<long> item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_searchPhrase))
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
path.Reverse();
|
||||
return path;
|
||||
return Task.FromResult(item.Text?.Contains(_searchPhrase, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
|
||||
private void NavigateToCategory(long categoryId)
|
||||
@@ -63,5 +97,31 @@ public partial class Categories : ComponentBase
|
||||
Navigation.NavigateTo($"{RouteConstants.Store.Products}?category={categoryId}");
|
||||
}
|
||||
|
||||
private sealed record CategoryRow(CategoryItem Category, string Path, int Depth);
|
||||
private Task OnSelectedValueChanged(long categoryId)
|
||||
{
|
||||
_selectedCategoryId = categoryId;
|
||||
_selectedNode = _nodeLookup.TryGetValue(categoryId, out var node) ? node : null;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void NavigateToSelectedCategory()
|
||||
{
|
||||
if (_selectedNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NavigateToCategory(_selectedNode.Category.Id);
|
||||
}
|
||||
|
||||
private sealed class CategoryTreeItem : TreeItemData<long>
|
||||
{
|
||||
public CategoryTreeItem(CategoryItem category) : base(category.Id)
|
||||
{
|
||||
Category = category;
|
||||
Text = string.IsNullOrWhiteSpace(category.Title) ? $"دستهبندی {category.Id}" : category.Title;
|
||||
}
|
||||
|
||||
public CategoryItem Category { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,19 +23,12 @@ else if (_product is null)
|
||||
else
|
||||
{
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
|
||||
@if (_product is not null)
|
||||
{
|
||||
<MudBreadcrumbs SeparatorIcon="@Icons.Material.Filled.ChevronRight" Class="mb-4">
|
||||
<MudBreadcrumbItem Href="@RouteConstants.Main.MainPage">دیجی کالا</MudBreadcrumbItem>
|
||||
@foreach (var node in _breadcrumbNodes)
|
||||
{
|
||||
<MudBreadcrumbItem Href="@($"{RouteConstants.Store.Products}?category={node.Id}")">@node.Title</MudBreadcrumbItem>
|
||||
}
|
||||
<MudBreadcrumbItem Disabled="true">@_product.Title</MudBreadcrumbItem>
|
||||
</MudBreadcrumbs>
|
||||
}
|
||||
<MudGrid Spacing="3">
|
||||
<MudItem xs="12" md="6">
|
||||
@if (_product is not null)
|
||||
{
|
||||
<MudBreadcrumbs Items="_breadcrumbItems" SeparatorIcon="@Icons.Material.Filled.ChevronRight" Class="mb-2" />
|
||||
}
|
||||
<MudPaper Class="rounded-lg pa-2">
|
||||
@if (!string.IsNullOrWhiteSpace(MainImageUrl))
|
||||
{
|
||||
@@ -69,20 +62,20 @@ else
|
||||
@((MarkupString)_product.FullInformation)
|
||||
</MudText>
|
||||
}
|
||||
@if (_categoryPaths.Count > 0)
|
||||
{
|
||||
<MudStack Spacing="1">
|
||||
<MudText Typo="Typo.caption" Class="mud-text-secondary">دستهبندیها</MudText>
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||
@foreach (var categoryPath in _categoryPaths)
|
||||
{
|
||||
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Primary" OnClick="() => NavigateToCategory(categoryPath)">
|
||||
@GetCategoryLabel(categoryPath)
|
||||
</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
}
|
||||
@* @if (_categoryPaths.Count > 0) *@
|
||||
@* { *@
|
||||
@* <MudStack Spacing="1"> *@
|
||||
@* <MudText Typo="Typo.caption" Class="mud-text-secondary">دستهبندیها</MudText> *@
|
||||
@* <MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap"> *@
|
||||
@* @foreach (var categoryPath in _categoryPaths) *@
|
||||
@* { *@
|
||||
@* <MudChip T="string" Variant="Variant.Outlined" Color="Color.Primary" OnClick="() => NavigateToCategory(categoryPath)"> *@
|
||||
@* @GetCategoryLabel(categoryPath) *@
|
||||
@* </MudChip> *@
|
||||
@* } *@
|
||||
@* </MudStack> *@
|
||||
@* </MudStack> *@
|
||||
@* } *@
|
||||
<MudDivider Class="my-2"/>
|
||||
<MudText Typo="Typo.h5" Color="Color.Primary">@FormatPrice(_product.Price)</MudText>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using FrontOffice.Main.Utilities;
|
||||
using MudBlazor;
|
||||
|
||||
namespace FrontOffice.Main.Pages.Store;
|
||||
|
||||
@@ -20,8 +21,8 @@ public partial class ProductDetail : ComponentBase, IDisposable
|
||||
private const int MaxQty = 20;
|
||||
private IReadOnlyList<ProductGalleryImage> _galleryItems = Array.Empty<ProductGalleryImage>();
|
||||
private IReadOnlyList<ProductCategoryPathInfo> _categoryPaths = Array.Empty<ProductCategoryPathInfo>();
|
||||
private IReadOnlyList<ProductCategoryNodeInfo> _breadcrumbNodes = Array.Empty<ProductCategoryNodeInfo>();
|
||||
private ProductGalleryImage? _selectedGalleryImage;
|
||||
private readonly List<BreadcrumbItem> _breadcrumbItems = new();
|
||||
private long TotalPrice => (_product?.Price ?? 0) * _qty;
|
||||
private bool HasDiscount => _product is { Discount: > 0 and < 100 };
|
||||
|
||||
@@ -60,7 +61,7 @@ public partial class ProductDetail : ComponentBase, IDisposable
|
||||
{
|
||||
_galleryItems = Array.Empty<ProductGalleryImage>();
|
||||
_categoryPaths = Array.Empty<ProductCategoryPathInfo>();
|
||||
_breadcrumbNodes = Array.Empty<ProductCategoryNodeInfo>();
|
||||
_breadcrumbItems.Clear();
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
@@ -177,9 +178,26 @@ public partial class ProductDetail : ComponentBase, IDisposable
|
||||
|
||||
private void UpdateBreadcrumb()
|
||||
{
|
||||
_breadcrumbNodes = _categoryPaths
|
||||
_breadcrumbItems.Clear();
|
||||
|
||||
if (_product is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_breadcrumbItems.Add(new BreadcrumbItem("خانه", href: RouteConstants.Store.Products));
|
||||
|
||||
var nodes = _categoryPaths
|
||||
.OrderByDescending(path => path.Nodes.Count)
|
||||
.FirstOrDefault()?.Nodes ?? Array.Empty<ProductCategoryNodeInfo>();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
var target = $"{RouteConstants.Store.Products}?category={node.Id}";
|
||||
_breadcrumbItems.Add(new BreadcrumbItem(node.Title, href: target));
|
||||
}
|
||||
|
||||
// _breadcrumbItems.Add(new BreadcrumbItem(_product.Title, href: null, disabled: true));
|
||||
}
|
||||
|
||||
private void NavigateToCategory(ProductCategoryPathInfo path)
|
||||
|
||||
@@ -25,7 +25,7 @@ public class CategoryService
|
||||
}
|
||||
|
||||
var response = await _client.GetAllCategoriesAsync(new GetCategoriesRequest());
|
||||
_cache = response.Categories
|
||||
_cache = response.Models
|
||||
.Select(dto => new CategoryItem(
|
||||
Id: dto.Id,
|
||||
Title: dto.Title ?? dto.Name ?? string.Empty,
|
||||
|
||||
@@ -98,7 +98,10 @@ public class ProductService
|
||||
|
||||
public async Task<Product?> GetByIdAsync(long id)
|
||||
{
|
||||
if (TryGetCachedProduct(id, out var cached)) return cached;
|
||||
if (TryGetCachedProduct(id, out var cached) && HasDetailedData(cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
@@ -112,6 +115,11 @@ public class ProductService
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
TryGetCachedProduct(id, out var result);
|
||||
return result;
|
||||
}
|
||||
@@ -134,6 +142,8 @@ public class ProductService
|
||||
Rate: m.Rate,
|
||||
RemainingCount: m.RemainingCount
|
||||
);
|
||||
|
||||
p = PreserveCachedDetails(p);
|
||||
CacheProduct(p);
|
||||
|
||||
list.Add(p);
|
||||
@@ -142,6 +152,27 @@ public class ProductService
|
||||
return list;
|
||||
}
|
||||
|
||||
private Product PreserveCachedDetails(Product product)
|
||||
{
|
||||
if (_cache.TryGetValue(product.Id, out var entry))
|
||||
{
|
||||
var cached = entry.Product;
|
||||
var hasGallery = cached.Gallery.Count > 0;
|
||||
var hasCategories = cached.Categories.Count > 0;
|
||||
|
||||
if (hasGallery || hasCategories)
|
||||
{
|
||||
product = product with
|
||||
{
|
||||
Gallery = hasGallery ? cached.Gallery : product.Gallery,
|
||||
Categories = hasCategories ? cached.Categories : product.Categories
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
private Product MapAndCache(GetProductsResponse model)
|
||||
{
|
||||
var gallery = model.Gallery
|
||||
@@ -203,6 +234,9 @@ public class ProductService
|
||||
.Select(entry => entry.Product);
|
||||
}
|
||||
|
||||
private static bool HasDetailedData(Product product)
|
||||
=> product.Gallery.Count > 0 || product.Categories.Count > 0;
|
||||
|
||||
private sealed record CacheEntry(Product Product, DateTime Expiration);
|
||||
private static string BuildUrl(string? path) =>
|
||||
string.IsNullOrWhiteSpace(path) ? string.Empty : UrlUtility.DownloadUrl + path;
|
||||
|
||||
Reference in New Issue
Block a user