| | | 1 | | @using Microsoft.AspNetCore.Identity |
| | | 2 | | @using OzonRepositories.Context.Identity |
| | | 3 | | @using System.Security.Claims |
| | | 4 | | @using System.Text.Json |
| | | 5 | | @using OzonOrdersWeb.WebServices.Sidebar |
| | | 6 | | @using Services.CacheServices.Cache.UserCacheService |
| | | 7 | | @inject IUserCacheService UserCache |
| | | 8 | | @inject IUserSidebarSettingsService SidebarSettings |
| | | 9 | | @{ |
| | 0 | 10 | | var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? ""; |
| | 0 | 11 | | var currentAction = ViewContext.RouteData.Values["action"]?.ToString() ?? ""; |
| | 0 | 12 | | var currentArea = ViewContext.RouteData.Values["area"]?.ToString() ?? ""; |
| | | 13 | | // Sidebar рендерится в _Layout на КАЖДОЙ странице — берём пользователя из 10-мин memcache, |
| | | 14 | | // а не свежим запросом UserManager.GetUserAsync к БД на каждый рендер. |
| | 0 | 15 | | var currentUser = await UserCache.GetCachedUserAsync(User); |
| | 0 | 16 | | var displayName = currentUser?.GoogleDisplayName |
| | 0 | 17 | | ?? currentUser?.UserName |
| | 0 | 18 | | ?? User.Identity?.Name |
| | 0 | 19 | | ?? "User"; |
| | 0 | 20 | | var avatarUrl = currentUser?.GoogleAvatarUrl; |
| | 0 | 21 | | var safeName = (displayName ?? "U").Trim(); |
| | 0 | 22 | | var userInitials = safeName.Length >= 2 ? safeName[..2].ToUpperInvariant() : safeName.ToUpperInvariant(); |
| | 0 | 23 | | var isAdmin = User.IsInRole("Admin"); |
| | | 24 | | |
| | | 25 | | // Настройки меню (избранное, порядок, свёрнутые группы, pin) — из БД через 10-мин memory-кеш. |
| | 0 | 26 | | var sidebarUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); |
| | 0 | 27 | | var sidebarSettings = string.IsNullOrEmpty(sidebarUserId) |
| | 0 | 28 | | ? new Dictionary<string, string>() |
| | 0 | 29 | | : await SidebarSettings.GetAllAsync(sidebarUserId, Context.RequestAborted); |
| | 0 | 30 | | var sidebarPinned = false; |
| | 0 | 31 | | if (sidebarSettings.TryGetValue("prefs", out var prefsJson)) |
| | | 32 | | { |
| | | 33 | | try |
| | | 34 | | { |
| | 0 | 35 | | using var prefs = JsonDocument.Parse(prefsJson); |
| | 0 | 36 | | sidebarPinned = prefs.RootElement.TryGetProperty("pinned", out var pinnedEl) && pinnedEl.ValueKind == JsonVa |
| | 0 | 37 | | } |
| | 0 | 38 | | catch (JsonException) { /* битые настройки — как будто их нет */ } |
| | | 39 | | } |
| | 0 | 40 | | var sidebarNav = new SidebarNavViewModel |
| | 0 | 41 | | { |
| | 0 | 42 | | Tree = SidebarNavigationCatalog.For(isAdmin), |
| | 0 | 43 | | Settings = sidebarSettings, |
| | 0 | 44 | | SaveUrl = Url.Action("SaveSetting", "SidebarSettings") ?? "/Sidebar/SaveSetting", |
| | 0 | 45 | | Pinned = sidebarPinned |
| | 0 | 46 | | }; |
| | | 47 | | } |
| | | 48 | | |
| | 0 | 49 | | <aside class="app-sidebar app-sidebar--quartz @(sidebarPinned ? "is-pinned" : "")" id="appSidebar" data-sidebar-pinned=" |
| | | 50 | | <div class="sidebar-header"> |
| | | 51 | | <a class="sidebar-logo" asp-area="Studio2" asp-controller="Home" asp-action="Index" title="Открыть навигацию Stu |
| | | 52 | | <div class="sidebar-logo-icon"> |
| | | 53 | | <img class="sidebar-logo-image" src="~/images/branding/studio2-app-icon-v2.png" asp-append-version="true |
| | | 54 | | </div> |
| | | 55 | | <div class="sidebar-logo-meta"> |
| | | 56 | | <span class="sidebar-logo-text">Studio2</span> |
| | 0 | 57 | | <span class="sidebar-build-info" title="@AppBuildInfoDisplay.Tooltip">@AppBuildInfoDisplay.CompactLabel< |
| | | 58 | | </div> |
| | | 59 | | </a> |
| | | 60 | | <button class="sidebar-pin-toggle @(sidebarPinned ? "is-active" : "")" id="sidebarPinToggle" type="button" |
| | | 61 | | aria-pressed="@(sidebarPinned ? "true" : "false")" title="@(sidebarPinned ? "Открепить меню" : "Закрепит |
| | | 62 | | <span class="ag-icon @(sidebarPinned ? "ag-icon-un-pin" : "ag-icon-pin")" role="presentation"></span> |
| | | 63 | | <span class="visually-hidden">Закрепить раскрытое меню</span> |
| | | 64 | | </button> |
| | | 65 | | </div> |
| | | 66 | | |
| | | 67 | | <partial name="_SidebarStructuredNav" model="sidebarNav" /> |
| | | 68 | | |
| | | 69 | | <div class="sidebar-footer"> |
| | | 70 | | <a class="sidebar-user" asp-area="Studio2" asp-controller="Profile" asp-action="Index" title="Открыть профиль"> |
| | 0 | 71 | | @if (!string.IsNullOrWhiteSpace(avatarUrl)) |
| | | 72 | | { |
| | | 73 | | <img class="sidebar-user-avatar" src="@avatarUrl" alt="@displayName" referrerpolicy="no-referrer" /> |
| | | 74 | | } |
| | | 75 | | else |
| | | 76 | | { |
| | 0 | 77 | | <div class="sidebar-user-avatar">@userInitials</div> |
| | | 78 | | } |
| | | 79 | | <div class="sidebar-user-info"> |
| | 0 | 80 | | <div class="sidebar-user-name">@displayName</div> |
| | 0 | 81 | | <div class="sidebar-user-role">@(isAdmin ? "Администратор" : "Пользователь")</div> |
| | | 82 | | </div> |
| | | 83 | | </a> |
| | | 84 | | </div> |
| | | 85 | | </aside> |
| | | 86 | | |
| | | 87 | | <script> |
| | | 88 | | const orderControlBadge = document.getElementById('sidebar-order-control-badge'); |
| | | 89 | | const badgeUpdatedAt = () => new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); |
| | | 90 | | const updateOrderControlBadge = total => { |
| | | 91 | | if (!orderControlBadge || !Number.isInteger(total)) return; |
| | | 92 | | orderControlBadge.textContent = total > 99 ? '99+' : String(total); |
| | | 93 | | const description = `Активных задач контроля заказов: ${total}. Обновлено ${badgeUpdatedAt()}`; |
| | | 94 | | orderControlBadge.setAttribute('aria-label', description); |
| | | 95 | | orderControlBadge.title = description; |
| | | 96 | | orderControlBadge.style.display = total > 0 ? 'inline-flex' : 'none'; |
| | | 97 | | }; |
| | | 98 | | window.addEventListener('orderControlTasksCountChanged', event => updateOrderControlBadge(Number(event.detail?.total |
| | | 99 | | |
| | | 100 | | // Счётчик загружается фоном: недоступность API АВД не должна задерживать навигацию. |
| | | 101 | | document.addEventListener('DOMContentLoaded', async () => { |
| | | 102 | | const badge = document.getElementById('sidebar-supplier-cart-badge'); |
| | | 103 | | if (!badge) return; |
| | | 104 | | |
| | | 105 | | try { |
| | 0 | 106 | | const response = await fetch('@Url.Action("Count", "SupplierCart", new { area = "Studio2" })', { |
| | | 107 | | headers: { 'Accept': 'application/json' } |
| | | 108 | | }); |
| | | 109 | | if (!response.ok) return; |
| | | 110 | | |
| | | 111 | | const data = await response.json(); |
| | | 112 | | const count = Number(data?.count); |
| | | 113 | | if (!Number.isInteger(count) || count <= 0) return; |
| | | 114 | | |
| | | 115 | | badge.textContent = count > 99 ? '99+' : String(count); |
| | | 116 | | const description = `Позиций в корзине поставщиков: ${count}. Обновлено ${badgeUpdatedAt()}`; |
| | | 117 | | badge.setAttribute('aria-label', description); |
| | | 118 | | badge.title = description; |
| | | 119 | | badge.style.display = 'inline-flex'; |
| | | 120 | | } catch { |
| | | 121 | | // Индикатор необязателен: при сетевой ошибке оставляем его скрытым. |
| | | 122 | | } |
| | | 123 | | }); |
| | | 124 | | </script> |