fix(ui): cap khanegi bottom nav at 4 slots with drag reorder

Merge all versions onto the shared nav sample so extra items cannot stay enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-24 11:42:42 +03:30
co-authored by Cursor
parent 349031d3bc
commit 238fb7966c
2 changed files with 186 additions and 4 deletions
+86
View File
@@ -91,6 +91,7 @@ export function createDefaultKhanegiUiConfig() {
{ id: 'user', label: 'تنظیمات', icon: 'vuesax-linear:setting-2', route: 'user', order: 8, visible: true }, { id: 'user', label: 'تنظیمات', icon: 'vuesax-linear:setting-2', route: 'user', order: 8, visible: true },
], ],
bottom: [ bottom: [
// ۴ جایگاه ثابت نوار پایین — با get-user/khanegi-nav-sample.ts یکی بماند
{ id: 'gold', label: 'خرید و فروش', icon: 'vuesax-linear:coin', route: 'gold', order: 1, visible: true }, { id: 'gold', label: 'خرید و فروش', icon: 'vuesax-linear:coin', route: 'gold', order: 1, visible: true },
{ id: 'transactions', label: 'تراکنش‌ها', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 2, visible: true }, { id: 'transactions', label: 'تراکنش‌ها', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 2, visible: true },
{ id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-add', route: 'wallet', order: 3, visible: true }, { id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-add', route: 'wallet', order: 3, visible: true },
@@ -118,6 +119,88 @@ export function createDefaultKhanegiUiConfig() {
export type UiConfigBase = ReturnType<typeof createDefaultKhanegiUiConfig> export type UiConfigBase = ReturnType<typeof createDefaultKhanegiUiConfig>
/** نوار پایین موبایل فقط ۴ جایگاه دارد (خانه جدا در FAB وسط است) */
export const MAX_BOTTOM_NAV_SLOTS = 4
export type KhanegiNavItem = UiConfigBase['app']['navigation']['sidebar'][number]
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T
}
export function getKhanegiNavSample() {
const { navigation } = createDefaultKhanegiUiConfig().app
return {
sidebar: cloneJson(navigation.sidebar),
bottom: cloneJson(navigation.bottom),
sidebarReferral: cloneJson(navigation.sidebarReferral),
}
}
/** آیتم‌های سمپل را نگه می‌دارد؛ لیبل/وضعیت نسخه‌ها روی همان id overlay می‌شود */
export function mergeNavItemsFromSample(
sample: KhanegiNavItem[],
stored: KhanegiNavItem[] | undefined,
maxVisible?: number,
): KhanegiNavItem[] {
const sampleById = new Map(sample.map((item) => [item.id, item]))
const storedList = Array.isArray(stored) ? stored : []
const kept: KhanegiNavItem[] = []
const seen = new Set<string>()
for (const prev of storedList) {
const base = sampleById.get(prev.id)
if (!base || seen.has(prev.id)) continue
seen.add(prev.id)
kept.push({
...base,
label: prev.label || base.label,
icon: prev.icon || base.icon,
visible: typeof prev.visible === 'boolean' ? prev.visible : base.visible,
query: prev.query ?? base.query,
})
}
for (const item of sample) {
if (seen.has(item.id)) continue
kept.push({ ...item })
}
if (typeof maxVisible === 'number') {
let visibleCount = 0
for (const item of kept) {
if (!item.visible) continue
visibleCount += 1
if (visibleCount > maxVisible) item.visible = false
}
}
return kept.map((item, index) => ({ ...item, order: index + 1 }))
}
export function applyKhanegiNavSample(app: { navigation?: any } | null | undefined) {
if (!app || typeof app !== 'object') return
const sample = getKhanegiNavSample()
if (!app.navigation || typeof app.navigation !== 'object') {
app.navigation = {
type: 'both',
sidebar: sample.sidebar,
bottom: sample.bottom,
sidebarReferral: sample.sidebarReferral,
}
return
}
app.navigation.sidebar = mergeNavItemsFromSample(sample.sidebar, app.navigation.sidebar)
app.navigation.bottom = mergeNavItemsFromSample(
sample.bottom.slice(0, MAX_BOTTOM_NAV_SLOTS),
app.navigation.bottom,
MAX_BOTTOM_NAV_SLOTS,
).slice(0, MAX_BOTTOM_NAV_SLOTS)
if (!app.navigation.sidebarReferral || typeof app.navigation.sidebarReferral !== 'object') {
app.navigation.sidebarReferral = sample.sidebarReferral
}
}
export type UiConfig = UiConfigBase & { export type UiConfig = UiConfigBase & {
/** Immutable factory look — set once; never overwrite on normal save */ /** Immutable factory look — set once; never overwrite on normal save */
defaultSnapshot?: { defaultSnapshot?: {
@@ -173,6 +256,9 @@ export function normalizeKhanegiUiConfigForClient<T extends Record<string, any>>
if (app.navigation.sidebarReferral?.route === 'products') { if (app.navigation.sidebarReferral?.route === 'products') {
app.navigation.sidebarReferral.route = 'buyProduct' app.navigation.sidebarReferral.route = 'buyProduct'
} }
applyKhanegiNavSample(app)
} else {
applyKhanegiNavSample(app)
} }
} }
+100 -4
View File
@@ -2,12 +2,14 @@
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import { ArrowRightOutlined } from '@ant-design/icons-vue' import { ArrowRightOutlined, HolderOutlined } from '@ant-design/icons-vue'
import api from '@/api/http' import api from '@/api/http'
import VersionPageTabs from '@/components/VersionPageTabs.vue' import VersionPageTabs from '@/components/VersionPageTabs.vue'
import { import {
applyKhanegiNavSample,
cloneKhanegiAppSnapshot, cloneKhanegiAppSnapshot,
createDefaultKhanegiUiConfig, createDefaultKhanegiUiConfig,
MAX_BOTTOM_NAV_SLOTS,
normalizeKhanegiUiConfigForClient, normalizeKhanegiUiConfigForClient,
} from '@/config/defaultKhanegiUiConfig' } from '@/config/defaultKhanegiUiConfig'
import { import {
@@ -37,6 +39,7 @@ const advancedJson = ref('')
const jsonError = ref('') const jsonError = ref('')
const selectedPreset = ref<KhanegiPresetId>('navy-gold') const selectedPreset = ref<KhanegiPresetId>('navy-gold')
const previewMode = ref<'dark' | 'light'>('dark') const previewMode = ref<'dark' | 'light'>('dark')
const bottomDragFrom = ref<number | null>(null)
const config = reactive(createDefaultKhanegiUiConfig()) as ReturnType< const config = reactive(createDefaultKhanegiUiConfig()) as ReturnType<
typeof createDefaultKhanegiUiConfig typeof createDefaultKhanegiUiConfig
@@ -44,6 +47,57 @@ const config = reactive(createDefaultKhanegiUiConfig()) as ReturnType<
defaultSnapshot?: ReturnType<typeof cloneKhanegiAppSnapshot> defaultSnapshot?: ReturnType<typeof cloneKhanegiAppSnapshot>
} }
function syncBottomNavOrder() {
config.app.navigation.bottom.forEach((item, index) => {
item.order = index + 1
})
}
function applySharedNavSample() {
applyKhanegiNavSample(config.app)
if (config.defaultSnapshot?.app) applyKhanegiNavSample(config.defaultSnapshot.app)
syncBottomNavOrder()
}
const visibleBottomCount = computed(
() => config.app.navigation.bottom.filter((item) => item.visible).length,
)
function onBottomVisibleChange(item: { visible: boolean }, checked: boolean) {
if (checked && visibleBottomCount.value >= MAX_BOTTOM_NAV_SLOTS && !item.visible) {
message.warning('منوی پایین فقط ۴ جایگاه دارد')
return
}
item.visible = checked
}
function onBottomDragStart(index: number, e: DragEvent) {
bottomDragFrom.value = index
e.dataTransfer?.setData('text/plain', String(index))
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
}
function onBottomDragOver(index: number, e: DragEvent) {
e.preventDefault()
const from = bottomDragFrom.value
if (from == null || from === index) return
const list = config.app.navigation.bottom
const [moved] = list.splice(from, 1)
list.splice(index, 0, moved)
bottomDragFrom.value = index
syncBottomNavOrder()
}
function onBottomDrop() {
bottomDragFrom.value = null
syncBottomNavOrder()
}
function onBottomDragEnd() {
bottomDragFrom.value = null
syncBottomNavOrder()
}
const hasDefaultSnapshot = computed(() => Boolean(config.defaultSnapshot)) const hasDefaultSnapshot = computed(() => Boolean(config.defaultSnapshot))
const isCustom = computed(() => selectedPreset.value === 'custom') const isCustom = computed(() => selectedPreset.value === 'custom')
@@ -129,6 +183,7 @@ function applyAdvancedJson() {
if (keepSnapshot && !config.defaultSnapshot) { if (keepSnapshot && !config.defaultSnapshot) {
config.defaultSnapshot = keepSnapshot config.defaultSnapshot = keepSnapshot
} }
applySharedNavSample()
ensureSchemes() ensureSchemes()
selectedPreset.value = resolvePresetId(config) selectedPreset.value = resolvePresetId(config)
jsonError.value = '' jsonError.value = ''
@@ -180,6 +235,7 @@ async function load() {
Object.assign(config, createDefaultKhanegiUiConfig()) Object.assign(config, createDefaultKhanegiUiConfig())
} }
} }
applySharedNavSample()
ensureSchemes() ensureSchemes()
if (!config.defaultSnapshot) { if (!config.defaultSnapshot) {
config.defaultSnapshot = cloneKhanegiAppSnapshot(config) config.defaultSnapshot = cloneKhanegiAppSnapshot(config)
@@ -285,6 +341,7 @@ function resetToDefault() {
const snap = config.defaultSnapshot! const snap = config.defaultSnapshot!
config.app = JSON.parse(JSON.stringify(snap.app)) config.app = JSON.parse(JSON.stringify(snap.app))
config.pages = JSON.parse(JSON.stringify(snap.pages || {})) config.pages = JSON.parse(JSON.stringify(snap.pages || {}))
applySharedNavSample()
ensureSchemes() ensureSchemes()
selectedPreset.value = resolvePresetId(config) selectedPreset.value = resolvePresetId(config)
previewMode.value = config.app.theme.mode === 'light' ? 'light' : 'dark' previewMode.value = config.app.theme.mode === 'light' ? 'light' : 'dark'
@@ -523,8 +580,30 @@ onMounted(load)
</section> </section>
<section class="form-section"> <section class="form-section">
<h3>منوی پایین موبایل</h3> <h3>منوی پایین موبایل</h3>
<div v-for="item in config.app.navigation.bottom" :key="item.id" class="nav-row"> <p class="page-sub" style="margin-bottom: 12px">
<a-switch v-model:checked="item.visible" /> ۴ جایگاه ثابت — بیشتر در نوار پایین جا نمی‌شود. با دستگیره، ترتیب را عوض کنید.
</p>
<div
v-for="(item, index) in config.app.navigation.bottom"
:key="item.id"
class="nav-row"
:class="{ 'is-dragging': bottomDragFrom === index }"
@dragover.prevent="onBottomDragOver(index, $event)"
@drop.prevent="onBottomDrop"
>
<span
class="drag-handle"
draggable="true"
title="جابه‌جایی"
@dragstart="onBottomDragStart(index, $event)"
@dragend="onBottomDragEnd"
>
<HolderOutlined />
</span>
<a-switch
:checked="item.visible"
@change="(v: boolean) => onBottomVisibleChange(item, v)"
/>
<a-input v-model:value="item.label" /> <a-input v-model:value="item.label" />
<span class="nav-route" dir="ltr">{{ item.route }}</span> <span class="nav-route" dir="ltr">{{ item.route }}</span>
</div> </div>
@@ -807,12 +886,29 @@ onMounted(load)
.nav-row { .nav-row {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto auto 1fr auto;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
margin-bottom: 10px; margin-bottom: 10px;
} }
.nav-row.is-dragging {
opacity: 0.55;
}
.drag-handle {
display: inline-flex;
align-items: center;
cursor: grab;
color: var(--ink-soft);
padding: 4px;
touch-action: none;
}
.drag-handle:active {
cursor: grabbing;
}
.nav-route { .nav-route {
font-size: 12px; font-size: 12px;
color: var(--ink-soft); color: var(--ink-soft);