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> <MudText Class="mt-2 mud-text-secondary">در حال بارگذاری دسته‌بندی‌ها...</MudText>
</MudStack> </MudStack>
} }
else if (_rows.Count == 0) else if (_treeItems.Count == 0)
{ {
<MudAlert Severity="Severity.Info">هنوز دسته‌بندی‌ای ثبت نشده است.</MudAlert> <MudAlert Severity="Severity.Info">هنوز دسته‌بندی‌ای ثبت نشده است.</MudAlert>
} }
else else
{ {
<MudTable Items="_rows" Elevation="0" Bordered="true" Hover="true"> <MudPaper Elevation="0" Class="pa-0" Style="max-width:600px;margin:auto;">
<HeaderContent> <MudStack AlignItems="AlignItems.Stretch" Spacing="2">
<MudTh>عنوان</MudTh> <MudTextField T="string"
<MudTh>مسیر دسته‌بندی</MudTh> Label="جستجو در دسته‌ها"
<MudTh>اقدامات</MudTh> Adornment="Adornment.Start"
</HeaderContent> AdornmentIcon="@Icons.Material.Filled.Search"
<RowTemplate> TextChanged="OnSearchChanged"
<MudTd> Immediate="true"
<MudStack Row="true" AlignItems="AlignItems.Center" Class="d-flex" Style="@($"padding-left:{context.Depth * 1.5}rem")"> Clearable="true" />
<MudText Typo="Typo.subtitle2">@context.Category.Title</MudText> <MudTreeView Items="@_treeItems"
@if (!string.IsNullOrWhiteSpace(context.Category.ImagePath)) @ref="_treeView"
{ FilterFunc="MatchesName"
<MudAvatar Size="Size.Small" Image="@context.Category.ImagePath" Class="me-2" /> 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;">
</MudStack> <ItemTemplate>
</MudTd> <MudTreeViewItem @bind-Expanded="@context.Expanded"
<MudTd>@context.Path</MudTd> Items="@context.Children"
<MudTd> Value="@context.Value"
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="() => NavigateToCategory(context.Category.Id)"> 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> </MudButton>
</MudTd> </MudStack>
</RowTemplate> </MudStack>
</MudTable> </MudPaper>
} }
</MudPaper> </MudPaper>
</MudContainer> </MudContainer>

View File

@@ -2,8 +2,9 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using FrontOffice.Main.Utilities; using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace FrontOffice.Main.Pages.Store; namespace FrontOffice.Main.Pages.Store;
@@ -13,49 +14,82 @@ public partial class Categories : ComponentBase
private bool _loading; private bool _loading;
private List<CategoryItem> _items = new(); 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() protected override async Task OnInitializedAsync()
{ {
_loading = true; _loading = true;
_items = await CategoryService.GetAllAsync(); _items = await CategoryService.GetAllAsync();
BuildRows(); BuildTreeItems();
_loading = false; _loading = false;
} }
private void BuildRows() private void BuildTreeItems()
{ {
var lookup = _items.ToDictionary(item => item.Id); _nodeLookup = new Dictionary<long, CategoryTreeItem>();
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)));
}
_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 childrenLookup = _items
{ .Where(item => item.ParentId.HasValue && categoryIds.Contains(item.ParentId.Value))
var path = new List<CategoryItem>(); .GroupBy(item => item.ParentId!.Value)
var current = category; .ToDictionary(group => group.Key, group => group.OrderBy(child => child.Title).ToList());
var visited = new HashSet<long>();
while (current is not null && visited.Add(current.Id)) 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); var node = new CategoryTreeItem(category)
if (current.ParentId is { } parentId && lookup.TryGetValue(parentId, out var parent))
{ {
current = parent; Icon = category.ParentId is null ? Icons.Material.Filled.Category : Icons.Material.Outlined.Category,
continue; 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 Task.FromResult(item.Text?.Contains(_searchPhrase, StringComparison.OrdinalIgnoreCase) ?? false);
return path;
} }
private void NavigateToCategory(long categoryId) private void NavigateToCategory(long categoryId)
@@ -63,5 +97,31 @@ public partial class Categories : ComponentBase
Navigation.NavigateTo($"{RouteConstants.Store.Products}?category={categoryId}"); 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 else
{ {
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6"> <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"> <MudGrid Spacing="3">
<MudItem xs="12" md="6"> <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"> <MudPaper Class="rounded-lg pa-2">
@if (!string.IsNullOrWhiteSpace(MainImageUrl)) @if (!string.IsNullOrWhiteSpace(MainImageUrl))
{ {
@@ -69,20 +62,20 @@ else
@((MarkupString)_product.FullInformation) @((MarkupString)_product.FullInformation)
</MudText> </MudText>
} }
@if (_categoryPaths.Count > 0) @* @if (_categoryPaths.Count > 0) *@
{ @* { *@
<MudStack Spacing="1"> @* <MudStack Spacing="1"> *@
<MudText Typo="Typo.caption" Class="mud-text-secondary">دسته‌بندی‌ها</MudText> @* <MudText Typo="Typo.caption" Class="mud-text-secondary">دسته‌بندی‌ها</MudText> *@
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap"> @* <MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap"> *@
@foreach (var categoryPath in _categoryPaths) @* @foreach (var categoryPath in _categoryPaths) *@
{ @* { *@
<MudChip T="string" Variant="Variant.Outlined" Color="Color.Primary" OnClick="() => NavigateToCategory(categoryPath)"> @* <MudChip T="string" Variant="Variant.Outlined" Color="Color.Primary" OnClick="() => NavigateToCategory(categoryPath)"> *@
@GetCategoryLabel(categoryPath) @* @GetCategoryLabel(categoryPath) *@
</MudChip> @* </MudChip> *@
} @* } *@
</MudStack> @* </MudStack> *@
</MudStack> @* </MudStack> *@
} @* } *@
<MudDivider Class="my-2"/> <MudDivider Class="my-2"/>
<MudText Typo="Typo.h5" Color="Color.Primary">@FormatPrice(_product.Price)</MudText> <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 System.Linq;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using FrontOffice.Main.Utilities; using FrontOffice.Main.Utilities;
using MudBlazor;
namespace FrontOffice.Main.Pages.Store; namespace FrontOffice.Main.Pages.Store;
@@ -20,8 +21,8 @@ public partial class ProductDetail : ComponentBase, IDisposable
private const int MaxQty = 20; private const int MaxQty = 20;
private IReadOnlyList<ProductGalleryImage> _galleryItems = Array.Empty<ProductGalleryImage>(); private IReadOnlyList<ProductGalleryImage> _galleryItems = Array.Empty<ProductGalleryImage>();
private IReadOnlyList<ProductCategoryPathInfo> _categoryPaths = Array.Empty<ProductCategoryPathInfo>(); private IReadOnlyList<ProductCategoryPathInfo> _categoryPaths = Array.Empty<ProductCategoryPathInfo>();
private IReadOnlyList<ProductCategoryNodeInfo> _breadcrumbNodes = Array.Empty<ProductCategoryNodeInfo>();
private ProductGalleryImage? _selectedGalleryImage; private ProductGalleryImage? _selectedGalleryImage;
private readonly List<BreadcrumbItem> _breadcrumbItems = new();
private long TotalPrice => (_product?.Price ?? 0) * _qty; private long TotalPrice => (_product?.Price ?? 0) * _qty;
private bool HasDiscount => _product is { Discount: > 0 and < 100 }; private bool HasDiscount => _product is { Discount: > 0 and < 100 };
@@ -60,7 +61,7 @@ public partial class ProductDetail : ComponentBase, IDisposable
{ {
_galleryItems = Array.Empty<ProductGalleryImage>(); _galleryItems = Array.Empty<ProductGalleryImage>();
_categoryPaths = Array.Empty<ProductCategoryPathInfo>(); _categoryPaths = Array.Empty<ProductCategoryPathInfo>();
_breadcrumbNodes = Array.Empty<ProductCategoryNodeInfo>(); _breadcrumbItems.Clear();
} }
StateHasChanged(); StateHasChanged();
@@ -177,9 +178,26 @@ public partial class ProductDetail : ComponentBase, IDisposable
private void UpdateBreadcrumb() 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) .OrderByDescending(path => path.Nodes.Count)
.FirstOrDefault()?.Nodes ?? Array.Empty<ProductCategoryNodeInfo>(); .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) private void NavigateToCategory(ProductCategoryPathInfo path)

View File

@@ -25,7 +25,7 @@ public class CategoryService
} }
var response = await _client.GetAllCategoriesAsync(new GetCategoriesRequest()); var response = await _client.GetAllCategoriesAsync(new GetCategoriesRequest());
_cache = response.Categories _cache = response.Models
.Select(dto => new CategoryItem( .Select(dto => new CategoryItem(
Id: dto.Id, Id: dto.Id,
Title: dto.Title ?? dto.Name ?? string.Empty, Title: dto.Title ?? dto.Name ?? string.Empty,

View File

@@ -98,7 +98,10 @@ public class ProductService
public async Task<Product?> GetByIdAsync(long id) 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 try
{ {
@@ -112,6 +115,11 @@ public class ProductService
} }
catch catch
{ {
if (cached is not null)
{
return cached;
}
TryGetCachedProduct(id, out var result); TryGetCachedProduct(id, out var result);
return result; return result;
} }
@@ -134,6 +142,8 @@ public class ProductService
Rate: m.Rate, Rate: m.Rate,
RemainingCount: m.RemainingCount RemainingCount: m.RemainingCount
); );
p = PreserveCachedDetails(p);
CacheProduct(p); CacheProduct(p);
list.Add(p); list.Add(p);
@@ -142,6 +152,27 @@ public class ProductService
return list; 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) private Product MapAndCache(GetProductsResponse model)
{ {
var gallery = model.Gallery var gallery = model.Gallery
@@ -202,6 +233,9 @@ public class ProductService
.Where(entry => entry.Expiration > now) .Where(entry => entry.Expiration > now)
.Select(entry => entry.Product); .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 sealed record CacheEntry(Product Product, DateTime Expiration);
private static string BuildUrl(string? path) => private static string BuildUrl(string? path) =>