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:
@@ -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));
|
||||
|
||||
@@ -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: 'هشتگ' },
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user