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:
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user