Import large activity Excel in the browser via JSON chunks to avoid 502.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 18:28:47 +03:30
co-authored by Cursor
parent ac57b8ac33
commit cbfe470f33
11 changed files with 6451 additions and 31 deletions
+86 -7
View File
@@ -3,10 +3,12 @@ import { computed, reactive, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
ExcelFieldOption,
ExcelImportPrepareStatus,
ExcelPreviewResult,
ImportContactsResult,
} from '@mento/shared';
import { previewExcelClient, readMappedRowsClient } from '@/lib/excel-client';
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
const props = defineProps<{
@@ -14,9 +16,25 @@ const props = defineProps<{
importPath: string;
/** اگر باشد: ابتدا فقط فایل خوانده می‌شود، بعد تکه‌تکه وارد می‌شود (برای فایل‌های خیلی بزرگ) */
preparePath?: string;
/**
* ایمپورت مرورگرگرا: اکسل در مرورگر خوانده می‌شود و فقط JSON کوچک به API می‌رود.
* برای فایل‌های ده‌ها هزار ردیفی (جلوگیری از ۵۰۲ nginx).
*/
rowsImportPath?: string;
clientAliases?: Record<string, string>;
clientFields?: ExcelFieldOption[];
disabled?: boolean;
}>();
const useClientImport = computed(
() =>
Boolean(
props.rowsImportPath &&
props.clientAliases &&
props.clientFields?.length,
),
);
const emit = defineEmits<{
start: [];
done: [result: ImportContactsResult];
@@ -182,7 +200,62 @@ async function waitPrepareReady(sessionId: string) {
throw new Error('آماده‌سازی فایل بیش از حد طول کشید. دوباره تلاش کنید.');
}
async function runClientRowsImport(file: File, map?: Record<string, string>) {
if (!props.rowsImportPath || !props.clientAliases) {
throw new Error('پیکربندی ایمپورت مرورگر ناقص است');
}
phase.value = 'import';
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
progressPct.value = 0;
const rows = await readMappedRowsClient(file, props.clientAliases, map);
const total = rows.length;
if (total > 100_000) {
return {
created: 0,
skipped: 0,
errors: [{ row: 1, message: 'فایل بیش از ۱۰۰۰۰۰ ردیف دارد. فایل را تقسیم کنید.' }],
total,
processed: 0,
hasMore: false,
};
}
updateImportProgress(0, total);
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
const chunkSize = 60;
for (let offset = 0; offset < total; offset += chunkSize) {
const slice = rows.slice(offset, offset + chunkSize);
const { data: part } = await api.post<ImportContactsResult>(
props.rowsImportPath,
{
rows: slice,
offset,
total,
sessionId,
},
{ timeout: 120_000 },
);
acc = mergeImportResults(acc, part);
updateImportProgress(part.processed ?? offset + slice.length, total);
if (part.errors?.some((e) => e.row === 1 && /حداکثر|بیش از/.test(e.message))) {
break;
}
sessionId = part.sessionId;
if (!part.hasMore) break;
}
updateImportProgress(total, total);
return acc;
}
async function runChunkedImport(file: File, map?: Record<string, string>) {
if (useClientImport.value) {
return runClientRowsImport(file, map);
}
phase.value = 'import';
progressLabel.value = 'در حال ارسال فایل…';
progressPct.value = 0;
@@ -229,7 +302,6 @@ async function runChunkedImport(file: File, map?: Record<string, string>) {
const processed = part.processed ?? 0;
updateImportProgress(processed, total);
// خطای سراسری (مثلاً سقف ردیف) — ادامه نده
if (
part.errors?.some((e) => e.row === 1 && /بیش از .* ردیف/.test(e.message))
) {
@@ -293,12 +365,19 @@ async function onFile(ev: Event) {
submitting.value = true;
phase.value = 'preview';
try {
const form = new FormData();
form.append('file', file);
const { data } = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
timeout: 5 * 60 * 1000,
});
let data: ExcelPreviewResult;
if (useClientImport.value && props.clientAliases && props.clientFields) {
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
data = await previewExcelClient(file, props.clientAliases, props.clientFields);
} else {
const form = new FormData();
form.append('file', file);
const res = await api.post<ExcelPreviewResult>(props.previewPath, form, {
headers: { 'Content-Type': undefined },
timeout: 5 * 60 * 1000,
});
data = res.data;
}
if (!data.needsMapping) {
try {
emit('done', await runChunkedImport(file));
+48
View File
@@ -0,0 +1,48 @@
import type { ExcelFieldOption } from '@mento/shared';
/** هم‌تراز با aliases سمت API برای ایمپورت فعالیت */
export const ACTIVITY_EXCEL_ALIASES: Record<string, string> = {
_type: '_type',
type: '_type',
نوعفعالیت: 'kind',
عنوانفعالیت: 'title',
عنوان: 'title',
متنفعالیت: 'body',
نتیجهفعالیت: 'result',
مسئولاجرافعالیت: 'assignee',
ثبتکنندهفعالیت: 'creator',
تاریخبرنامهریزیشدهاجرافعالیت: 'dueDate',
زمانبرنامهریزیشدهاجرافعالیت: 'dueTime',
وضعیتاجرایفعالیت: 'status',
تاریخانجامشدنفعالیت: 'doneDate',
زمانانجامشدنفعالیت: 'doneTime',
کدمعاملهفعالیت: 'dealCode',
کدکارتفعالیت: 'cardCode',
اشخاصفعالیت: 'personName',
کداشخاصفعالیت: 'personCode',
موبایلاشخاصفعالیت: 'personMobile',
شرکتهایفعالیت: 'companyName',
کدشرکتهایفعالیت: 'companyCode',
معاملهفعالیت1: 'dealTitle',
کارتفعالیت1: 'cardTitle',
هشتگ: 'hashtag',
};
export const ACTIVITY_EXCEL_FIELDS: ExcelFieldOption[] = [
{ key: 'title', label: 'عنوان فعالیت', required: true },
{ key: 'kind', label: 'نوع فعالیت' },
{ key: 'body', label: 'متن فعالیت' },
{ key: 'result', label: 'نتیجه فعالیت' },
{ key: 'assignee', label: 'مسئول اجرا فعالیت' },
{ key: 'creator', label: 'ثبت کننده فعالیت' },
{ key: 'dueDate', label: 'تاریخ برنامه ریزی شده اجرا فعالیت' },
{ key: 'dueTime', label: 'زمان برنامه ریزی شده اجرا فعالیت' },
{ key: 'status', label: 'وضعیت اجرای فعالیت' },
{ key: 'doneDate', label: 'تاریخ انجام شدن فعالیت' },
{ key: 'doneTime', label: 'زمان انجام شدن فعالیت' },
{ key: 'personName', label: 'اشخاص فعالیت' },
{ key: 'personMobile', label: 'موبایل اشخاص فعالیت' },
{ key: 'companyName', label: 'شرکت های فعالیت' },
{ key: 'dealTitle', label: 'معامله فعالیت_1' },
{ key: 'hashtag', label: 'هشتگ' },
];
+138
View File
@@ -0,0 +1,138 @@
import * as XLSX from 'xlsx';
import type { ExcelFieldOption, ExcelPreviewResult } from '@mento/shared';
function normalizeHeader(raw: string) {
return raw
.trim()
.toLowerCase()
.replace(/[_-]+/g, '')
.replace(/‌/g, '')
.replace(/\s+/g, '')
.replace(/[()]/g, '');
}
function cellToString(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? '' : value.toISOString();
}
return String(value).trim();
}
function resolveFieldKey(
raw: string,
aliases: Record<string, string>,
mapping?: Record<string, string>,
) {
if (mapping) {
return mapping[raw] || mapping[normalizeHeader(raw)] || '';
}
const compact = normalizeHeader(raw);
return aliases[compact] ?? aliases[raw.trim()] ?? '';
}
/** خواندن اکسل در مرورگر — بدون آپلود فایل سنگین به API */
export async function previewExcelClient(
file: File,
aliases: Record<string, string>,
fields: ExcelFieldOption[],
): Promise<ExcelPreviewResult> {
const buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
const sheetName = wb.SheetNames[0];
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
const sheet = wb.Sheets[sheetName]!;
const matrix = XLSX.utils.sheet_to_json<(string | number | boolean | Date | null)[]>(
sheet,
{ header: 1, defval: '', raw: false },
) as unknown[][];
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
const columns: ExcelPreviewResult['columns'] = [];
const suggested: Record<string, string> = {};
const used = new Set<string>();
headerRow.forEach((header, idx) => {
if (!header) return;
const samples: string[] = [];
for (let r = 1; r < Math.min(matrix.length, 6); r += 1) {
const value = cellToString(matrix[r]?.[idx]);
if (value) samples.push(value);
if (samples.length >= 3) break;
}
columns.push({ header, index: idx + 1, samples });
const key = resolveFieldKey(header, aliases);
if (key && !used.has(key)) {
suggested[header] = key;
used.add(key);
}
});
if (!columns.length) throw new Error('ستون عنوان در ردیف اول فایل پیدا نشد');
let rowCount = 0;
for (let r = 1; r < matrix.length; r += 1) {
const row = matrix[r] ?? [];
if (row.some((c) => cellToString(c))) rowCount += 1;
}
const required = fields.filter((f) => f.required).map((f) => f.key);
const mapped = new Set(Object.values(suggested));
const missingRequired = required.some((key) => !mapped.has(key));
const unrecognized = columns.some((col) => !suggested[col.header]);
return {
columns,
fields,
suggested,
needsMapping: unrecognized || missingRequired || !mapped.size,
rowCount,
largeFile: rowCount >= 80,
};
}
export async function readMappedRowsClient(
file: File,
aliases: Record<string, string>,
mapping?: Record<string, string>,
): Promise<Record<string, string>[]> {
const buffer = await file.arrayBuffer();
const wb = XLSX.read(buffer, { type: 'array', cellDates: true });
const sheetName = wb.SheetNames[0];
if (!sheetName) throw new Error('شیت اکسل پیدا نشد');
const sheet = wb.Sheets[sheetName]!;
const matrix = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
header: 1,
defval: '',
raw: false,
}) as unknown[][];
const headerRow = (matrix[0] ?? []).map((c) => cellToString(c));
const colMap = new Map<number, string>();
headerRow.forEach((header, idx) => {
if (!header) return;
const key = resolveFieldKey(header, aliases, mapping);
if (key) colMap.set(idx, key);
});
if (!colMap.size) {
throw new Error('ستون‌های فایل شناسایی نشد. ستون‌های فایل را با فیلدهای سیستم تطبیق دهید.');
}
const rows: Record<string, string>[] = [];
for (let r = 1; r < matrix.length; r += 1) {
const line = matrix[r] ?? [];
const item: Record<string, string> = {};
let any = false;
for (const [idx, key] of colMap) {
const value = cellToString(line[idx]);
if (value) any = true;
item[key] = value;
}
if (any) rows.push(item);
}
if (!rows.length) throw new Error('هیچ ردیف داده‌ای در فایل نیست');
return rows;
}
+4 -1
View File
@@ -20,6 +20,7 @@ import {
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import ExcelImportFlow from '@/components/ExcelImportFlow.vue';
import { ACTIVITY_EXCEL_ALIASES, ACTIVITY_EXCEL_FIELDS } from '@/lib/activity-excel';
import ActivityFormView from '@/views/ActivityFormView.vue';
import type {
ActivityDto,
@@ -499,8 +500,10 @@ onUnmounted(() => {
</UiButton>
<ExcelImportFlow
preview-path="/activities/import/preview"
prepare-path="/activities/import/prepare"
import-path="/activities/import"
rows-import-path="/activities/import/rows"
:client-aliases="ACTIVITY_EXCEL_ALIASES"
:client-fields="ACTIVITY_EXCEL_FIELDS"
:disabled="busy"
@start="onImportStart"
@done="onImportDone"