feat: add search settings page in dashboard, sidebar menu, and MongoDB search integration
deploy-chabokan / deploy (push) Canceled after 0s

This commit is contained in:
2026-08-19 05:50:18 +03:30
parent cf7e1ee45c
commit 859a86f1a5
8 changed files with 581 additions and 20 deletions
+37 -2
View File
@@ -3,8 +3,10 @@ import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref, watch, computed } from 'vue';
import { PropertyService } from '@/service/PropertyService';
import { SettingService } from '@/service/SettingService';
import { useRouter, useRoute } from 'vue-router';
import Chip from 'primevue/chip';
import Tag from 'primevue/tag';
import InputIcon from 'primevue/inputicon';
import IconField from 'primevue/iconfield';
import SelectButton from 'primevue/selectbutton';
@@ -34,6 +36,7 @@ const selectedPropertyId = ref(null);
const mapRef = ref(null);
const splitMapRef = ref(null);
const propertyCardRefs = ref({});
const searchMethod = ref(null); // 'typesense' | 'mongo'
const filters = ref({
global: { value: null, matchMode: FilterMatchMode.CONTAINS }
@@ -47,7 +50,18 @@ const pagesCount = ref(0);
const DEFAULT_IMAGE = 'https://cloud.mbmanager.ir/images/item-no-image.png';
onMounted(() => {
const loadSearchSetting = async () => {
try {
const setting = await SettingService.getSearchSettingByPage('properties');
searchMethod.value = setting?.searchMethod || 'typesense';
} catch (e) {
console.warn('Failed to load search setting, defaulting to typesense', e);
searchMethod.value = 'typesense';
}
};
onMounted(async () => {
await loadSearchSetting();
loadData();
});
@@ -112,7 +126,15 @@ const onPageChange = (event) => {
const loadData = async () => {
try {
const response = await PropertyService.searchProperties(route.query);
if (searchMethod.value === null) {
await loadSearchSetting();
}
let response;
if (searchMethod.value === 'mongo') {
response = await PropertyService.searchPropertiesMongo(route.query);
} else {
response = await PropertyService.searchProperties(route.query);
}
properties.value = response.properties || response.belongings || response || [];
totalCount.value = response.totalCount || (Array.isArray(response) ? response.length : 0);
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
@@ -122,6 +144,7 @@ const loadData = async () => {
}
};
const loading = ref(false);
const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
@@ -262,6 +285,18 @@ const onCardHover = (propertyId) => {
</IconField>
<Chip v-if="searchPhrase" class="mx-1" :label="searchPhrase" removable @remove="handleRemoveSearch" />
<!-- Search Engine Mode Tag -->
<Tag
v-if="searchMethod"
:value="searchMethod === 'mongo' ? 'جستجو: MongoDB' : 'جستجو: Typesense'"
:severity="searchMethod === 'mongo' ? 'success' : 'info'"
:icon="searchMethod === 'mongo' ? 'pi pi-database' : 'pi pi-bolt'"
class="cursor-pointer text-xs"
v-tooltip.bottom="'کلیک برای تغییر موتور جستجو در تنظیمات'"
@click="router.push('/settings/search-settings')"
/>
<!-- View Mode Selector -->
<SelectButton
v-model="viewMode"
+334
View File
@@ -0,0 +1,334 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { SettingService } from '@/service/SettingService';
import { useRouter } from 'vue-router';
const toast = useToast();
const confirm = useConfirm();
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const settings = ref([]);
const methodOptions = [
{ label: 'Typesense', value: 'typesense', icon: 'pi pi-bolt', class: 'text-amber-500' },
{ label: 'MongoDB', value: 'mongo', icon: 'pi pi-database', class: 'text-emerald-500' }
];
onMounted(async () => {
await loadSettings();
});
const loadSettings = async () => {
loading.value = true;
try {
const data = await SettingService.getSearchSettings();
settings.value = data;
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در بارگذاری تنظیمات جستجو',
life: 3000
});
} finally {
loading.value = false;
}
};
const stats = computed(() => {
const total = settings.value.length;
const mongoCount = settings.value.filter((s) => s.searchMethod === 'mongo').length;
const typesenseCount = settings.value.filter((s) => s.searchMethod === 'typesense').length;
const persistedCount = settings.value.filter((s) => s.isPersisted).length;
return { total, mongoCount, typesenseCount, persistedCount };
});
const updateMethod = async (pageKey, newMethod) => {
try {
const updated = await SettingService.updateSearchSetting(pageKey, newMethod);
const index = settings.value.findIndex((s) => s.pageKey === pageKey);
if (index !== -1) {
settings.value[index] = { ...settings.value[index], ...updated };
}
toast.add({
severity: 'success',
summary: 'بروزرسانی شد',
detail: `روش جستجوی "${updated.title}" به ${newMethod === 'mongo' ? 'MongoDB' : 'Typesense'} تغییر یافت.`,
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در ذخیره‌سازی تنظیمات در پایگاه داده',
life: 3000
});
}
};
const setAllToMethod = async (method) => {
confirm.require({
message: `آیا از تغییر روش جستجوی تمامی بخش‌ها به ${method === 'mongo' ? 'MongoDB' : 'Typesense'} اطمینان دارید؟`,
header: 'تایید تغییر همگانی',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'انصراف', severity: 'secondary', outlined: true },
acceptProps: { label: 'تایید و اعمال', severity: method === 'mongo' ? 'success' : 'warn' },
accept: async () => {
saving.value = true;
try {
const payload = settings.value.map((s) => ({
pageKey: s.pageKey,
searchMethod: method
}));
const updatedList = await SettingService.bulkUpdateSearchSettings(payload);
settings.value = updatedList;
toast.add({
severity: 'success',
summary: 'موفقیت‌آمیز',
detail: `تمامی بخش‌ها با موفقیت به ${method === 'mongo' ? 'MongoDB' : 'Typesense'} تغییر یافتند.`,
life: 4000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'خطا',
detail: 'خطا در بروزرسانی همگانی تنظیمات',
life: 3000
});
} finally {
saving.value = false;
}
}
});
};
const navigateToModule = (routePath) => {
if (routePath) {
router.push(routePath);
}
};
</script>
<template>
<div class="search-settings-page">
<Toast />
<ConfirmDialog />
<!-- ── Header Banner ── -->
<div class="card p-6 mb-6 shadow-sm border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 rounded-2xl">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="w-14 h-14 rounded-2xl bg-primary-100 dark:bg-primary-950 flex items-center justify-center text-primary-600 dark:text-primary-400">
<i class="pi pi-sliders-h text-2xl"></i>
</div>
<div>
<h2 class="text-2xl font-bold m-0 text-surface-900 dark:text-surface-0">تنظیمات موتور جستجو</h2>
<p class="text-surface-500 dark:text-surface-400 m-0 mt-1 text-sm">
انتخاب روش جستجو (MongoDB یا Typesense) برای هر بخش از سیستم به صورت مجزا
</p>
</div>
</div>
<!-- Stats Badges -->
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2 px-3 py-2 rounded-xl bg-surface-100 dark:bg-surface-800 text-surface-700 dark:text-surface-300 text-xs font-semibold">
<i class="pi pi-list text-primary-500"></i>
<span>کل بخش‌ها: {{ stats.total }}</span>
</div>
<div class="flex items-center gap-2 px-3 py-2 rounded-xl bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 text-xs font-semibold">
<i class="pi pi-database text-emerald-500"></i>
<span>MongoDB: {{ stats.mongoCount }}</span>
</div>
<div class="flex items-center gap-2 px-3 py-2 rounded-xl bg-amber-50 dark:bg-amber-950 text-amber-700 dark:text-amber-300 text-xs font-semibold">
<i class="pi pi-bolt text-amber-500"></i>
<span>Typesense: {{ stats.typesenseCount }}</span>
</div>
</div>
</div>
</div>
<!-- ── Quick Actions Toolbar ── -->
<div class="card p-4 mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="flex flex-wrap items-center gap-3">
<Button
label="تنظیم همه روی MongoDB"
icon="pi pi-database"
severity="success"
outlined
size="small"
:disabled="loading || saving"
@click="setAllToMethod('mongo')"
/>
<Button
label="تنظیم همه روی Typesense"
icon="pi pi-bolt"
severity="warn"
outlined
size="small"
:disabled="loading || saving"
@click="setAllToMethod('typesense')"
/>
</div>
<div class="flex items-center gap-2">
<Button
icon="pi pi-refresh"
severity="secondary"
text
rounded
:loading="loading"
v-tooltip.top="'بارگذاری مجدد'"
@click="loadSettings"
/>
</div>
</div>
<!-- ── Settings Cards Grid ── -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div v-for="i in 6" :key="i" class="card p-6">
<Skeleton width="40%" height="1.5rem" class="mb-3" />
<Skeleton width="80%" height="1rem" class="mb-4" />
<Skeleton width="100%" height="2.5rem" />
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="item in settings"
:key="item.pageKey"
class="card p-6 rounded-2xl border transition-all duration-200 hover:shadow-md flex flex-col justify-between"
:class="[
item.searchMethod === 'mongo'
? 'border-emerald-200 dark:border-emerald-900 bg-emerald-50/10'
: 'border-amber-200 dark:border-amber-900 bg-amber-50/10'
]"
>
<div>
<!-- Card Top Header -->
<div class="flex items-start justify-between gap-3 mb-3">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl flex items-center justify-center text-lg"
:class="[
item.searchMethod === 'mongo'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-300'
]"
>
<i :class="item.icon || 'pi pi-file'"></i>
</div>
<div>
<h3 class="text-lg font-bold m-0 text-surface-900 dark:text-surface-0">{{ item.title }}</h3>
<span class="text-xs text-surface-500 font-mono">{{ item.pageKey }}</span>
</div>
</div>
<!-- Persistence Badge -->
<Tag
:value="item.isPersisted ? 'ذخیره در دیتابیس' : 'پیش‌فرض'"
:severity="item.isPersisted ? 'success' : 'secondary'"
class="text-xs"
/>
</div>
<!-- Description -->
<p class="text-sm text-surface-600 dark:text-surface-300 mb-4 leading-relaxed">
{{ item.description }}
</p>
<!-- Endpoints Information -->
<div class="p-3 mb-4 rounded-xl bg-surface-100 dark:bg-surface-800/80 text-xs space-y-1 font-mono">
<div class="flex items-center justify-between">
<span class="text-surface-500">اندپوینت فعال:</span>
<span class="font-bold" :class="item.searchMethod === 'mongo' ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'">
{{ item.searchMethod === 'mongo' ? item.endpointMongo : item.endpointTypesense }}
</span>
</div>
</div>
</div>
<!-- Bottom Selector & Actions -->
<div class="pt-4 border-t border-surface-200 dark:border-surface-700 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="text-xs font-semibold text-surface-600 dark:text-surface-400">موتور جستجو:</span>
<SelectButton
:modelValue="item.searchMethod"
:options="methodOptions"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
size="small"
@update:modelValue="(val) => updateMethod(item.pageKey, val)"
>
<template #option="slotProps">
<div class="flex items-center gap-1.5 px-1 py-0.5 text-xs font-bold">
<i :class="slotProps.option.icon"></i>
<span>{{ slotProps.option.label }}</span>
</div>
</template>
</SelectButton>
</div>
<Button
v-if="item.routePath"
label="مشاهده صفحه"
icon="pi pi-arrow-left"
iconPos="right"
size="small"
severity="secondary"
text
@click="navigateToModule(item.routePath)"
/>
</div>
</div>
</div>
<!-- ── Information Guide ── -->
<div class="card p-6 mt-8 rounded-2xl bg-surface-50 dark:bg-surface-800/50 border border-surface-200 dark:border-surface-700">
<h4 class="text-base font-bold mb-3 flex items-center gap-2 text-surface-800 dark:text-surface-100">
<i class="pi pi-info-circle text-primary-500"></i>
<span>راهنمای روش‌های جستجو</span>
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs leading-relaxed text-surface-600 dark:text-surface-300">
<div class="p-4 rounded-xl bg-surface-0 dark:bg-surface-900 border border-amber-200 dark:border-amber-900/50">
<div class="font-bold text-amber-700 dark:text-amber-400 flex items-center gap-2 mb-1">
<i class="pi pi-bolt"></i>
<span>موتور Typesense</span>
</div>
<p class="m-0">
موتور جستجوی ایندکس‌شده با سرعت فوق‌العاده بالا، تحمل خطای تایپی (Typo Tolerance) و سیستم Faceting پیشرفته. نیازمند فرآیند ایندکس‌سازی داده‌ها است.
</p>
</div>
<div class="p-4 rounded-xl bg-surface-0 dark:bg-surface-900 border border-emerald-200 dark:border-emerald-900/50">
<div class="font-bold text-emerald-700 dark:text-emerald-400 flex items-center gap-2 mb-1">
<i class="pi pi-database"></i>
<span>موتور MongoDB</span>
</div>
<p class="m-0">
جستجوی زنده مستقیم روی پایگاه داده MongoDB با الگوهای عبارت منظم (Regex) و فیلترهای دقیق. بدون نیاز به ایندکس جداگانه و همیشه هماهنگ با آخرین تغییرات پایگاه داده.
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.search-settings-page {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
@@ -3,13 +3,29 @@ import { FilterMatchMode } from '@primevue/core/api';
import { useToast } from 'primevue/usetoast';
import { onMounted, ref, watch } from 'vue';
import { TransactionService } from '@/service/TransactionService';
import { SettingService } from '@/service/SettingService';
import { useRouter, useRoute } from 'vue-router';
import Chip from 'primevue/chip';
import Tag from 'primevue/tag';
import InputIcon from 'primevue/inputicon';
import IconField from 'primevue/iconfield';
import LoadingCheck from '@/components/LoadingCheck.vue';
import Popover from 'primevue/popover';
onMounted(() => {
const searchMethod = ref(null);
const loadSearchSetting = async () => {
try {
const setting = await SettingService.getSearchSettingByPage('transactions');
searchMethod.value = setting?.searchMethod || 'typesense';
} catch (e) {
console.warn('Failed to load search setting for transactions, defaulting to typesense', e);
searchMethod.value = 'typesense';
}
};
onMounted(async () => {
await loadSearchSetting();
loadData();
});
@@ -74,7 +90,7 @@ watch(
{ deep: true }
);
const handleRemoveSearch = () => {
removeFromQuery('searchPhrase'); // Ensure 'searchPhrase' is the correct key
removeFromQuery('query');
searchPhrase.value = '';
};
@@ -105,20 +121,27 @@ const onPageChange = (event) => {
}
};
const loadData = () => {
TransactionService.searchTransactions(route.query)
.then((response) => {
console.log('response', response);
transactions.value = response.transactions || response;
totalCount.value = response.totalCount || response.length || 0;
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
})
.catch((error) => {
toast.add({ severity: 'error', summary: 'Error', detail: 'خطا در دریافت اطلاعات', life: 3000 });
console.error('Error loading data:', error);
});
const loadData = async () => {
try {
if (searchMethod.value === null) {
await loadSearchSetting();
}
let response;
if (searchMethod.value === 'mongo') {
response = await TransactionService.searchTransactionsMongo(route.query);
} else {
response = await TransactionService.searchTransactions(route.query);
}
transactions.value = response.transactions || response;
totalCount.value = response.totalCount || response.length || 0;
pagesCount.value = Math.ceil(totalCount.value / per_page.value);
} catch (error) {
toast.add({ severity: 'error', summary: 'Error', detail: 'خطا در دریافت اطلاعات', life: 3000 });
console.error('Error loading data:', error);
}
};
const loading = ref(false);
const indexingFinished = ref(false);
const indexingMessage = ref('در حال ایندکس...');
@@ -270,15 +293,27 @@ const toggle = (event, data) => {
:globalFilterFields="['type', 'status', 'belongingName', 'createdAt', 'description']"
>
<template #header>
<div class="">
<h4 class="m-0">مدیریت تراکنش‌ها</h4>
<div class="p-input-icon-right w-1/3 mt-2 lg:mt-0">
<InputText v-model="searchPhrase" placeholder="جستجو..." class="w-full" />
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<h4 class="m-0 font-bold">مدیریت تراکنش‌ها</h4>
<Tag
v-if="searchMethod"
:value="searchMethod === 'mongo' ? 'جستجو: MongoDB' : 'جستجو: Typesense'"
:severity="searchMethod === 'mongo' ? 'success' : 'info'"
:icon="searchMethod === 'mongo' ? 'pi pi-database' : 'pi pi-bolt'"
class="cursor-pointer text-xs"
v-tooltip.bottom="'کلیک برای تغییر موتور جستجو در تنظیمات'"
@click="router.push('/settings/search-settings')"
/>
</div>
<div class="p-input-icon-right w-full sm:w-72 mt-2 lg:mt-0">
<InputText v-model="searchPhrase" placeholder="جستجو در تراکنش‌ها..." class="w-full" />
</div>
</div>
<Chip v-if="searchPhrase" class="mx-2 mt-2" :label="searchPhrase" removable @remove="handleRemoveSearch" />
</template>
<Column field="_id" header="شناسه" sortable style="width: 10%"></Column>
<Column field="belongingName" header="ملک" sortable style="width: 15%"></Column>