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 lastSavedTime = ref(null);
|
||||||
const isAutoSaving = ref(false);
|
const isAutoSaving = ref(false);
|
||||||
const isDirty = 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)
|
// Registration Date Fields (Jalali: Year, Month, Day)
|
||||||
const regYear = ref('');
|
const regYear = ref('');
|
||||||
@@ -157,6 +165,40 @@ const computedPropertyName = computed(() => {
|
|||||||
return '';
|
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
|
// Dong options with fractional description
|
||||||
const dongOptions = [
|
const dongOptions = [
|
||||||
{ value: 1, label: '۱ دانگ', fraction: '۱⁏۶' },
|
{ value: 1, label: '۱ دانگ', fraction: '۱⁏۶' },
|
||||||
@@ -339,6 +381,7 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
if (!pageLoading.value) {
|
if (!pageLoading.value) {
|
||||||
isDirty.value = true;
|
isDirty.value = true;
|
||||||
|
scheduleAutoSave();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
@@ -443,10 +486,31 @@ const getStatusSeverity = (st) => {
|
|||||||
return valid.includes(st.color) ? st.color : 'secondary';
|
return valid.includes(st.color) ? st.color : 'secondary';
|
||||||
};
|
};
|
||||||
|
|
||||||
const initAutoSaveTimer = () => {
|
const clearAutoSaveTimers = () => {
|
||||||
if (autoSaveTimer) clearInterval(autoSaveTimer);
|
if (autoSaveDebounceTimer) {
|
||||||
if (autoSaveEnabled.value && autoSaveIntervalSeconds.value > 0) {
|
clearTimeout(autoSaveDebounceTimer);
|
||||||
autoSaveTimer = setInterval(performAutoSave, autoSaveIntervalSeconds.value * 1000);
|
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);
|
const status = await PropertyService.updateProperty(property.value._id, payload);
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
isDirty.value = false;
|
isDirty.value = false;
|
||||||
|
autoSaveFailed.value = false;
|
||||||
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
|
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
|
||||||
|
} else {
|
||||||
|
autoSaveFailed.value = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
autoSaveFailed.value = true;
|
||||||
console.warn('Auto-save background exception:', e);
|
console.warn('Auto-save background exception:', e);
|
||||||
} finally {
|
} finally {
|
||||||
isAutoSaving.value = false;
|
isAutoSaving.value = false;
|
||||||
@@ -502,8 +570,11 @@ const performAutoSave = async () => {
|
|||||||
};
|
};
|
||||||
localStorage.setItem('bm_property_draft', JSON.stringify(draft));
|
localStorage.setItem('bm_property_draft', JSON.stringify(draft));
|
||||||
isDirty.value = false;
|
isDirty.value = false;
|
||||||
|
autoSaveFailed.value = false;
|
||||||
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
|
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
|
||||||
} catch (e) {}
|
} catch (e) {
|
||||||
|
autoSaveFailed.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -610,12 +681,11 @@ onBeforeMount(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
pageLoading.value = false;
|
pageLoading.value = false;
|
||||||
isDirty.value = false;
|
isDirty.value = false;
|
||||||
initAutoSaveTimer();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (autoSaveTimer) clearInterval(autoSaveTimer);
|
clearAutoSaveTimers();
|
||||||
if (geoCodeDebounceTimeout) clearTimeout(geoCodeDebounceTimeout);
|
if (geoCodeDebounceTimeout) clearTimeout(geoCodeDebounceTimeout);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -674,7 +744,9 @@ const saveProperty = async () => {
|
|||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
isDirty.value = false;
|
isDirty.value = false;
|
||||||
|
autoSaveFailed.value = false;
|
||||||
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
|
lastSavedTime.value = new Date().toLocaleTimeString('fa-IR');
|
||||||
|
clearAutoSaveTimers();
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: 'موفقیتآمیز',
|
summary: 'موفقیتآمیز',
|
||||||
@@ -1153,27 +1225,16 @@ const saveProperty = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Auto-Save Status Popup (Bottom-Right) -->
|
<!-- Tiny Fixed Auto-Save Indicator (Bottom-Right, stays visible while scrolling) -->
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div
|
<div
|
||||||
v-if="autoSaveEnabled && (lastSavedTime || isAutoSaving)"
|
v-if="autoSaveEnabled && !pageLoading"
|
||||||
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"
|
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"
|
||||||
style="box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.15), 0 8px 10px -6px rgba(0, 0, 0, 0.1);"
|
:class="autoSaveMeta.badge"
|
||||||
|
:title="autoSaveTooltip"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<span class="w-1.5 h-1.5 rounded-full shrink-0" :class="autoSaveMeta.dot"></span>
|
||||||
<span v-if="isAutoSaving" class="flex items-center text-primary gap-1.5 font-semibold">
|
<span class="text-[10px] font-medium leading-none whitespace-nowrap">{{ autoSaveMeta.label }}</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user