feat(tahesab): show remaining-time estimate in sync progress modal

Estimate ETA from processed throughput for customer export and document sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-03 20:24:44 +03:30
co-authored by Cursor
parent 11f60f205a
commit 7c95f1f9d6
+98 -1
View File
@@ -38,6 +38,7 @@
<div class="cip-track__fill" :style="{ width: store.percent + '%' }" /> <div class="cip-track__fill" :style="{ width: store.percent + '%' }" />
</div> </div>
<div class="cip-percent">{{ store.percent }}٪</div> <div class="cip-percent">{{ store.percent }}٪</div>
<div v-if="etaLabel" class="cip-eta">{{ etaLabel }}</div>
<div class="cip-current"> <div class="cip-current">
<a-spin size="small" /> <a-spin size="small" />
<span class="cip-current__name">{{ store.state.current || '...' }}</span> <span class="cip-current__name">{{ store.state.current || '...' }}</span>
@@ -127,6 +128,7 @@
</template> </template>
<template v-else>{{ store.state.processed }} از {{ store.state.total }}</template> <template v-else>{{ store.state.processed }} از {{ store.state.total }}</template>
</span> </span>
<span v-if="store.active && etaLabel" class="cip-mini__eta">{{ etaLabel }}</span>
</span> </span>
<span class="cip-mini__bar"> <span class="cip-mini__bar">
<span class="cip-mini__bar-fill" :style="{ width: store.percent + '%' }" /> <span class="cip-mini__bar-fill" :style="{ width: store.percent + '%' }" />
@@ -137,12 +139,87 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Icon from '@core/components/BaseIcon/src/Icon.vue' import Icon from '@core/components/BaseIcon/src/Icon.vue'
import { useTahesabDocSyncStore } from '@/store/tahesabDocSyncStore' import { useTahesabDocSyncStore } from '@/store/tahesabDocSyncStore'
const store = useTahesabDocSyncStore() const store = useTahesabDocSyncStore()
const runStartedAt = ref<number | null>(null)
const runStartProcessed = ref(0)
const tick = ref(Date.now())
let tickTimer: ReturnType<typeof setInterval> | null = null
function formatEta(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return 'به‌زودی'
const rounded = Math.ceil(seconds)
if (rounded < 60) return `حدود ${rounded} ثانیه`
const mins = Math.ceil(rounded / 60)
if (mins < 60) return `حدود ${mins} دقیقه`
const hours = Math.floor(mins / 60)
const remMins = mins % 60
if (remMins === 0) return `حدود ${hours} ساعت`
return `حدود ${hours} ساعت و ${remMins} دقیقه`
}
function resetEtaClock() {
runStartedAt.value = Date.now()
runStartProcessed.value = store.state.processed
}
function startTick() {
if (tickTimer) return
tickTimer = setInterval(() => {
tick.value = Date.now()
}, 1000)
}
function stopTick() {
if (!tickTimer) return
clearInterval(tickTimer)
tickTimer = null
}
watch(
() => store.active,
(active) => {
if (active) {
resetEtaClock()
startTick()
return
}
runStartedAt.value = null
stopTick()
},
{ immediate: true },
)
watch(
() => store.state.stage,
() => {
if (store.active) resetEtaClock()
},
)
const etaLabel = computed(() => {
void tick.value
if (!store.active) return ''
const { processed, total } = store.state
const remaining = Math.max(0, total - processed)
if (remaining <= 0) return 'در حال اتمام…'
if (!runStartedAt.value) return ''
const deltaProcessed = processed - runStartProcessed.value
const elapsedSec = (Date.now() - runStartedAt.value) / 1000
if (deltaProcessed < 2 || elapsedSec < 4) {
return 'در حال محاسبه زمان باقی‌مانده…'
}
const secPerItem = elapsedSec / deltaProcessed
return `تخمین باقی‌مانده: ${formatEta(secPerItem * remaining)}`
})
const stage = computed(() => store.stage) const stage = computed(() => store.stage)
function outcomeMap() { function outcomeMap() {
@@ -217,6 +294,10 @@ const ringStyle = computed(() => ({
onMounted(() => { onMounted(() => {
void store.hydrate() void store.hydrate()
}) })
onUnmounted(() => {
stopTick()
})
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -360,6 +441,15 @@ onMounted(() => {
color: var(--admin-text-secondary); color: var(--admin-text-secondary);
} }
.cip-eta {
align-self: flex-end;
margin-top: 4px;
font-size: 11px;
font-weight: 600;
color: var(--admin-text-muted);
text-align: right;
}
.cip-current { .cip-current {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -622,6 +712,13 @@ onMounted(() => {
white-space: nowrap; white-space: nowrap;
} }
&__eta {
font-size: 10px;
font-weight: 600;
color: var(--admin-text-muted);
white-space: nowrap;
}
&__bar { &__bar {
position: absolute; position: absolute;
left: 14px; left: 14px;