feat(property): replace polling autosave with change-driven debounce and compact status indicator
deploy-chabokan / deploy (push) Canceled after 0s
deploy-chabokan / deploy (push) Canceled after 0s
Autosave previously ran on a fixed setInterval regardless of activity. It now debounces a save shortly after the user stops editing, with a max-wait safety net for long uninterrupted typing sessions, so it only does work when there are actual changes. Also replaces the bulky bottom-right status popup with a very small fixed pill (idle/pending/saving/saved/error) that stays visible while scrolling.
This commit is contained in:
@@ -48,7 +48,15 @@ const autoSaveIntervalSeconds = ref(60);
|
||||
const lastSavedTime = ref(null);
|
||||
const isAutoSaving = ref(false);
|
||||
const isDirty = ref(false);
|
||||
let autoSaveTimer = null;
|
||||
const autoSaveFailed = ref(false);
|
||||
|
||||
// Change-driven auto-save: instead of polling on a fixed interval regardless of
|
||||
// activity, we debounce a save shortly after the user stops typing (cheap - only
|
||||
// ever one pending timer, reset on each change) and fall back to a max-wait timer
|
||||
// so long, uninterrupted editing sessions still get saved periodically.
|
||||
const AUTOSAVE_DEBOUNCE_MS = 2500;
|
||||
let autoSaveDebounceTimer = null;
|
||||
let autoSaveMaxWaitTimer = null;
|
||||
|
||||
// Registration Date Fields (Jalali: Year, Month, Day)
|
||||
const regYear = ref('');
|
||||
@@ -157,6 +165,40 @@ const computedPropertyName = computed(() => {
|
||||
return '';
|
||||
});
|
||||
|
||||
// Auto-save indicator status: derived, no extra reactive bookkeeping needed
|
||||
const autoSaveStatus = computed(() => {
|
||||
if (isAutoSaving.value || isSaving.value) return 'saving';
|
||||
if (autoSaveFailed.value) return 'error';
|
||||
if (isDirty.value) return 'pending';
|
||||
if (lastSavedTime.value) return 'saved';
|
||||
return 'idle';
|
||||
});
|
||||
|
||||
const AUTOSAVE_STATUS_META = {
|
||||
saving: { label: 'در حال ذخیره', icon: 'pi pi-spin pi-sync', dot: 'bg-primary-500', badge: 'bg-primary-50/95 dark:bg-primary-950/70 border-primary-200 dark:border-primary-800 text-primary-700 dark:text-primary-300' },
|
||||
pending: { label: 'تغییرات ذخیرهنشده', icon: 'pi pi-circle-fill', dot: 'bg-amber-500 animate-pulse', badge: 'bg-amber-50/95 dark:bg-amber-950/70 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300' },
|
||||
saved: { label: 'ذخیره شد', icon: 'pi pi-check-circle', dot: 'bg-emerald-500', badge: 'bg-surface-0/95 dark:bg-surface-900/95 border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-300' },
|
||||
error: { label: 'خطا در ذخیره', icon: 'pi pi-exclamation-triangle', dot: 'bg-red-500', badge: 'bg-red-50/95 dark:bg-red-950/70 border-red-200 dark:border-red-800 text-red-600 dark:text-red-300' },
|
||||
idle: { label: 'ذخیره خودکار', icon: 'pi pi-circle', dot: 'bg-surface-400', badge: 'bg-surface-0/80 dark:bg-surface-900/80 border-surface-200 dark:border-surface-700 text-surface-400 dark:text-surface-500' }
|
||||
};
|
||||
|
||||
const autoSaveMeta = computed(() => AUTOSAVE_STATUS_META[autoSaveStatus.value]);
|
||||
|
||||
const autoSaveTooltip = computed(() => {
|
||||
switch (autoSaveStatus.value) {
|
||||
case 'saving':
|
||||
return 'در حال ذخیره خودکار تغییرات...';
|
||||
case 'pending':
|
||||
return 'تغییراتی ثبت شده که بهزودی بهصورت خودکار ذخیره میشود.';
|
||||
case 'error':
|
||||
return 'ذخیره خودکار ناموفق بود. لطفاً بهصورت دستی ذخیره کنید.';
|
||||
case 'saved':
|
||||
return lastSavedTime.value ? `آخرین ذخیره خودکار: ${lastSavedTime.value}` : 'ذخیره شد';
|
||||
default:
|
||||
return 'ذخیره خودکار فعال است';
|
||||
}
|
||||
});
|
||||
|
||||
// Dong options with fractional description
|
||||
const dongOptions = [
|
||||
{ value: 1, label: '۱ دانگ', fraction: '۱⁏۶' },
|
||||
@@ -339,6 +381,7 @@ watch(
|
||||
() => {
|
||||
if (!pageLoading.value) {
|
||||
isDirty.value = true;
|
||||
scheduleAutoSave();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
@@ -443,10 +486,31 @@ const getStatusSeverity = (st) => {
|
||||
return valid.includes(st.color) ? st.color : 'secondary';
|
||||
};
|
||||
|
||||
const initAutoSaveTimer = () => {
|
||||
if (autoSaveTimer) clearInterval(autoSaveTimer);
|
||||
if (autoSaveEnabled.value && autoSaveIntervalSeconds.value > 0) {
|
||||
autoSaveTimer = setInterval(performAutoSave, autoSaveIntervalSeconds.value * 1000);
|
||||
const clearAutoSaveTimers = () => {
|
||||
if (autoSaveDebounceTimer) {
|
||||
clearTimeout(autoSaveDebounceTimer);
|
||||
autoSaveDebounceTimer = null;
|
||||
}
|
||||
if (autoSaveMaxWaitTimer) {
|
||||
clearTimeout(autoSaveMaxWaitTimer);
|
||||
autoSaveMaxWaitTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const triggerAutoSave = () => {
|
||||
clearAutoSaveTimers();
|
||||
performAutoSave();
|
||||
};
|
||||
|
||||
const scheduleAutoSave = () => {
|
||||
if (!autoSaveEnabled.value || pageLoading.value) return;
|
||||
|
||||
if (autoSaveDebounceTimer) clearTimeout(autoSaveDebounceTimer);
|
||||
autoSaveDebounceTimer = setTimeout(triggerAutoSave, AUTOSAVE_DEBOUNCE_MS);
|
||||
|
||||
if (!autoSaveMaxWaitTimer) {
|
||||
const maxWaitMs = Math.max(AUTOSAVE_DEBOUNCE_MS, (autoSaveIntervalSeconds.value || 60) * 1000);
|
||||
autoSaveMaxWaitTimer = setTimeout(triggerAutoSave, maxWaitMs);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -482,9 +546,13 @@ const performAutoSave = async () => {
|
||||
const status = await PropertyService.updateProperty(property.value._id, payload);
|
||||
if (status === 'success') {
|
||||
isDirty.value = false;
|
||||
autoSaveFailed.value = false;
|
||||
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
|
||||
} else {
|
||||
autoSaveFailed.value = true;
|
||||
}
|
||||
} catch (e) {
|
||||
autoSaveFailed.value = true;
|
||||
console.warn('Auto-save background exception:', e);
|
||||
} finally {
|
||||
isAutoSaving.value = false;
|
||||
@@ -502,8 +570,11 @@ const performAutoSave = async () => {
|
||||
};
|
||||
localStorage.setItem('bm_property_draft', JSON.stringify(draft));
|
||||
isDirty.value = false;
|
||||
autoSaveFailed.value = false;
|
||||
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
autoSaveFailed.value = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -610,12 +681,11 @@ onBeforeMount(async () => {
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
isDirty.value = false;
|
||||
initAutoSaveTimer();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (autoSaveTimer) clearInterval(autoSaveTimer);
|
||||
clearAutoSaveTimers();
|
||||
if (geoCodeDebounceTimeout) clearTimeout(geoCodeDebounceTimeout);
|
||||
});
|
||||
|
||||
@@ -674,7 +744,9 @@ const saveProperty = async () => {
|
||||
|
||||
if (status === 'success') {
|
||||
isDirty.value = false;
|
||||
autoSaveFailed.value = false;
|
||||
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
|
||||
clearAutoSaveTimers();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'موفقیتآمیز',
|
||||
@@ -1153,27 +1225,16 @@ const saveProperty = async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Auto-Save Status Popup (Bottom-Right) -->
|
||||
<!-- Tiny Fixed Auto-Save Indicator (Bottom-Right, stays visible while scrolling) -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="autoSaveEnabled && (lastSavedTime || isAutoSaving)"
|
||||
class="fixed bottom-6 right-6 z-50 flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-surface-0/95 dark:bg-surface-900/95 backdrop-blur-md shadow-xl border border-surface-200 dark:border-surface-700 select-none text-xs text-surface-700 dark:text-surface-200 animate-fade-in"
|
||||
style="box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1);"
|
||||
v-if="autoSaveEnabled && !pageLoading"
|
||||
class="fixed bottom-4 right-4 z-50 flex items-center gap-1.5 h-6 px-2 rounded-full border shadow-sm backdrop-blur-md select-none transition-colors duration-300"
|
||||
:class="autoSaveMeta.badge"
|
||||
:title="autoSaveTooltip"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="isAutoSaving" class="flex items-center text-primary gap-1.5 font-semibold">
|
||||
<i class="pi pi-spin pi-sync text-sm"></i>
|
||||
<span>در حال ذخیره خودکار...</span>
|
||||
</span>
|
||||
<span v-else class="flex items-center text-emerald-600 dark:text-emerald-400 gap-1.5 font-semibold">
|
||||
<i class="pi pi-check-circle text-sm"></i>
|
||||
<span>آخرین ذخیره: {{ lastSavedTime }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="w-px h-3.5 bg-surface-200 dark:bg-surface-700"></span>
|
||||
<span class="text-[10px] text-surface-400 font-mono" :title="`بازه زمانی ذخیره خودکار: ${autoSaveIntervalSeconds} ثانیه`">
|
||||
{{ autoSaveIntervalSeconds }}s
|
||||
</span>
|
||||
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="autoSaveMeta.dot"></span>
|
||||
<span class="text-[10px] font-medium leading-none whitespace-nowrap">{{ autoSaveMeta.label }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user