Refactor categories view with tree structure and improve product caching

This commit is contained in:
masoodafar-web
2025-11-28 12:35:34 +03:30
parent 6ab835f7e9
commit 12d19f966c
6 changed files with 199 additions and 82 deletions

View File

@@ -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>

View File

@@ -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)
{
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)));
}
_nodeLookup = new Dictionary<long, CategoryTreeItem>();
_rows = rows.OrderBy(row => row.Depth).ThenBy(row => row.Category.Title).ToList();
}
var categoryIds = _items.Select(item => item.Id).ToHashSet();
private static List<CategoryItem> BuildPath(CategoryItem category, IReadOnlyDictionary<long, CategoryItem> lookup)
{
var path = new List<CategoryItem>();
var current = category;
var visited = new HashSet<long>();
while (current is not null && visited.Add(current.Id))
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)
{
path.Add(current);
if (current.ParentId is { } parentId && lookup.TryGetValue(parentId, out var parent))
var node = new CategoryTreeItem(category)
{
current = parent;
continue;
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();
}
break;
return node;
}
}
private async Task OnSearchChanged(string searchPhrase)
{
_searchPhrase = searchPhrase;
if (_treeView is not null)
{
await _treeView.FilterAsync();
}
}
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; }
}
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -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,

View File

@@ -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
@@ -202,6 +233,9 @@ public class ProductService
.Where(entry => entry.Expiration > now)
.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) =>