Files
CRM_Montzar/apps/web/src/components/ExcelImportFlow.vue
T
MahdiandCursor 4abab55a6b feat(web): migrate CRM screens to Ant Design Vue with central theme
Adopt ant-design-vue and a shared antdTheme for leads, pipeline, invoices, and shared form controls under strict single-page governance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-18 19:39:59 +03:30

536 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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,
type ClientExcelReadOptions,
} from '@/lib/excel-client';
import {
Col as ACol,
Flex as AFlex,
Modal as AModal,
Progress as AProgress,
Row as ARow,
Select as ASelect,
Spin as ASpin,
Typography,
Upload as AUpload,
} from 'ant-design-vue';
import type { UploadProps } from 'ant-design-vue';
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
const props = defineProps<{
previewPath: string;
importPath: string;
/** اگر باشد: ابتدا فقط فایل خوانده می‌شود، بعد تکه‌تکه وارد می‌شود (برای فایل‌های خیلی بزرگ) */
preparePath?: string;
/**
* ایمپورت مرورگرگرا: اکسل در مرورگر خوانده می‌شود و فقط JSON کوچک به API می‌رود.
* برای فایل‌های ده‌ها هزار ردیفی (جلوگیری از ۵۰۲ nginx).
*/
rowsImportPath?: string;
clientAliases?: Record<string, string>;
clientFields?: ExcelFieldOption[];
/** تشخیص خروجی بدون‌هدر (مثل فعالیت دیدار) */
clientReadOptions?: ClientExcelReadOptions;
disabled?: boolean;
}>();
const useClientImport = computed(
() =>
Boolean(
props.rowsImportPath &&
props.clientAliases &&
props.clientFields?.length,
),
);
const emit = defineEmits<{
start: [];
done: [result: ImportContactsResult];
fail: [message: string];
settled: [];
}>();
const pending = ref<File | null>(null);
const preview = ref<ExcelPreviewResult | null>(null);
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) ?? [],
);
const mappedFields = computed(() => new Set(Object.values(mapping).filter(Boolean)));
const canImport = computed(() => requiredKeys.value.every((key) => mappedFields.value.has(key)));
const busyOpen = computed(() => submitting.value && phase.value !== 'idle');
const busyMessage = computed(() => {
if (phase.value === 'import') {
return progressLabel.value || 'در حال ورود اطلاعات از اکسل…';
}
return 'در حال خواندن و بررسی فایل اکسل…';
});
const busyProgress = computed(() =>
phase.value === 'import' ? progressPct.value : null,
);
function reset() {
pending.value = null;
preview.value = null;
submitting.value = false;
phase.value = 'idle';
progressLabel.value = '';
progressPct.value = null;
for (const key of Object.keys(mapping)) delete mapping[key];
}
function closeMapper() {
if (submitting.value) return;
reset();
emit('settled');
}
function setColumnField(header: string, field: string) {
if (field) {
for (const [col, current] of Object.entries(mapping)) {
if (col !== header && current === field) mapping[col] = '';
}
}
mapping[header] = field;
}
function mergeImportResults(
acc: ImportContactsResult,
part: ImportContactsResult,
): ImportContactsResult {
const errors = [...acc.errors, ...(part.errors ?? [])];
return {
created: acc.created + (part.created ?? 0),
skipped: acc.skipped + (part.skipped ?? 0),
errors: errors.slice(0, 200),
boardId: part.boardId ?? acc.boardId,
boards: part.boards?.length ? part.boards : acc.boards,
sessionId: part.sessionId,
total: part.total ?? acc.total,
processed: part.processed ?? acc.processed,
hasMore: part.hasMore,
nextOffset: part.nextOffset,
};
}
async function postImportChunk(opts: {
file?: File;
map?: Record<string, string>;
sessionId?: string;
offset?: number;
}) {
const form = new FormData();
if (opts.file) form.append('file', opts.file);
if (opts.map && Object.values(opts.map).some(Boolean)) {
form.append('mapping', JSON.stringify(opts.map));
}
if (opts.sessionId) form.append('sessionId', opts.sessionId);
if (opts.offset != null) form.append('offset', String(opts.offset));
form.append('limit', '250');
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
headers: { 'Content-Type': undefined },
// خواندن فایل‌های خیلی بزرگ در مرحلهٔ اول ممکن است طول بکشد
timeout: opts.file ? 10 * 60 * 1000 : 3 * 60 * 1000,
});
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')} ردیف`;
}
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 تنظیم نشده');
}
const form = new FormData();
form.append('file', file);
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: 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 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,
props.clientReadOptions,
);
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;
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
let sessionId: string | undefined;
let offset = 0;
let guard = 0;
const maxChunks = 2000;
if (props.preparePath) {
const prep = await postPrepare(file, map);
if (!prep.sessionId) {
throw new Error(prep.errors[0]?.message || 'آماده‌سازی فایل ناموفق بود');
}
sessionId = prep.sessionId;
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) {
guard += 1;
const part = await postImportChunk({
file: sessionId ? undefined : file,
map: sessionId ? undefined : map,
sessionId,
offset: sessionId ? offset : undefined,
});
acc = mergeImportResults(acc, part);
const total = part.total ?? 0;
const processed = part.processed ?? 0;
updateImportProgress(processed, total);
if (
part.errors?.some((e) => e.row === 1 && /بیش از .* ردیف/.test(e.message))
) {
break;
}
if (!part.hasMore || !part.sessionId) {
if (total > 0) updateImportProgress(total, total);
break;
}
sessionId = part.sessionId;
offset = part.nextOffset ?? processed;
}
if (guard >= maxChunks && acc.hasMore) {
acc.errors = [
...acc.errors,
{
row: 1,
message: 'ورود به‌خاطر تعداد مراحل زیاد متوقف شد. از جایی که مانده ادامه دهید یا فایل را تقسیم کنید.',
},
];
}
return acc;
}
function apiMessage(e: unknown, fallback: string) {
if (!axios.isAxiosError(e)) {
return e instanceof Error && e.message ? e.message : fallback;
}
if (e.code === 'ECONNABORTED' || /timeout/i.test(e.message)) {
return 'مهلت درخواست تمام شد — فایل خیلی بزرگ است یا سرور دیر جواب داد. دوباره تلاش کنید.';
}
if (!e.response) {
return 'ارتباط با سرور برقرار نشد. اتصال را بررسی کنید و دوباره تلاش کنید.';
}
const status = e.response.status;
const data = e.response.data as { message?: string | string[] } | string | undefined;
let msg: string | undefined;
if (typeof data === 'string' && data.trim() && !data.trim().startsWith('<')) {
msg = data.trim().slice(0, 280);
} else if (data && typeof data === 'object') {
const m = data.message;
msg = Array.isArray(m) ? m.join('، ') : m;
}
if (msg) return msg;
if (status === 413) return 'حجم فایل بیش از حد مجاز سرور است.';
if (status === 502 || status === 504) {
return 'سرور میانی قطع شد (معمولاً به‌خاطر حجم/زمان فایل). چند دقیقه بعد دوباره تلاش کنید.';
}
return `${fallback} (کد ${status})`;
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
void handleExcelFile(file as File);
return false;
};
async function handleExcelFile(file: File) {
if (props.disabled || submitting.value) return;
emit('start');
submitting.value = true;
phase.value = 'preview';
try {
let data: ExcelPreviewResult;
if (useClientImport.value && props.clientAliases && props.clientFields) {
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
data = await previewExcelClient(
file,
props.clientAliases,
props.clientFields,
props.clientReadOptions,
);
} 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));
reset();
emit('settled');
} catch (impErr) {
emit('fail', apiMessage(impErr, 'ایمپورت ناموفق بود'));
reset();
emit('settled');
}
return;
}
pending.value = file;
preview.value = data;
for (const col of data.columns) {
mapping[col.header] = data.suggested[col.header] ?? '';
}
phase.value = 'idle';
submitting.value = false;
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
reset();
emit('settled');
}
}
async function confirmMapping() {
if (!pending.value || !canImport.value || submitting.value) return;
emit('start');
submitting.value = true;
phase.value = 'import';
try {
emit('done', await runChunkedImport(pending.value, { ...mapping }));
reset();
emit('settled');
} catch (e) {
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
phase.value = 'idle';
submitting.value = false;
progressLabel.value = '';
progressPct.value = null;
emit('settled');
}
}
</script>
<template>
<a-upload
:show-upload-list="false"
:disabled="disabled || submitting"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
:before-upload="beforeUpload"
@click.stop
>
<slot :loading="submitting" :disabled="disabled || submitting" />
</a-upload>
<a-modal
:open="busyOpen"
:footer="null"
:closable="false"
:mask-closable="false"
:keyboard="false"
centered
>
<a-flex vertical align="center" :gap="16">
<a-spin />
<a-typography-text>{{ busyMessage }}</a-typography-text>
<a-progress
v-if="busyProgress != null"
:percent="busyProgress"
style="width: 100%"
/>
</a-flex>
</a-modal>
<a-modal
:open="!!preview"
title="تطبیق ستون‌های اکسل"
:confirm-loading="submitting"
ok-text="ورود فایل"
cancel-text="انصراف"
:ok-button-props="{ disabled: !canImport }"
@ok="confirmMapping"
@cancel="closeMapper"
>
<a-typography-paragraph type="secondary">
ستون‌های این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
</a-typography-paragraph>
<a-typography-paragraph v-if="preview?.largeFile" type="secondary">
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود به‌صورت چندمرحله‌ای انجام می‌شود.
</a-typography-paragraph>
<a-flex vertical :gap="12">
<a-row
v-for="col in preview?.columns ?? []"
:key="col.header"
:gutter="[16, 8]"
align="middle"
>
<a-col :xs="24" :md="12">
<a-typography-text strong>{{ col.header }}</a-typography-text>
<div v-if="col.samples.length">
<a-typography-text type="secondary">
نمونه: {{ col.samples.join('، ') }}
</a-typography-text>
</div>
</a-col>
<a-col :xs="24" :md="12">
<a-select
:value="mapping[col.header] ?? ''"
:disabled="submitting"
style="width: 100%"
:options="[
{ value: '', label: 'نادیده بگیر' },
...(preview?.fields ?? []).map((field) => ({
value: field.key,
label: field.required ? `${field.label} *` : field.label,
})),
]"
@update:value="(v) => setColumnField(col.header, String(v ?? ''))"
/>
</a-col>
</a-row>
</a-flex>
<a-typography-text v-if="!canImport" type="danger">
فیلدهای ستاره‌دار را حتماً به یک ستون وصل کنید.
</a-typography-text>
</a-modal>
</template>