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>
536 lines
17 KiB
Vue
536 lines
17 KiB
Vue
<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>
|