Parse large Excel imports asynchronously to avoid gateway 502 timeouts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 18:14:17 +03:30
co-authored by Cursor
parent d3fb92a5e2
commit ac57b8ac33
5 changed files with 270 additions and 60 deletions
+52 -12
View File
@@ -2,7 +2,11 @@
import { computed, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type { ExcelPreviewResult, ImportContactsResult } from '@mento/shared';
import type {
ExcelImportPrepareStatus,
ExcelPreviewResult,
ImportContactsResult,
} from '@mento/shared';
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
const props = defineProps<{
@@ -131,6 +135,10 @@ function updateImportProgress(processed: number, total: number) {
progressLabel.value = `ورود فایل: ${processed.toLocaleString('fa-IR')} از ${total.toLocaleString('fa-IR')} ردیف`;
}
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
async function postPrepare(file: File, map?: Record<string, string>) {
if (!props.preparePath) {
throw new Error('preparePath تنظیم نشده');
@@ -140,16 +148,43 @@ async function postPrepare(file: File, map?: Record<string, string>) {
if (map && Object.values(map).some(Boolean)) {
form.append('mapping', JSON.stringify(map));
}
// فقط آپلود — پارس روی سرور ناهمگام است؛ تایم‌اوت کوتاه کافی است
const { data } = await api.post<ImportContactsResult>(props.preparePath, form, {
headers: { 'Content-Type': undefined },
timeout: 10 * 60 * 1000,
timeout: 3 * 60 * 1000,
});
return data;
}
async function waitPrepareReady(sessionId: string) {
if (!props.preparePath) throw new Error('preparePath تنظیم نشده');
const statusUrl = `${props.preparePath}/${sessionId}`;
const started = Date.now();
const maxMs = 15 * 60 * 1000;
while (Date.now() - started < maxMs) {
const { data } = await api.get<ExcelImportPrepareStatus>(statusUrl, {
timeout: 30_000,
});
if (data.status === 'ready') {
progressLabel.value = `فایل آماده شد — ${data.total.toLocaleString('fa-IR')} ردیف`;
return data;
}
if (data.status === 'error') {
throw new Error(data.error || 'آماده‌سازی فایل اکسل ناموفق بود');
}
const parsed = data.parsed || 0;
progressPct.value = null;
progressLabel.value = parsed
? `در حال خواندن فایل روی سرور… ${parsed.toLocaleString('fa-IR')} ردیف`
: 'در حال خواندن فایل روی سرور… لطفاً صبر کنید';
await sleep(1200);
}
throw new Error('آماده‌سازی فایل بیش از حد طول کشید. دوباره تلاش کنید.');
}
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: [] };
@@ -160,19 +195,24 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
if (props.preparePath) {
const prep = await postPrepare(file, map);
acc = mergeImportResults(acc, prep);
if (
prep.errors?.some((e) => e.row === 1 && /بیش از .* ردیف|خواندن فایل/.test(e.message))
) {
return acc;
}
if (!prep.sessionId) {
if (!prep.total) return acc;
throw new Error(prep.errors[0]?.message || 'آماده‌سازی فایل ناموفق بود');
}
sessionId = prep.sessionId;
updateImportProgress(0, prep.total ?? 0);
if (!prep.hasMore && (prep.total ?? 0) === 0) return acc;
progressLabel.value = 'در حال خواندن فایل روی سرور…';
const ready = await waitPrepareReady(sessionId);
if (!ready.total) {
return {
...acc,
total: 0,
processed: 0,
hasMore: false,
errors: ready.error
? [{ row: 1, message: ready.error }]
: [{ row: 1, message: 'هیچ ردیف داده‌ای در فایل نیست' }],
};
}
updateImportProgress(0, ready.total);
}
while (guard < maxChunks) {