Show percent progress bar during long Excel imports.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 17:45:08 +03:30
co-authored by Cursor
parent c629729a9a
commit 07bdc4a3b6
2 changed files with 88 additions and 8 deletions
+31 -6
View File
@@ -25,6 +25,8 @@ const mapping = reactive<Record<string, string>>({});
const submitting = ref(false);
const phase = ref<'idle' | 'preview' | 'import'>('idle');
const progressLabel = ref('');
/** درصد ۰–۱۰۰؛ در مرحلهٔ خواندن فایل null */
const progressPct = ref<number | null>(null);
const requiredKeys = computed(
() => preview.value?.fields.filter((f) => f.required).map((f) => f.key) ?? [],
@@ -41,6 +43,9 @@ const busyMessage = computed(() => {
}
return 'در حال خواندن و بررسی فایل اکسل…';
});
const busyProgress = computed(() =>
phase.value === 'import' ? progressPct.value : null,
);
function pick() {
if (props.disabled || submitting.value) return;
@@ -53,6 +58,7 @@ function reset() {
submitting.value = false;
phase.value = 'idle';
progressLabel.value = '';
progressPct.value = null;
for (const key of Object.keys(mapping)) delete mapping[key];
}
@@ -112,9 +118,21 @@ async function postImportChunk(opts: {
return data;
}
function updateImportProgress(processed: number, total: number) {
if (total <= 0) {
progressPct.value = null;
progressLabel.value = 'در حال ورود اطلاعات از اکسل…';
return;
}
const pct = Math.max(0, Math.min(100, Math.round((processed / total) * 100)));
progressPct.value = pct;
progressLabel.value = `ورود فایل: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`;
}
async function runChunkedImport(file: File, map?: Record<string, string>) {
phase.value = 'import';
progressLabel.value = 'در حال ورود اطلاعات از اکسل…';
progressLabel.value = 'در حال آماده‌سازی و خواندن فایل…';
progressPct.value = 0;
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
@@ -134,9 +152,7 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
const total = part.total ?? 0;
const processed = part.processed ?? 0;
if (total > 0) {
progressLabel.value = `ورود چندمرحله‌ای: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`;
}
updateImportProgress(processed, total);
// خطای سراسری (مثلاً سقف ردیف) — ادامه نده
if (
@@ -145,7 +161,10 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
break;
}
if (!part.hasMore || !part.sessionId) break;
if (!part.hasMore || !part.sessionId) {
if (total > 0) updateImportProgress(total, total);
break;
}
sessionId = part.sessionId;
offset = part.nextOffset ?? processed;
}
@@ -228,6 +247,7 @@ async function confirmMapping() {
phase.value = 'idle';
submitting.value = false;
progressLabel.value = '';
progressPct.value = null;
emit('settled');
}
}
@@ -247,7 +267,12 @@ async function confirmMapping() {
/>
</span>
<UiBusyOverlay :open="busyOpen" :message="busyMessage" nest />
<UiBusyOverlay
:open="busyOpen"
:message="busyMessage"
:progress="busyProgress"
nest
/>
<UiModal :open="!!preview" title="تطبیق ستون‌های اکسل" @close="closeMapper">
<p class="lead">
+57 -2
View File
@@ -1,16 +1,31 @@
<script setup lang="ts">
withDefaults(
import { computed } from 'vue';
import { toFaDigits } from '@/lib/jalali';
const props = withDefaults(
defineProps<{
open: boolean;
message?: string;
/** وقتی true باشد روی مودال هم دیده می‌شود */
nest?: boolean;
/** درصد پیشرفت ۰ تا ۱۰۰؛ اگر null باشد نوار نشان داده نمی‌شود */
progress?: number | null;
}>(),
{
message: 'لطفاً صبر کنید…',
nest: false,
progress: null,
},
);
const showBar = computed(
() => props.progress != null && Number.isFinite(props.progress),
);
const pct = computed(() => {
if (!showBar.value) return 0;
return Math.max(0, Math.min(100, Math.round(props.progress!)));
});
const pctLabel = computed(() => toFaDigits(String(pct.value)));
</script>
<template>
@@ -22,10 +37,19 @@ withDefaults(
role="status"
aria-live="polite"
aria-busy="true"
:aria-valuenow="showBar ? pct : undefined"
aria-valuemin="0"
aria-valuemax="100"
>
<div class="card">
<span class="spinner" aria-hidden="true" />
<p>{{ message }}</p>
<div v-if="showBar" class="progress-wrap">
<div class="progress-track" aria-hidden="true">
<div class="progress-fill" :style="{ width: `${pct}%` }" />
</div>
<strong class="progress-pct">{{ pctLabel }}٪</strong>
</div>
</div>
</div>
</Teleport>
@@ -51,7 +75,8 @@ withDefaults(
display: grid;
justify-items: center;
gap: 0.85rem;
min-width: min(18rem, 100%);
min-width: min(20rem, 100%);
max-width: 22rem;
padding: 1.35rem 1.5rem;
border-radius: 16px;
background: #fff;
@@ -67,6 +92,36 @@ withDefaults(
line-height: 1.6;
}
.progress-wrap {
display: grid;
gap: 0.45rem;
width: 100%;
justify-items: center;
}
.progress-track {
width: 100%;
height: 8px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #0d9488, #14b8a6);
transition: width 0.25s ease;
}
.progress-pct {
font-size: 1.15rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: #0f766e;
letter-spacing: 0.02em;
}
.spinner {
width: 28px;
height: 28px;