2026-09-19 15:30:31 +03:30
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, reactive, ref } from 'vue';
|
|
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
2026-10-03 18:14:17 +03:30
|
|
|
|
import type {
|
2026-10-03 18:28:47 +03:30
|
|
|
|
ExcelFieldOption,
|
2026-10-03 18:14:17 +03:30
|
|
|
|
ExcelImportPrepareStatus,
|
|
|
|
|
|
ExcelPreviewResult,
|
|
|
|
|
|
ImportContactsResult,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-10-04 10:44:00 +03:30
|
|
|
|
import {
|
|
|
|
|
|
previewExcelClient,
|
|
|
|
|
|
readMappedRowsClient,
|
|
|
|
|
|
type ClientExcelReadOptions,
|
|
|
|
|
|
} from '@/lib/excel-client';
|
2026-10-18 19:39:59 +03:30
|
|
|
|
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;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
previewPath: string;
|
|
|
|
|
|
importPath: string;
|
2026-10-03 17:58:41 +03:30
|
|
|
|
/** اگر باشد: ابتدا فقط فایل خوانده میشود، بعد تکهتکه وارد میشود (برای فایلهای خیلی بزرگ) */
|
|
|
|
|
|
preparePath?: string;
|
2026-10-03 18:28:47 +03:30
|
|
|
|
/**
|
|
|
|
|
|
* ایمپورت مرورگرگرا: اکسل در مرورگر خوانده میشود و فقط JSON کوچک به API میرود.
|
|
|
|
|
|
* برای فایلهای دهها هزار ردیفی (جلوگیری از ۵۰۲ nginx).
|
|
|
|
|
|
*/
|
|
|
|
|
|
rowsImportPath?: string;
|
|
|
|
|
|
clientAliases?: Record<string, string>;
|
|
|
|
|
|
clientFields?: ExcelFieldOption[];
|
2026-10-04 10:44:00 +03:30
|
|
|
|
/** تشخیص خروجی بدونهدر (مثل فعالیت دیدار) */
|
|
|
|
|
|
clientReadOptions?: ClientExcelReadOptions;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
disabled?: boolean;
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
2026-10-03 18:28:47 +03:30
|
|
|
|
const useClientImport = computed(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
Boolean(
|
|
|
|
|
|
props.rowsImportPath &&
|
|
|
|
|
|
props.clientAliases &&
|
|
|
|
|
|
props.clientFields?.length,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-19 15:30:31 +03:30
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
start: [];
|
2026-09-29 14:57:10 +03:30
|
|
|
|
done: [result: ImportContactsResult];
|
|
|
|
|
|
fail: [message: string];
|
2026-09-19 15:30:31 +03:30
|
|
|
|
settled: [];
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
const pending = ref<File | null>(null);
|
|
|
|
|
|
const preview = ref<ExcelPreviewResult | null>(null);
|
|
|
|
|
|
const mapping = reactive<Record<string, string>>({});
|
|
|
|
|
|
const submitting = ref(false);
|
2026-10-03 12:29:11 +03:30
|
|
|
|
const phase = ref<'idle' | 'preview' | 'import'>('idle');
|
2026-10-03 15:01:01 +03:30
|
|
|
|
const progressLabel = ref('');
|
2026-10-03 17:45:08 +03:30
|
|
|
|
/** درصد ۰–۱۰۰؛ در مرحلهٔ خواندن فایل null */
|
|
|
|
|
|
const progressPct = ref<number | null>(null);
|
2026-09-19 15:30:31 +03:30
|
|
|
|
|
|
|
|
|
|
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)));
|
|
|
|
|
|
|
2026-10-03 12:29:11 +03:30
|
|
|
|
const busyOpen = computed(() => submitting.value && phase.value !== 'idle');
|
2026-10-03 15:01:01 +03:30
|
|
|
|
const busyMessage = computed(() => {
|
|
|
|
|
|
if (phase.value === 'import') {
|
|
|
|
|
|
return progressLabel.value || 'در حال ورود اطلاعات از اکسل…';
|
|
|
|
|
|
}
|
|
|
|
|
|
return 'در حال خواندن و بررسی فایل اکسل…';
|
|
|
|
|
|
});
|
2026-10-03 17:45:08 +03:30
|
|
|
|
const busyProgress = computed(() =>
|
|
|
|
|
|
phase.value === 'import' ? progressPct.value : null,
|
|
|
|
|
|
);
|
2026-10-03 12:29:11 +03:30
|
|
|
|
|
2026-09-19 15:30:31 +03:30
|
|
|
|
function reset() {
|
|
|
|
|
|
pending.value = null;
|
|
|
|
|
|
preview.value = null;
|
|
|
|
|
|
submitting.value = false;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
phase.value = 'idle';
|
2026-10-03 15:01:01 +03:30
|
|
|
|
progressLabel.value = '';
|
2026-10-03 17:45:08 +03:30
|
|
|
|
progressPct.value = null;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
for (const key of Object.keys(mapping)) delete mapping[key];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeMapper() {
|
2026-10-03 12:29:11 +03:30
|
|
|
|
if (submitting.value) return;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 15:01:01 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}) {
|
2026-09-19 15:30:31 +03:30
|
|
|
|
const form = new FormData();
|
2026-10-03 15:01:01 +03:30
|
|
|
|
if (opts.file) form.append('file', opts.file);
|
|
|
|
|
|
if (opts.map && Object.values(opts.map).some(Boolean)) {
|
|
|
|
|
|
form.append('mapping', JSON.stringify(opts.map));
|
2026-09-19 15:30:31 +03:30
|
|
|
|
}
|
2026-10-03 15:01:01 +03:30
|
|
|
|
if (opts.sessionId) form.append('sessionId', opts.sessionId);
|
|
|
|
|
|
if (opts.offset != null) form.append('offset', String(opts.offset));
|
2026-10-03 17:42:11 +03:30
|
|
|
|
form.append('limit', '250');
|
2026-10-03 15:01:01 +03:30
|
|
|
|
const { data } = await api.post<ImportContactsResult>(props.importPath, form, {
|
|
|
|
|
|
headers: { 'Content-Type': undefined },
|
2026-10-03 17:42:11 +03:30
|
|
|
|
// خواندن فایلهای خیلی بزرگ در مرحلهٔ اول ممکن است طول بکشد
|
|
|
|
|
|
timeout: opts.file ? 10 * 60 * 1000 : 3 * 60 * 1000,
|
2026-10-03 15:01:01 +03:30
|
|
|
|
});
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 17:45:08 +03:30
|
|
|
|
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')} ردیف`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 18:14:17 +03:30
|
|
|
|
function sleep(ms: number) {
|
|
|
|
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 17:58:41 +03:30
|
|
|
|
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));
|
|
|
|
|
|
}
|
2026-10-03 18:14:17 +03:30
|
|
|
|
// فقط آپلود — پارس روی سرور ناهمگام است؛ تایماوت کوتاه کافی است
|
2026-10-03 17:58:41 +03:30
|
|
|
|
const { data } = await api.post<ImportContactsResult>(props.preparePath, form, {
|
|
|
|
|
|
headers: { 'Content-Type': undefined },
|
2026-10-03 18:14:17 +03:30
|
|
|
|
timeout: 3 * 60 * 1000,
|
2026-10-03 17:58:41 +03:30
|
|
|
|
});
|
|
|
|
|
|
return data;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 18:14:17 +03:30
|
|
|
|
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('آمادهسازی فایل بیش از حد طول کشید. دوباره تلاش کنید.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 18:28:47 +03:30
|
|
|
|
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;
|
|
|
|
|
|
|
2026-10-04 10:44:00 +03:30
|
|
|
|
const rows = await readMappedRowsClient(
|
|
|
|
|
|
file,
|
|
|
|
|
|
props.clientAliases,
|
|
|
|
|
|
map,
|
|
|
|
|
|
props.clientReadOptions,
|
|
|
|
|
|
);
|
2026-10-03 18:28:47 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 15:01:01 +03:30
|
|
|
|
async function runChunkedImport(file: File, map?: Record<string, string>) {
|
2026-10-03 18:28:47 +03:30
|
|
|
|
if (useClientImport.value) {
|
|
|
|
|
|
return runClientRowsImport(file, map);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 15:01:01 +03:30
|
|
|
|
phase.value = 'import';
|
2026-10-03 18:14:17 +03:30
|
|
|
|
progressLabel.value = 'در حال ارسال فایل…';
|
2026-10-03 17:45:08 +03:30
|
|
|
|
progressPct.value = 0;
|
2026-10-03 15:01:01 +03:30
|
|
|
|
|
|
|
|
|
|
let acc: ImportContactsResult = { created: 0, skipped: 0, errors: [] };
|
|
|
|
|
|
let sessionId: string | undefined;
|
|
|
|
|
|
let offset = 0;
|
|
|
|
|
|
let guard = 0;
|
2026-10-03 17:42:11 +03:30
|
|
|
|
const maxChunks = 2000;
|
2026-10-03 15:01:01 +03:30
|
|
|
|
|
2026-10-03 17:58:41 +03:30
|
|
|
|
if (props.preparePath) {
|
|
|
|
|
|
const prep = await postPrepare(file, map);
|
|
|
|
|
|
if (!prep.sessionId) {
|
|
|
|
|
|
throw new Error(prep.errors[0]?.message || 'آمادهسازی فایل ناموفق بود');
|
|
|
|
|
|
}
|
|
|
|
|
|
sessionId = prep.sessionId;
|
2026-10-03 18:14:17 +03:30
|
|
|
|
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);
|
2026-10-03 17:58:41 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 17:42:11 +03:30
|
|
|
|
while (guard < maxChunks) {
|
2026-10-03 15:01:01 +03:30
|
|
|
|
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;
|
2026-10-03 17:45:08 +03:30
|
|
|
|
updateImportProgress(processed, total);
|
2026-10-03 15:01:01 +03:30
|
|
|
|
|
2026-10-03 17:42:11 +03:30
|
|
|
|
if (
|
|
|
|
|
|
part.errors?.some((e) => e.row === 1 && /بیش از .* ردیف/.test(e.message))
|
|
|
|
|
|
) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 17:45:08 +03:30
|
|
|
|
if (!part.hasMore || !part.sessionId) {
|
|
|
|
|
|
if (total > 0) updateImportProgress(total, total);
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
2026-10-03 15:01:01 +03:30
|
|
|
|
sessionId = part.sessionId;
|
|
|
|
|
|
offset = part.nextOffset ?? processed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 17:42:11 +03:30
|
|
|
|
if (guard >= maxChunks && acc.hasMore) {
|
|
|
|
|
|
acc.errors = [
|
|
|
|
|
|
...acc.errors,
|
|
|
|
|
|
{
|
|
|
|
|
|
row: 1,
|
|
|
|
|
|
message: 'ورود بهخاطر تعداد مراحل زیاد متوقف شد. از جایی که مانده ادامه دهید یا فایل را تقسیم کنید.',
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 15:01:01 +03:30
|
|
|
|
return acc;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function apiMessage(e: unknown, fallback: string) {
|
2026-10-03 17:58:41 +03:30
|
|
|
|
if (!axios.isAxiosError(e)) {
|
|
|
|
|
|
return e instanceof Error && e.message ? e.message : fallback;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
}
|
2026-10-03 17:58:41 +03:30
|
|
|
|
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})`;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
|
|
|
|
|
void handleExcelFile(file as File);
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function handleExcelFile(file: File) {
|
|
|
|
|
|
if (props.disabled || submitting.value) return;
|
2026-09-19 15:30:31 +03:30
|
|
|
|
emit('start');
|
|
|
|
|
|
submitting.value = true;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
phase.value = 'preview';
|
2026-09-19 15:30:31 +03:30
|
|
|
|
try {
|
2026-10-03 18:28:47 +03:30
|
|
|
|
let data: ExcelPreviewResult;
|
|
|
|
|
|
if (useClientImport.value && props.clientAliases && props.clientFields) {
|
|
|
|
|
|
progressLabel.value = 'در حال خواندن فایل در مرورگر…';
|
2026-10-04 10:44:00 +03:30
|
|
|
|
data = await previewExcelClient(
|
|
|
|
|
|
file,
|
|
|
|
|
|
props.clientAliases,
|
|
|
|
|
|
props.clientFields,
|
|
|
|
|
|
props.clientReadOptions,
|
|
|
|
|
|
);
|
2026-10-03 18:28:47 +03:30
|
|
|
|
} 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;
|
|
|
|
|
|
}
|
2026-09-19 15:30:31 +03:30
|
|
|
|
if (!data.needsMapping) {
|
2026-10-03 15:01:01 +03:30
|
|
|
|
try {
|
|
|
|
|
|
emit('done', await runChunkedImport(file));
|
|
|
|
|
|
reset();
|
|
|
|
|
|
emit('settled');
|
|
|
|
|
|
} catch (impErr) {
|
|
|
|
|
|
emit('fail', apiMessage(impErr, 'ایمپورت ناموفق بود'));
|
|
|
|
|
|
reset();
|
|
|
|
|
|
emit('settled');
|
|
|
|
|
|
}
|
2026-09-19 15:30:31 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
pending.value = file;
|
|
|
|
|
|
preview.value = data;
|
|
|
|
|
|
for (const col of data.columns) {
|
|
|
|
|
|
mapping[col.header] = data.suggested[col.header] ?? '';
|
|
|
|
|
|
}
|
2026-10-03 12:29:11 +03:30
|
|
|
|
phase.value = 'idle';
|
|
|
|
|
|
submitting.value = false;
|
|
|
|
|
|
emit('settled');
|
2026-09-19 15:30:31 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
emit('fail', apiMessage(e, 'خواندن فایل اکسل ناموفق بود'));
|
|
|
|
|
|
reset();
|
|
|
|
|
|
emit('settled');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmMapping() {
|
2026-10-03 12:29:11 +03:30
|
|
|
|
if (!pending.value || !canImport.value || submitting.value) return;
|
|
|
|
|
|
emit('start');
|
2026-09-19 15:30:31 +03:30
|
|
|
|
submitting.value = true;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
phase.value = 'import';
|
2026-09-19 15:30:31 +03:30
|
|
|
|
try {
|
2026-10-03 15:01:01 +03:30
|
|
|
|
emit('done', await runChunkedImport(pending.value, { ...mapping }));
|
2026-09-19 15:30:31 +03:30
|
|
|
|
reset();
|
|
|
|
|
|
emit('settled');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
emit('fail', apiMessage(e, 'ایمپورت ناموفق بود'));
|
2026-10-03 12:29:11 +03:30
|
|
|
|
phase.value = 'idle';
|
2026-09-19 15:30:31 +03:30
|
|
|
|
submitting.value = false;
|
2026-10-03 15:01:01 +03:30
|
|
|
|
progressLabel.value = '';
|
2026-10-03 17:45:08 +03:30
|
|
|
|
progressPct.value = null;
|
2026-10-03 12:29:11 +03:30
|
|
|
|
emit('settled');
|
2026-09-19 15:30:31 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-upload
|
|
|
|
|
|
:show-upload-list="false"
|
|
|
|
|
|
:disabled="disabled || submitting"
|
|
|
|
|
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
|
|
|
|
:before-upload="beforeUpload"
|
|
|
|
|
|
@click.stop
|
|
|
|
|
|
>
|
2026-10-03 12:29:11 +03:30
|
|
|
|
<slot :loading="submitting" :disabled="disabled || submitting" />
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-upload>
|
2026-09-19 15:30:31 +03:30
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<a-modal
|
2026-10-03 17:45:08 +03:30
|
|
|
|
:open="busyOpen"
|
2026-10-18 19:39:59 +03:30
|
|
|
|
: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>
|
2026-10-03 12:29:11 +03:30
|
|
|
|
|
2026-10-18 19:39:59 +03:30
|
|
|
|
<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">
|
2026-09-19 15:30:31 +03:30
|
|
|
|
ستونهای این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</a-typography-paragraph>
|
|
|
|
|
|
<a-typography-paragraph v-if="preview?.largeFile" type="secondary">
|
2026-10-03 15:01:01 +03:30
|
|
|
|
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود بهصورت چندمرحلهای انجام میشود.
|
2026-10-18 19:39:59 +03:30
|
|
|
|
</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>
|
2026-09-19 15:30:31 +03:30
|
|
|
|
</template>
|