admin-panel: render sidebar icons, fix openKeys, persist collapse state

- VerticalNavMenu.vue now renders the icon: values that navMenuItems.ts
  already carried on 33 nodes but were never displayed, via the shared
  resolveIcon() map. Sub-menus and leaf items get a #icon slot each; ADV4's
  Menu shows icons only (labels hidden) once :inline-collapsed is true, which
  is now wired to the collapsed prop.
- Fixed openKeys: it previously started at [] and was only ever updated by
  user clicks, so a direct link or page refresh landed with the active
  item's parent submenu collapsed. It's now derived from the current route
  on load and on every navigation.
- Sidebar collapse state moves from a local ref in Main.vue to
  stores/layout.ts's existing (previously unused) sidebarCollapsed, persisted
  to localStorage so it survives a reload.

Verified: pnpm build passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-14 15:20:47 +03:30
co-authored by Claude Sonnet 5
parent 02d92fc465
commit a6d8993d01
3 changed files with 72 additions and 5 deletions
@@ -24,25 +24,31 @@
<a-menu
mode="inline"
theme="light"
:inline-collapsed="collapsed"
:selected-keys="selectedKeys"
:open-keys="openKeys"
@update:openKeys="(k: string[]) => (openKeys = k)"
@click="onClick"
>
<template v-for="(node, i) in visibleMenu" :key="node.url || node.header || i">
<!-- header node: a section divider carrying `items` -->
<!-- header node: a section divider carrying `items` (ant has no icon
slot on a menu-item-group; it's a label-only divider, so its
children still carry their own icons below). -->
<a-menu-item-group v-if="node.header" :title="node.i18n || node.header">
<template v-for="(child, j) in visibleChildren(node.items)" :key="child.url || j">
<a-sub-menu v-if="child.submenu" :key="subKey(child, j)">
<template #icon><component :is="resolveIcon(child.icon)" v-if="child.icon" /></template>
<template #title>{{ child.i18n || child.name }}</template>
<a-menu-item
v-for="(leaf, k) in visibleChildren(child.submenu)"
:key="leaf.url || `${j}-${k}`"
>
<template #icon><component :is="resolveIcon(leaf.icon)" v-if="leaf.icon" /></template>
{{ leaf.i18n || leaf.name }}
</a-menu-item>
</a-sub-menu>
<a-menu-item v-else :key="child.url || `${j}`">
<template #icon><component :is="resolveIcon(child.icon)" v-if="child.icon" /></template>
{{ child.i18n || child.name }}
</a-menu-item>
</template>
@@ -50,17 +56,20 @@
<!-- group node -->
<a-sub-menu v-else-if="node.submenu" :key="subKey(node, i)">
<template #icon><component :is="resolveIcon(node.icon)" v-if="node.icon" /></template>
<template #title>{{ node.i18n || node.name }}</template>
<a-menu-item
v-for="(leaf, k) in visibleChildren(node.submenu)"
:key="leaf.url || `${i}-${k}`"
>
<template #icon><component :is="resolveIcon(leaf.icon)" v-if="leaf.icon" /></template>
{{ leaf.i18n || leaf.name }}
</a-menu-item>
</a-sub-menu>
<!-- leaf node -->
<a-menu-item v-else :key="node.url || `${i}`">
<template #icon><component :is="resolveIcon(node.icon)" v-if="node.icon" /></template>
{{ node.i18n || node.name }}
</a-menu-item>
</template>
@@ -73,6 +82,7 @@ import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getMenu, type NavMenuNode } from '../navMenuItems';
import { useUsersStore } from '../../stores/users';
import { resolveIcon } from '../../components/icons';
withDefaults(defineProps<{ title?: string; collapsed?: boolean }>(), {
title: 'هنزا گلد',
@@ -113,13 +123,45 @@ const visibleMenu = computed(() =>
const subKey = (node: NavMenuNode, i: number) => `sub-${node.name ?? ''}-${i}`;
/**
* Finds the sub-menu key(s) that must be open so the current route's menu
* item is visible. Previously openKeys only ever changed via user clicks
* (@update:openKeys), so a direct link or a page refresh landed with every
* submenu collapsed even when the active item was nested inside one.
*/
function findOpenKeysFor(path: string): string[] {
for (let i = 0; i < visibleMenu.value.length; i++) {
const node = visibleMenu.value[i];
if (node.header) {
const children = visibleChildren(node.items);
for (let j = 0; j < children.length; j++) {
const child = children[j];
if (child.submenu && visibleChildren(child.submenu).some((leaf) => leaf.url === path)) {
return [subKey(child, j)];
}
}
continue;
}
if (node.submenu && visibleChildren(node.submenu).some((leaf) => leaf.url === path)) {
return [subKey(node, i)];
}
}
return [];
}
const selectedKeys = ref<string[]>([route.path]);
const openKeys = ref<string[]>([]);
const openKeys = ref<string[]>(findOpenKeysFor(route.path));
watch(
() => route.path,
(p) => {
selectedKeys.value = [p];
const needed = findOpenKeysFor(p);
for (const key of needed) {
if (!openKeys.value.includes(key)) openKeys.value = [...openKeys.value, key];
}
},
);
+6 -2
View File
@@ -9,7 +9,7 @@
<VerticalNavMenu :collapsed="collapsed" />
<a-layout>
<TheNavbar :collapsed="collapsed" @toggle-sidebar="collapsed = !collapsed" />
<TheNavbar :collapsed="collapsed" @toggle-sidebar="layout.setSidebarCollapsed(!collapsed)" />
<a-layout-content id="content-area">
<div class="content-wrapper">
@@ -40,6 +40,7 @@ import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import VerticalNavMenu from '../components/VerticalNavMenu.vue';
import TheNavbar from '../components/TheNavbar.vue';
import { useLayoutStore } from '../../stores/layout';
interface Crumb {
title: string;
@@ -48,7 +49,10 @@ interface Crumb {
}
const route = useRoute();
const collapsed = ref(false);
const layout = useLayoutStore();
// Persisted via stores/layout.ts (localStorage) so the sidebar stays
// collapsed/expanded across reloads instead of resetting every time.
const collapsed = computed(() => layout.sidebarCollapsed);
const pageTitle = computed(() => route.meta?.pageTitle as string | undefined);
const breadcrumb = computed(() => route.meta?.breadcrumb as Crumb[] | undefined);
+22 -1
View File
@@ -5,6 +5,20 @@
import { defineStore } from 'pinia';
import themeConfig, { type ThemeMode } from '../themeConfig';
const SIDEBAR_COLLAPSED_KEY = 'admin-panel:sidebarCollapsed';
/** Reads the persisted collapse state, falling back to themeConfig's default
* when localStorage is unavailable or has never been set. */
function readPersistedCollapsed(): boolean {
if (typeof window === 'undefined') return themeConfig.sidebarCollapsed;
try {
const raw = window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
return raw === null ? themeConfig.sidebarCollapsed : raw === '1';
} catch {
return themeConfig.sidebarCollapsed;
}
}
export const useLayoutStore = defineStore('layout', {
state: () => ({
windowWidth: typeof window !== 'undefined' ? window.innerWidth : 0,
@@ -12,7 +26,7 @@ export const useLayoutStore = defineStore('layout', {
theme: themeConfig.theme as ThemeMode,
// legacy state.js: sidebar visibility for the vertical nav menu
isSidebarActive: true,
sidebarCollapsed: themeConfig.sidebarCollapsed,
sidebarCollapsed: readPersistedCollapsed(),
}),
getters: {
// legacy used breakpoints of 1200 (theme tour) and 992 (nav menu collapse)
@@ -32,6 +46,13 @@ export const useLayoutStore = defineStore('layout', {
},
setSidebarCollapsed(value: boolean) {
this.sidebarCollapsed = value;
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, value ? '1' : '0');
} catch {
// localStorage unavailable (private mode, disabled site data, ...) —
// collapse state just won't persist across reloads.
}
},
},
});