feat(ui): selectable bottom-nav slots and sidebar drag reorder
Let each of the four bottom slots pick a menu from the shared catalog, and allow reordering the sidebar the same way. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -178,6 +178,64 @@ export function mergeNavItemsFromSample(
|
|||||||
return kept.map((item, index) => ({ ...item, order: index + 1 }))
|
return kept.map((item, index) => ({ ...item, order: index + 1 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** گزینههای قابل انتخاب برای هر جایگاه منوی پایین (سایدبار + خرید و فروش) */
|
||||||
|
export function getKhanegiBottomNavChoices(): KhanegiNavItem[] {
|
||||||
|
const sample = getKhanegiNavSample()
|
||||||
|
const byId = new Map<string, KhanegiNavItem>()
|
||||||
|
for (const item of sample.bottom) byId.set(item.id, cloneJson(item))
|
||||||
|
for (const item of sample.sidebar) {
|
||||||
|
if (item.id === 'home') continue // خانه همان FAB وسط است
|
||||||
|
if (!byId.has(item.id)) byId.set(item.id, cloneJson(item))
|
||||||
|
}
|
||||||
|
return Array.from(byId.values())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** دقیقاً ۴ جایگاه؛ انتخاب ذخیرهشده از کاتالوگ، بقیه از پیشفرض پر میشود */
|
||||||
|
export function mergeBottomNavSlots(
|
||||||
|
stored: KhanegiNavItem[] | undefined,
|
||||||
|
): KhanegiNavItem[] {
|
||||||
|
const choices = getKhanegiBottomNavChoices()
|
||||||
|
const defaults = getKhanegiNavSample().bottom.slice(0, MAX_BOTTOM_NAV_SLOTS)
|
||||||
|
const byId = new Map(choices.map((item) => [item.id, item]))
|
||||||
|
const slots: KhanegiNavItem[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const storedList = Array.isArray(stored) ? stored : []
|
||||||
|
|
||||||
|
for (const prev of storedList) {
|
||||||
|
if (slots.length >= MAX_BOTTOM_NAV_SLOTS) break
|
||||||
|
if (!prev?.id || seen.has(prev.id)) continue
|
||||||
|
const base = byId.get(prev.id)
|
||||||
|
if (!base) continue
|
||||||
|
seen.add(prev.id)
|
||||||
|
slots.push({
|
||||||
|
...base,
|
||||||
|
label: prev.label || base.label,
|
||||||
|
icon: prev.icon || base.icon,
|
||||||
|
visible: typeof prev.visible === 'boolean' ? prev.visible : true,
|
||||||
|
query: prev.query ?? base.query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of defaults) {
|
||||||
|
if (slots.length >= MAX_BOTTOM_NAV_SLOTS) break
|
||||||
|
if (seen.has(item.id)) continue
|
||||||
|
seen.add(item.id)
|
||||||
|
slots.push({ ...item, visible: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of choices) {
|
||||||
|
if (slots.length >= MAX_BOTTOM_NAV_SLOTS) break
|
||||||
|
if (seen.has(item.id)) continue
|
||||||
|
seen.add(item.id)
|
||||||
|
slots.push({ ...item, visible: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots.slice(0, MAX_BOTTOM_NAV_SLOTS).map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
order: index + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export function applyKhanegiNavSample(app: { navigation?: any } | null | undefined) {
|
export function applyKhanegiNavSample(app: { navigation?: any } | null | undefined) {
|
||||||
if (!app || typeof app !== 'object') return
|
if (!app || typeof app !== 'object') return
|
||||||
const sample = getKhanegiNavSample()
|
const sample = getKhanegiNavSample()
|
||||||
@@ -191,11 +249,7 @@ export function applyKhanegiNavSample(app: { navigation?: any } | null | undefin
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
app.navigation.sidebar = mergeNavItemsFromSample(sample.sidebar, app.navigation.sidebar)
|
app.navigation.sidebar = mergeNavItemsFromSample(sample.sidebar, app.navigation.sidebar)
|
||||||
app.navigation.bottom = mergeNavItemsFromSample(
|
app.navigation.bottom = mergeBottomNavSlots(app.navigation.bottom)
|
||||||
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') {
|
if (!app.navigation.sidebarReferral || typeof app.navigation.sidebarReferral !== 'object') {
|
||||||
app.navigation.sidebarReferral = sample.sidebarReferral
|
app.navigation.sidebarReferral = sample.sidebarReferral
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-6
@@ -9,8 +9,10 @@ import {
|
|||||||
applyKhanegiNavSample,
|
applyKhanegiNavSample,
|
||||||
cloneKhanegiAppSnapshot,
|
cloneKhanegiAppSnapshot,
|
||||||
createDefaultKhanegiUiConfig,
|
createDefaultKhanegiUiConfig,
|
||||||
|
getKhanegiBottomNavChoices,
|
||||||
MAX_BOTTOM_NAV_SLOTS,
|
MAX_BOTTOM_NAV_SLOTS,
|
||||||
normalizeKhanegiUiConfigForClient,
|
normalizeKhanegiUiConfigForClient,
|
||||||
|
type KhanegiNavItem,
|
||||||
} from '@/config/defaultKhanegiUiConfig'
|
} from '@/config/defaultKhanegiUiConfig'
|
||||||
import {
|
import {
|
||||||
applyKhanegiPreset,
|
applyKhanegiPreset,
|
||||||
@@ -40,6 +42,7 @@ 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 bottomDragFrom = ref<number | null>(null)
|
||||||
|
const sidebarDragFrom = ref<number | null>(null)
|
||||||
|
|
||||||
const config = reactive(createDefaultKhanegiUiConfig()) as ReturnType<
|
const config = reactive(createDefaultKhanegiUiConfig()) as ReturnType<
|
||||||
typeof createDefaultKhanegiUiConfig
|
typeof createDefaultKhanegiUiConfig
|
||||||
@@ -53,16 +56,62 @@ function syncBottomNavOrder() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncSidebarNavOrder() {
|
||||||
|
config.app.navigation.sidebar.forEach((item, index) => {
|
||||||
|
item.order = index + 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function applySharedNavSample() {
|
function applySharedNavSample() {
|
||||||
applyKhanegiNavSample(config.app)
|
applyKhanegiNavSample(config.app)
|
||||||
if (config.defaultSnapshot?.app) applyKhanegiNavSample(config.defaultSnapshot.app)
|
if (config.defaultSnapshot?.app) applyKhanegiNavSample(config.defaultSnapshot.app)
|
||||||
|
syncSidebarNavOrder()
|
||||||
syncBottomNavOrder()
|
syncBottomNavOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bottomNavChoices = computed(() => getKhanegiBottomNavChoices())
|
||||||
|
|
||||||
const visibleBottomCount = computed(
|
const visibleBottomCount = computed(
|
||||||
() => config.app.navigation.bottom.filter((item) => item.visible).length,
|
() => config.app.navigation.bottom.filter((item) => item.visible).length,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function bottomSlotOptions(slotIndex: number) {
|
||||||
|
const used = new Set(
|
||||||
|
config.app.navigation.bottom
|
||||||
|
.map((item, i) => (i === slotIndex ? null : item.id))
|
||||||
|
.filter(Boolean) as string[],
|
||||||
|
)
|
||||||
|
return bottomNavChoices.value.map((choice) => ({
|
||||||
|
value: choice.id,
|
||||||
|
label: `${choice.label} (${choice.route})`,
|
||||||
|
disabled: used.has(choice.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBottomSlotMenuChange(slotIndex: number, choiceId: string) {
|
||||||
|
const choice = bottomNavChoices.value.find((item) => item.id === choiceId)
|
||||||
|
if (!choice) return
|
||||||
|
const list = config.app.navigation.bottom
|
||||||
|
const current = list[slotIndex]
|
||||||
|
if (!current || current.id === choiceId) return
|
||||||
|
|
||||||
|
const otherIndex = list.findIndex((item, i) => i !== slotIndex && item.id === choiceId)
|
||||||
|
if (otherIndex >= 0) {
|
||||||
|
list[otherIndex] = {
|
||||||
|
...current,
|
||||||
|
order: otherIndex + 1,
|
||||||
|
} as KhanegiNavItem
|
||||||
|
}
|
||||||
|
|
||||||
|
list[slotIndex] = {
|
||||||
|
...choice,
|
||||||
|
label: choice.label,
|
||||||
|
visible: current.visible,
|
||||||
|
order: slotIndex + 1,
|
||||||
|
} as KhanegiNavItem
|
||||||
|
syncBottomNavOrder()
|
||||||
|
}
|
||||||
|
|
||||||
function onBottomVisibleChange(item: { visible: boolean }, checked: boolean) {
|
function onBottomVisibleChange(item: { visible: boolean }, checked: boolean) {
|
||||||
if (checked && visibleBottomCount.value >= MAX_BOTTOM_NAV_SLOTS && !item.visible) {
|
if (checked && visibleBottomCount.value >= MAX_BOTTOM_NAV_SLOTS && !item.visible) {
|
||||||
message.warning('منوی پایین فقط ۴ جایگاه دارد')
|
message.warning('منوی پایین فقط ۴ جایگاه دارد')
|
||||||
@@ -98,6 +147,33 @@ function onBottomDragEnd() {
|
|||||||
syncBottomNavOrder()
|
syncBottomNavOrder()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSidebarDragStart(index: number, e: DragEvent) {
|
||||||
|
sidebarDragFrom.value = index
|
||||||
|
e.dataTransfer?.setData('text/plain', String(index))
|
||||||
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSidebarDragOver(index: number, e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const from = sidebarDragFrom.value
|
||||||
|
if (from == null || from === index) return
|
||||||
|
const list = config.app.navigation.sidebar
|
||||||
|
const [moved] = list.splice(from, 1)
|
||||||
|
list.splice(index, 0, moved)
|
||||||
|
sidebarDragFrom.value = index
|
||||||
|
syncSidebarNavOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSidebarDrop() {
|
||||||
|
sidebarDragFrom.value = null
|
||||||
|
syncSidebarNavOrder()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSidebarDragEnd() {
|
||||||
|
sidebarDragFrom.value = null
|
||||||
|
syncSidebarNavOrder()
|
||||||
|
}
|
||||||
|
|
||||||
const hasDefaultSnapshot = computed(() => Boolean(config.defaultSnapshot))
|
const hasDefaultSnapshot = computed(() => Boolean(config.defaultSnapshot))
|
||||||
const isCustom = computed(() => selectedPreset.value === 'custom')
|
const isCustom = computed(() => selectedPreset.value === 'custom')
|
||||||
|
|
||||||
@@ -572,7 +648,26 @@ onMounted(load)
|
|||||||
<a-tab-pane key="nav" tab="منوها">
|
<a-tab-pane key="nav" tab="منوها">
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<h3>منوی کناری</h3>
|
<h3>منوی کناری</h3>
|
||||||
<div v-for="item in config.app.navigation.sidebar" :key="item.id" class="nav-row">
|
<p class="page-sub" style="margin-bottom: 12px">
|
||||||
|
با دستگیره ترتیب آیتمهای سایدبار را عوض کنید.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in config.app.navigation.sidebar"
|
||||||
|
:key="item.id"
|
||||||
|
class="nav-row sidebar-row"
|
||||||
|
:class="{ 'is-dragging': sidebarDragFrom === index }"
|
||||||
|
@dragover.prevent="onSidebarDragOver(index, $event)"
|
||||||
|
@drop.prevent="onSidebarDrop"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="drag-handle"
|
||||||
|
draggable="true"
|
||||||
|
title="جابهجایی"
|
||||||
|
@dragstart="onSidebarDragStart(index, $event)"
|
||||||
|
@dragend="onSidebarDragEnd"
|
||||||
|
>
|
||||||
|
<HolderOutlined />
|
||||||
|
</span>
|
||||||
<a-switch v-model:checked="item.visible" />
|
<a-switch v-model:checked="item.visible" />
|
||||||
<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>
|
||||||
@@ -581,12 +676,12 @@ onMounted(load)
|
|||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
<h3>منوی پایین موبایل</h3>
|
<h3>منوی پایین موبایل</h3>
|
||||||
<p class="page-sub" style="margin-bottom: 12px">
|
<p class="page-sub" style="margin-bottom: 12px">
|
||||||
۴ جایگاه ثابت — بیشتر در نوار پایین جا نمیشود. با دستگیره، ترتیب را عوض کنید.
|
۴ جایگاه ثابت — برای هر جایگاه از سلکت انتخاب کنید کدام منو باشد. با دستگیره ترتیب را عوض کنید.
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in config.app.navigation.bottom"
|
v-for="(item, index) in config.app.navigation.bottom"
|
||||||
:key="item.id"
|
:key="`${index}-${item.id}`"
|
||||||
class="nav-row"
|
class="nav-row bottom-slot-row"
|
||||||
:class="{ 'is-dragging': bottomDragFrom === index }"
|
:class="{ 'is-dragging': bottomDragFrom === index }"
|
||||||
@dragover.prevent="onBottomDragOver(index, $event)"
|
@dragover.prevent="onBottomDragOver(index, $event)"
|
||||||
@drop.prevent="onBottomDrop"
|
@drop.prevent="onBottomDrop"
|
||||||
@@ -604,8 +699,13 @@ onMounted(load)
|
|||||||
:checked="item.visible"
|
:checked="item.visible"
|
||||||
@change="(v: boolean) => onBottomVisibleChange(item, v)"
|
@change="(v: boolean) => onBottomVisibleChange(item, v)"
|
||||||
/>
|
/>
|
||||||
<a-input v-model:value="item.label" />
|
<a-select
|
||||||
<span class="nav-route" dir="ltr">{{ item.route }}</span>
|
:value="item.id"
|
||||||
|
class="bottom-slot-select"
|
||||||
|
:options="bottomSlotOptions(index)"
|
||||||
|
@change="(v: string) => onBottomSlotMenuChange(index, v)"
|
||||||
|
/>
|
||||||
|
<a-input v-model:value="item.label" placeholder="برچسب نمایش" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-section">
|
<section class="form-section">
|
||||||
@@ -892,6 +992,18 @@ onMounted(load)
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-row {
|
||||||
|
grid-template-columns: auto auto 1fr auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-slot-row {
|
||||||
|
grid-template-columns: auto auto minmax(160px, 1.1fr) minmax(120px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-slot-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-row.is-dragging {
|
.nav-row.is-dragging {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user