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-03 12:29:11 +03:30
|
|
|
|
import { UiBusyOverlay, UiButton, UiModal, UiSelect } from '@/components/ui';
|
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 input = ref<HTMLInputElement | null>(null);
|
|
|
|
|
|
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 pick() {
|
|
|
|
|
|
if (props.disabled || submitting.value) return;
|
|
|
|
|
|
input.value?.click();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onFile(ev: Event) {
|
|
|
|
|
|
const el = ev.target as HTMLInputElement;
|
|
|
|
|
|
const file = el.files?.[0];
|
|
|
|
|
|
el.value = '';
|
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
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;
|
|
|
|
|
|
// Allow parent toolbar while user maps columns.
|
|
|
|
|
|
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-03 12:29:11 +03:30
|
|
|
|
<span class="excel-import" :class="{ isBusy: submitting }" @click.stop="pick">
|
|
|
|
|
|
<slot :loading="submitting" :disabled="disabled || submitting" />
|
2026-09-19 15:30:31 +03:30
|
|
|
|
<input
|
|
|
|
|
|
ref="input"
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
|
|
|
|
|
hidden
|
2026-10-03 12:29:11 +03:30
|
|
|
|
:disabled="disabled || submitting"
|
2026-09-19 15:30:31 +03:30
|
|
|
|
@change="onFile"
|
|
|
|
|
|
@click.stop
|
|
|
|
|
|
/>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
2026-10-03 17:45:08 +03:30
|
|
|
|
<UiBusyOverlay
|
|
|
|
|
|
:open="busyOpen"
|
|
|
|
|
|
:message="busyMessage"
|
|
|
|
|
|
:progress="busyProgress"
|
|
|
|
|
|
nest
|
|
|
|
|
|
/>
|
2026-10-03 12:29:11 +03:30
|
|
|
|
|
2026-09-19 15:30:31 +03:30
|
|
|
|
<UiModal :open="!!preview" title="تطبیق ستونهای اکسل" @close="closeMapper">
|
|
|
|
|
|
<p class="lead">
|
|
|
|
|
|
ستونهای این فایل با قالب سیستم یکی نیست. مشخص کنید هر ستون فایل در کدام فیلد ریخته شود.
|
|
|
|
|
|
</p>
|
2026-10-03 15:01:01 +03:30
|
|
|
|
<p v-if="preview?.largeFile" class="large-hint">
|
|
|
|
|
|
فایل بزرگ است ({{ (preview.rowCount ?? 0).toLocaleString('fa-IR') }} ردیف) — ورود بهصورت چندمرحلهای انجام میشود.
|
|
|
|
|
|
</p>
|
2026-09-19 15:30:31 +03:30
|
|
|
|
<div class="rows">
|
|
|
|
|
|
<div v-for="col in preview?.columns ?? []" :key="col.header" class="row">
|
|
|
|
|
|
<div class="source">
|
|
|
|
|
|
<strong>{{ col.header }}</strong>
|
|
|
|
|
|
<small v-if="col.samples.length">نمونه: {{ col.samples.join('، ') }}</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<UiSelect
|
|
|
|
|
|
:model-value="mapping[col.header] ?? ''"
|
2026-10-03 12:29:11 +03:30
|
|
|
|
:disabled="submitting"
|
2026-09-19 15:30:31 +03:30
|
|
|
|
@update:model-value="setColumnField(col.header, $event)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">نادیده بگیر</option>
|
|
|
|
|
|
<option v-for="field in preview?.fields ?? []" :key="field.key" :value="field.key">
|
|
|
|
|
|
{{ field.label }}{{ field.required ? ' *' : '' }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</UiSelect>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-if="!canImport" class="hint">فیلدهای ستارهدار را حتماً به یک ستون وصل کنید.</p>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<UiButton variant="ghost" :disabled="submitting" @click="closeMapper">انصراف</UiButton>
|
2026-10-03 12:29:11 +03:30
|
|
|
|
<UiButton :disabled="!canImport" :loading="submitting" @click="confirmMapping">
|
|
|
|
|
|
ورود فایل
|
2026-09-19 15:30:31 +03:30
|
|
|
|
</UiButton>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiModal>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.excel-import {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 12:29:11 +03:30
|
|
|
|
.excel-import.isBusy {
|
|
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 15:30:31 +03:30
|
|
|
|
.lead {
|
|
|
|
|
|
margin: 0 0 1rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-03 15:01:01 +03:30
|
|
|
|
.large-hint {
|
|
|
|
|
|
margin: -0.35rem 0 1rem;
|
|
|
|
|
|
padding: 0.55rem 0.75rem;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
background: color-mix(in srgb, var(--accent, #2563eb) 12%, transparent);
|
|
|
|
|
|
color: var(--fg, inherit);
|
|
|
|
|
|
font-size: 0.88rem;
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 15:30:31 +03:30
|
|
|
|
.rows {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.row {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.source {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.2rem;
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.source strong {
|
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.source small {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: 0.78rem;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hint {
|
|
|
|
|
|
margin: 0.85rem 0 0;
|
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 700px) {
|
|
|
|
|
|
.row {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|