admin panel beter ui

This commit is contained in:
s.esmaeili
2026-09-19 12:05:59 +03:30
parent 80ccf1a2aa
commit 81987ceae8
196 changed files with 565 additions and 431 deletions
@@ -0,0 +1,45 @@
<template>
<!--
Recursive renderer for one navMenuItems.ts node, used by VerticalNavMenu.vue.
Extracted so a submenu can itself contain a submenu (e.g. "پیکربندی" nesting
"کیف پول" nesting "لیست کیف پول‌های شارژ شده") — ant-design-vue's a-sub-menu
nests natively, the fixed two-level template just didn't recurse into it.
-->
<a-sub-menu v-if="node.submenu" :key="menuKey">
<template #icon><component :is="resolveIcon(node.icon)" v-if="node.icon" /></template>
<template #title>{{ node.i18n || node.name }}</template>
<NavMenuNode
v-for="(child, k) in visibleChildren(node.submenu)"
:key="child.url || `${menuKey}-${k}`"
:node="child"
:menu-key="child.url || `${menuKey}-${k}`"
/>
</a-sub-menu>
<a-menu-item v-else :key="menuKey">
<template #icon><component :is="resolveIcon(node.icon)" v-if="node.icon" /></template>
{{ node.i18n || node.name }}
</a-menu-item>
</template>
<script setup lang="ts">
import { useUsersStore } from '../../stores/users';
import { resolveIcon } from '../../components/icons';
import type { NavMenuNode as NavMenuNodeType } from '../navMenuItems';
defineProps<{ node: NavMenuNodeType; menuKey: string }>();
const users = useUsersStore();
// Same visibility rule as VerticalNavMenu.vue: no permissionId = always visible.
function canSee(node: NavMenuNodeType): boolean {
if (!node.permissionId) return true;
return Boolean(users.hasPermission(node.permissionId));
}
function visibleChildren(nodes?: NavMenuNodeType[]): NavMenuNodeType[] {
return (nodes ?? []).filter(canSee);
}
defineOptions({ name: 'NavMenuNode' });
</script>
@@ -13,6 +13,14 @@
<MenuFoldOutlined v-else />
</a-button>
<span class="page-title">{{ pageTitle }}</span>
<a-breadcrumb v-if="breadcrumb" class="breadcrumb">
<a-breadcrumb-item v-for="(crumb, i) in breadcrumb" :key="i">
<router-link v-if="crumb.url && !crumb.active" :to="crumb.url">
{{ crumb.title }}
</router-link>
<span v-else>{{ crumb.title }}</span>
</a-breadcrumb-item>
</a-breadcrumb>
</div>
<div class="nav-right">
@@ -43,6 +51,12 @@ import { useRoute, useRouter } from 'vue-router';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue';
import { useAuthStore } from '../../stores/auth';
interface Crumb {
title: string;
url?: string;
active?: boolean;
}
defineProps<{ collapsed?: boolean }>();
defineEmits<{ (e: 'toggle-sidebar'): void }>();
@@ -51,6 +65,7 @@ const router = useRouter();
const auth = useAuthStore();
const pageTitle = computed(() => (route.meta?.pageTitle as string) ?? '');
const breadcrumb = computed(() => route.meta?.breadcrumb as Crumb[] | undefined);
const displayName = computed(() => auth.user?.name ?? auth.user?.fullName ?? 'کاربر');
const initial = computed(() => displayName.value.trim().charAt(0) || 'ک');
@@ -80,6 +95,9 @@ function onLogout() {
font-size: 16px;
color: #1c2d56;
}
.breadcrumb {
font-size: 13px;
}
.user-chip {
display: inline-flex;
align-items: center;
@@ -33,45 +33,20 @@
<template v-for="(node, i) in visibleMenu" :key="node.url || node.header || i">
<!-- 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). -->
children still carry their own icons below). Each child renders
through NavMenuNode, which recurses into however many submenu
levels it has. -->
<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>
<NavMenuNode
v-for="(child, j) in visibleChildren(node.items)"
:key="child.url || `${i}-${j}`"
:node="child"
:menu-key="child.url || subKey(child, j)"
/>
</a-menu-item-group>
<!-- 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>
<!-- group / leaf node -->
<NavMenuNode v-else :node="node" :menu-key="node.url || subKey(node, i)" />
</template>
</a-menu>
</a-layout-sider>
@@ -80,9 +55,10 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getMenu, type NavMenuNode } from '../navMenuItems';
import { getMenu, type NavMenuNode as NavMenuNodeData } from '../navMenuItems';
import { useUsersStore } from '../../stores/users';
import { resolveIcon } from '../../components/icons';
import NavMenuNode from './NavMenuNode.vue';
withDefaults(defineProps<{ title?: string; collapsed?: boolean }>(), {
title: 'منتزر',
@@ -101,17 +77,17 @@ const users = useUsersStore();
* items (whose permissionId is commented out in the legacy data) show for everyone.
* Preserved deliberately — see docs/ADMIN-PORT-PLAN.md.
*/
function canSee(node: NavMenuNode): boolean {
function canSee(node: NavMenuNodeData): boolean {
if (!node.permissionId) return true;
return Boolean(users.hasPermission(node.permissionId));
}
function visibleChildren(nodes?: NavMenuNode[]): NavMenuNode[] {
function visibleChildren(nodes?: NavMenuNodeData[]): NavMenuNodeData[] {
return (nodes ?? []).filter(canSee);
}
/** Hides a group whose children are all hidden, as legacy did. */
function hasVisibleContent(node: NavMenuNode): boolean {
function hasVisibleContent(node: NavMenuNodeData): boolean {
if (node.header) return visibleChildren(node.items).length > 0;
if (node.submenu) return visibleChildren(node.submenu).length > 0;
return true;
@@ -121,7 +97,28 @@ const visibleMenu = computed(() =>
getMenu().filter((n) => canSee(n) && hasVisibleContent(n)),
);
const subKey = (node: NavMenuNode, i: number) => `sub-${node.name ?? ''}-${i}`;
const subKey = (node: NavMenuNodeData, i: number) => `sub-${node.name ?? ''}-${i}`;
/**
* Finds the chain of sub-menu keys that must be open so the current route's
* menu item is visible, at any nesting depth. Mirrors the key each
* NavMenuNode instance is actually given (node.url, or menuKey-derived for
* url-less group nodes), so a match here really is a key ant's a-menu knows
* about.
*/
function findOpenKeysIn(nodes: NavMenuNodeData[], path: string, parentKey: string): string[] | null {
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const key = node.url || `${parentKey}-${i}`;
if (!node.submenu) {
if (node.url === path) return [];
continue;
}
const nested = findOpenKeysIn(visibleChildren(node.submenu), path, key);
if (nested) return [key, ...nested];
}
return null;
}
/**
* Finds the sub-menu key(s) that must be open so the current route's menu
@@ -137,15 +134,18 @@ function findOpenKeysFor(path: string): string[] {
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)];
}
if (!child.submenu) continue;
const childKey = child.url || subKey(child, j);
const nested = findOpenKeysIn(visibleChildren(child.submenu), path, childKey);
if (nested) return [childKey, ...nested];
}
continue;
}
if (node.submenu && visibleChildren(node.submenu).some((leaf) => leaf.url === path)) {
return [subKey(node, i)];
if (node.submenu) {
const nodeKey = node.url || subKey(node, i);
const nested = findOpenKeysIn(visibleChildren(node.submenu), path, nodeKey);
if (nested) return [nodeKey, ...nested];
}
}
return [];
@@ -175,7 +175,8 @@ function onClick({ key }: { key: string | number }) {
<style scoped>
.vertical-nav-menu {
min-height: 100vh;
height: 100vh;
overflow-y: auto; /* pinned in place; its own menu scrolls if it's taller than the viewport */
border-left: 1px solid #f0f0f0;
}
.nav-header {
+30 -36
View File
@@ -1,9 +1,15 @@
<template>
<!--
Ported from legacy henza_admin/src/layouts/main/Main.vue (281 lines).
Structure preserved: nav menu + navbar + breadcrumb/pageTitle header +
routed content. Dropped as template-only or Vue-2-only: VxTour, TheCustomizer,
vue-backtotop, and the Vuesax transition wrappers.
Structure preserved: nav menu + navbar + routed content. Dropped as
template-only or Vue-2-only: VxTour, TheCustomizer, vue-backtotop, and the
Vuesax transition wrappers. The breadcrumb/pageTitle header row that used
to sit above the content as its own row now lives inside TheNavbar, to
give pages full-height room for their card.
Layout is pinned viewport-height: the sidebar and navbar never move, and
only #content-area scrolls — instead of the whole page scrolling under a
sidebar/navbar that used to scroll away with it.
-->
<a-layout class="layout--main" :class="{ 'no-scroll': isAppPage }">
<VerticalNavMenu :collapsed="collapsed" />
@@ -13,19 +19,6 @@
<a-layout-content id="content-area">
<div class="content-wrapper">
<!-- legacy router-header: shown only when the route declares one -->
<div v-if="breadcrumb || pageTitle" class="router-header">
<h2 class="content-area__heading">{{ routeTitle }}</h2>
<a-breadcrumb v-if="breadcrumb" class="breadcrumb">
<a-breadcrumb-item v-for="(crumb, i) in breadcrumb" :key="i">
<router-link v-if="crumb.url && !crumb.active" :to="crumb.url">
{{ crumb.title }}
</router-link>
<span v-else>{{ crumb.title }}</span>
</a-breadcrumb-item>
</a-breadcrumb>
</div>
<div class="content-area__content">
<router-view @changeRouteTitle="changeRouteTitle" />
</div>
@@ -42,26 +35,20 @@ import VerticalNavMenu from '../components/VerticalNavMenu.vue';
import TheNavbar from '../components/TheNavbar.vue';
import { useLayoutStore } from '../../stores/layout';
interface Crumb {
title: string;
url?: string;
active?: boolean;
}
const route = useRoute();
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);
// legacy meta.no_scroll -> the 'no-scroll' body class
const isAppPage = computed(() => Boolean(route.meta?.no_scroll));
// legacy: views could override the heading via @changeRouteTitle
// legacy: views could override the heading via @changeRouteTitle. The
// heading itself now lives in TheNavbar, so this only resets on navigation;
// nothing currently emits changeRouteTitle, but the wiring is kept in case a
// view starts overriding its own title again.
const overriddenTitle = ref<string | undefined>(undefined);
const routeTitle = computed(() => overriddenTitle.value ?? pageTitle.value ?? '');
function changeRouteTitle(title: string) {
overriddenTitle.value = title;
@@ -77,21 +64,28 @@ watch(
<style scoped>
.layout--main {
min-height: 100vh;
height: 100vh;
overflow: hidden; /* the sidebar and navbar are pinned; only #content-area scrolls */
}
.layout--main > :deep(.ant-layout) {
height: 100%;
}
#content-area {
padding: 16px;
background: #f8f8f8;
}
.router-header {
/* Stretch the routed page (and its card) to fill the viewport height below
the navbar, instead of the card sizing to its content and leaving a gray
gap underneath on short lists. This is also the ONLY element that
scrolls — the navbar above it and the sidebar beside it stay fixed. */
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 1.5rem;
gap: 1rem;
height: calc(100vh - 61px); /* 61px matches TheNavbar's fixed height */
overflow-y: auto;
}
.content-area__heading {
margin: 0;
color: #1c2d56;
.content-wrapper,
.content-area__content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
</style>
+183 -160
View File
@@ -128,39 +128,49 @@ export const adminMenu: NavMenuNode[] = [
]
},
{
// Grouped per request: "شعب" and "کارگاه" used to be two separate
// top-level entries; they are now one group with each nested inside
// as its own submenu, same shape as the other grouped sections.
url: null,
name: 'Branch',
name: 'branch and workshop',
icon: 'GridIcon',
i18n: 'شعب',
permissionId:"branch",
i18n: 'شعب و کارگاه',
submenu: [
// 'افزودن شعب' removed: it is now the add button on the branch list
// page. The /branch/add route still works.
{
url: '/branch/list',
name: 'BranchList',
slug: 'branch-list',
i18n: 'لیست شعب',
permissionId:"branch.list"
url: null,
name: 'Branch',
icon: 'GridIcon',
i18n: 'شعب',
permissionId:"branch",
submenu: [
// 'افزودن شعب' removed: it is now the add button on the branch
// list page. The /branch/add route still works.
{
url: '/branch/list',
name: 'BranchList',
slug: 'branch-list',
i18n: 'لیست شعب',
permissionId:"branch.list"
},
]
},
]
},
/////////////////////////////////////////////
{
url: null,
name: 'Workshop',
icon: 'GridIcon',
i18n: 'کارگاه',
permissionId:"workshop",
submenu: [
// 'افزودن کارگاه' removed: it is now the add button on the workshop
// list page. The /workshop/add route still works.
{
url: '/workshop/list',
name: 'WorkshopList',
slug: 'workshop-list',
i18n: 'لیست کارگاه ها',
permissionId:"workshop.list"
url: null,
name: 'Workshop',
icon: 'GridIcon',
i18n: 'کارگاه',
permissionId:"workshop",
submenu: [
// 'افزودن کارگاه' removed: it is now the add button on the
// workshop list page. The /workshop/add route still works.
{
url: '/workshop/list',
name: 'WorkshopList',
slug: 'workshop-list',
i18n: 'لیست کارگاه ها',
permissionId:"workshop.list"
},
]
},
]
},
@@ -264,59 +274,6 @@ export const adminMenu: NavMenuNode[] = [
},
]
},
{
url: null,
name: 'wallet',
icon: 'CreditCardIcon',
i18n: 'کیف پول',
permissionId:"wallet",
submenu: [
{
url: '/wallet',
name: 'wallet',
slug: 'wallet',
i18n: 'لیست کیف پول های شارژ شده',
permissionId:"wallet.list"
},
]
},
{
url: null,
name: 'category',
icon: 'ListIcon',
i18n: 'دسته بندی ها',
permissionId:"collection",
submenu: [
// 'افزودن دسته بندی' removed: it is now the add button on the
// category list page. The /category/add route still works.
{
url: '/category/list',
name: 'categoryList',
slug: 'list-category',
i18n: 'لیست دسته بندی ها',
permissionId:"collection.list"
}
]
},
{
url: null,
name: 'finical',
icon: 'DollarSignIcon',
i18n: 'اسناد مالی',
permissionId:"document",
submenu: [
{
url: '/finical/list',
name: 'finical_list',
slug: 'finical_list',
i18n: 'لیست اسناد',
permissionId:"document.list"
},
// 'وارد کردن سند مالی مشتری' removed: it is now the add button on
// the financial documents list page. The /add/findoc route still
// works.
]
},
{
url: null,
name: 'report',
@@ -343,86 +300,89 @@ export const adminMenu: NavMenuNode[] = [
i18n: 'لیست تخفیفات ',
permissionId:"offers.list"
},
]
},
{
name: 'report',
icon: 'SlashIcon',
i18n: 'استثناعات تخفیف',
permissionId:"offersIgnore",
url: '/major/offersIgnore',
slug: 'offersIgnore',
},
{
url: null,
name: 'Invoice',
icon: 'BookIcon',
i18n: 'فاکتور انتقال',
permissionId:"invoice",
submenu: [
// 'افزودن فاکتور' removed: it is now the add button on the invoice
// list page. The /invoice/add route still works.
// Moved under "تخفیفات" per request — was a top-level entry.
{
url: '/invoice/list',
name: 'InvoiceList',
slug: 'invoice-list',
i18n: 'لیست فاکتورها',
permissionId:"invoice.list"
name: 'report',
icon: 'SlashIcon',
i18n: 'استثناعات تخفیف',
permissionId:"offersIgnore",
url: '/major/offersIgnore',
slug: 'offersIgnore',
},
]
},
{
// Grouped per request: "اسناد مالی", "فاکتور انتقال" and "گزارشات"
// used to be three separate top-level entries; they are now one
// group ("گزارشات و اسناد مالی") with each of them nested inside it
// as its own submenu, same 3-level shape as "پیکر بندی" below.
url: null,
name: 'report',
name: 'reports and documents',
icon: 'FileTextIcon',
i18n: 'گزارشات',
permissionId:"report",
i18n: 'گزارشات و اسناد مالی',
submenu: [
{
url: '/report/order_item',
name: 'report_order_item',
slug: 'report_ordedr_item',
i18n: 'گزارش تجمیعی سفارش و اسناد',
permissionId:"report.order.item"
url: null,
name: 'finical',
icon: 'DollarSignIcon',
i18n: 'اسناد مالی',
permissionId:"document",
submenu: [
{
url: '/finical/list',
name: 'finical_list',
slug: 'finical_list',
i18n: 'لیست اسناد',
permissionId:"document.list"
},
// 'وارد کردن سند مالی مشتری' removed: it is now the add button
// on the financial documents list page. The /add/findoc route
// still works.
]
},
{
url: '/report/product',
name: 'report_product',
slug: 'report_product',
i18n: 'گزارش محصولات',
permissionId:"report.product"
url: null,
name: 'Invoice',
icon: 'BookIcon',
i18n: 'فاکتور انتقال',
permissionId:"invoice",
submenu: [
// 'افزودن فاکتور' removed: it is now the add button on the
// invoice list page. The /invoice/add route still works.
{
url: '/invoice/list',
name: 'InvoiceList',
slug: 'invoice-list',
i18n: 'لیست فاکتورها',
permissionId:"invoice.list"
},
]
},
]
},
{
url: null,
name: 'cities offer percent',
// tagColor: 'warning',
icon: 'PercentIcon',
i18n: 'شهر ها',
permissionId:"cities",
submenu: [
// 'افزودن شهر' removed: it is now the add button on the city list
// page. The /city/create route still works.
{
url: '/city/list',
name: 'city list',
slug: 'city_list',
i18n: 'لیست شهر ها',
permissionId:"cities.list"
url: null,
name: 'report',
icon: 'FileTextIcon',
i18n: 'گزارشات',
permissionId:"report",
submenu: [
{
url: '/report/order_item',
name: 'report_order_item',
slug: 'report_ordedr_item',
i18n: 'گزارش تجمیعی سفارش و اسناد',
permissionId:"report.order.item"
},
{
url: '/report/product',
name: 'report_product',
slug: 'report_product',
i18n: 'گزارش محصولات',
permissionId:"report.product"
},
]
},
]
},
{
url: '/comments',
name: 'comments',
// tagColor: 'warning',
icon: 'MessageSquareIcon',
i18n: 'دیدگاه ها',
permissionId:"comment"
},
{
url: null,
name: 'sms',
@@ -439,22 +399,6 @@ export const adminMenu: NavMenuNode[] = [
},
]
},
{
url: null,
name: 'notify',
icon: 'BellIcon',
i18n: 'نوتیفیکیشن',
permissionId:"notify",
submenu: [
{
url: '/notify/create',
name: 'create notify',
slug: 'create_notify',
i18n: 'ارسال نوتیفیکیشن',
permissionId:"notify.create",
},
]
},
{
url: '/statics',
name: 'statics',
@@ -504,6 +448,85 @@ export const adminMenu: NavMenuNode[] = [
i18n: 'مدیریت کیف پول',
permissionId:"config.wallet"
},
// Moved under "پیکر بندی" per request — same node shape as before
// (own submenu / plain leaf), just relocated in the tree.
{
url: null,
name: 'category',
icon: 'ListIcon',
i18n: 'دسته بندی ها',
permissionId:"collection",
submenu: [
// 'افزودن دسته بندی' removed: it is now the add button on the
// category list page. The /category/add route still works.
{
url: '/category/list',
name: 'categoryList',
slug: 'list-category',
i18n: 'لیست دسته بندی ها',
permissionId:"collection.list"
}
]
},
{
url: null,
name: 'wallet',
icon: 'CreditCardIcon',
i18n: 'کیف پول',
permissionId:"wallet",
submenu: [
{
url: '/wallet',
name: 'wallet',
slug: 'wallet',
i18n: 'لیست کیف پول های شارژ شده',
permissionId:"wallet.list"
},
]
},
{
url: null,
name: 'cities offer percent',
// tagColor: 'warning',
icon: 'PercentIcon',
i18n: 'شهر ها',
permissionId:"cities",
submenu: [
// 'افزودن شهر' removed: it is now the add button on the city list
// page. The /city/create route still works.
{
url: '/city/list',
name: 'city list',
slug: 'city_list',
i18n: 'لیست شهر ها',
permissionId:"cities.list"
},
]
},
{
url: '/comments',
name: 'comments',
// tagColor: 'warning',
icon: 'MessageSquareIcon',
i18n: 'دیدگاه ها',
permissionId:"comment"
},
{
url: null,
name: 'notify',
icon: 'BellIcon',
i18n: 'نوتیفیکیشن',
permissionId:"notify",
submenu: [
{
url: '/notify/create',
name: 'create notify',
slug: 'create_notify',
i18n: 'ارسال نوتیفیکیشن',
permissionId:"notify.create",
},
]
},
]
},
]