diff --git a/src/components/ProductUploader/IndexImageUploader.vue b/src/components/ProductUploader/IndexImageUploader.vue index d4ff0f6..eb80a32 100644 --- a/src/components/ProductUploader/IndexImageUploader.vue +++ b/src/components/ProductUploader/IndexImageUploader.vue @@ -19,6 +19,7 @@ :action="uploadURL" :headers="fileHeader" :before-upload="beforeUpload" + :custom-request="customRequest" :file-list="fileList" @change="handleChange" @update:file-list="onFileListUpdate" @@ -31,7 +32,7 @@ @@ -57,6 +58,7 @@ :action="uploadURL" :headers="fileHeader" :before-upload="beforeUpload" + :custom-request="customRequest" :file-list="fileList" @change="handleChange" @update:file-list="onFileListUpdate" @@ -84,13 +86,17 @@ diff --git a/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue b/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue index a88fd09..b428318 100644 --- a/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue +++ b/src/pages/menuPages/products/base/components/ProductEditorFormBody.vue @@ -733,8 +733,12 @@ const weightFieldLabel = computed(() => { return weightFieldLabelForUnit(tradeWeightUnit.value) }) -// واحد حداقل تحویل فیزیکی: محصول وزنی → گرم/کیلوگرم، محصول تعدادی → عدد +// واحد حداقل تحویل فیزیکی: تعدادی → عدد، مقداری/وزنی → گرم/کیلوگرم const physicalDeliveryUnit = computed(() => { + if (!isQuantityTrade.value && !isMeltedGroup.value) { + return 'عدد' + } + const productUnit = props.form.unit?.trim() if (productUnit === 'کیلوگرم' || productUnit === 'گرم') { return productUnit diff --git a/src/utils/prepareImageForUpload.ts b/src/utils/prepareImageForUpload.ts new file mode 100644 index 0000000..dd5190e --- /dev/null +++ b/src/utils/prepareImageForUpload.ts @@ -0,0 +1,238 @@ +import type { UploadProps } from 'ant-design-vue' +import { Upload } from 'ant-design-vue' + +/** حداکثر حجم فایل خروجی آپلود (۱ مگابایت) */ +export const MAX_UPLOAD_IMAGE_BYTES = 1024 * 1024 + +/** حداکثر حجم فایل ورودی قبل از فشرده‌سازی (۲۵ مگابایت) */ +export const MAX_SOURCE_IMAGE_BYTES = 25 * 1024 * 1024 + +const MAX_DIMENSION = 1920 +const QUALITY_STEPS = [0.86, 0.78, 0.7, 0.62, 0.54, 0.46] +const UPLOAD_TIMEOUT_MS = 60_000 + +function stripExtension(name: string): string { + return name.replace(/\.(png|jpe?g|webp|gif|bmp|heic|heif)+$/iu, '') || 'image' +} + +function loadImageElement(file: File): Promise { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file) + const img = new Image() + img.onload = () => { + URL.revokeObjectURL(url) + resolve(img) + } + img.onerror = () => { + URL.revokeObjectURL(url) + reject(new Error('خواندن تصویر ناموفق بود')) + } + img.src = url + }) +} + +function canvasToBlob( + canvas: HTMLCanvasElement, + type: string, + quality: number +): Promise { + return new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), type, quality) + }) +} + +function drawScaled(img: HTMLImageElement, maxDimension: number): HTMLCanvasElement { + const scale = Math.min(1, maxDimension / Math.max(img.naturalWidth, img.naturalHeight)) + const width = Math.max(1, Math.round(img.naturalWidth * scale)) + const height = Math.max(1, Math.round(img.naturalHeight * scale)) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) { + throw new Error('Canvas در دسترس نیست') + } + ctx.drawImage(img, 0, 0, width, height) + return canvas +} + +async function encodeWebpUnderLimit( + img: HTMLImageElement, + maxBytes: number +): Promise { + let maxDimension = MAX_DIMENSION + + for (let resizePass = 0; resizePass < 4; resizePass += 1) { + const canvas = drawScaled(img, maxDimension) + + for (const quality of QUALITY_STEPS) { + const blob = await canvasToBlob(canvas, 'image/webp', quality) + if (blob && blob.size > 0 && blob.size <= maxBytes) { + return blob + } + } + + maxDimension = Math.round(maxDimension * 0.75) + } + + throw new Error('پس از تبدیل به WebP حجم فایل بیش از حد مجاز ماند') +} + +/** + * تصویر را به WebP تبدیل می‌کند (با کاهش کیفیت/ابعاد در صورت نیاز) + * و برای آپلود آماده می‌کند. خروجی همیشه ≤ maxBytes است. + */ +export async function prepareImageForUpload( + file: File, + maxBytes: number = MAX_UPLOAD_IMAGE_BYTES +): Promise { + if (!file || file.size < 1) { + throw new Error('فایل خالی است') + } + + if (!file.type.startsWith('image/')) { + throw new Error('فقط فایل تصویری مجاز است') + } + + if (file.size > MAX_SOURCE_IMAGE_BYTES) { + throw new Error('حجم فایل انتخابی بیش از حد مجاز است (حداکثر ۲۵ مگابایت)') + } + + if (file.type === 'image/webp' && file.size <= maxBytes) { + return file + } + + const img = await loadImageElement(file) + const blob = await encodeWebpUnderLimit(img, maxBytes) + const baseName = stripExtension(file.name) + + return new File([blob], `${baseName}.webp`, { + type: 'image/webp', + lastModified: Date.now() + }) +} + +/** اعتبارسنجی سریع قبل از customRequest */ +export function createImageBeforeUpload(messageError: (msg: string) => void) { + return (file: File) => { + if (!file?.type?.startsWith('image/')) { + messageError('فقط فایل تصویری مجاز است') + return Upload.LIST_IGNORE + } + if (file.size > MAX_SOURCE_IMAGE_BYTES) { + messageError('حجم فایل انتخابی بیش از حد مجاز است (حداکثر ۲۵ مگابایت)') + return Upload.LIST_IGNORE + } + return true + } +} + +type CustomRequestOption = Parameters>[0] + +function parseXhrBody(xhr: XMLHttpRequest): unknown { + const text = xhr.responseText + if (!text) return xhr.response ?? null + try { + return JSON.parse(text) + } catch { + return text + } +} + +/** + * تبدیل به WebP + آپلود با XHR و تایم‌اوت — جایگزین action پیش‌فرض آنت‌دیزاین. + * + * `onUploaded` / `onFailed` مستقل از وضعیت داخلی ant-design-vue صدا زده می‌شوند؛ + * با fileList کنترل‌شده، گاهی `onSuccess` آنت به‌خاطر getFileItem ساکت می‌ماند + * و UI روی «در حال بارگذاری» گیر می‌کند. + */ +export function createImageCustomRequest(options: { + action: string | (() => string) + getHeaders: () => Record + messageError: (msg: string) => void + timeoutMs?: number + onStarted?: () => void + onUploaded?: (body: unknown, meta: { uid?: string }) => void + onFailed?: (error: Error, meta: { uid?: string }) => void +}): UploadProps['customRequest'] { + return (opt: CustomRequestOption) => { + const { file, onSuccess, onError, onProgress } = opt + const action = typeof options.action === 'function' ? options.action() : options.action + const timeoutMs = options.timeoutMs ?? UPLOAD_TIMEOUT_MS + const uid = (file as File & { uid?: string })?.uid + + options.onStarted?.() + + void (async () => { + try { + const prepared = await prepareImageForUpload(file as File) + const formData = new FormData() + formData.append('file', prepared) + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest() + xhr.open('POST', action) + xhr.timeout = timeoutMs + // text + JSON.parse دستی؛ responseType=json گاهی body را null می‌کند + + const headers = options.getHeaders() + Object.entries(headers).forEach(([key, value]) => { + if (value) { + xhr.setRequestHeader(key, value) + } + }) + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + onProgress?.({ + ...event, + percent: Math.round((event.loaded / event.total) * 100) + }) + } + } + + xhr.onload = () => { + const body = parseXhrBody(xhr) + + if (xhr.status >= 200 && xhr.status < 300) { + try { + onSuccess?.(body, xhr) + } catch { + // ant ممکن است به‌خاطر fileList کنترل‌شده throw/no-op کند + } + options.onUploaded?.(body, { uid }) + resolve() + return + } + + const msg = + (body && typeof body === 'object' && 'message' in body && String((body as { message: unknown }).message)) || + `خطای سرور (${xhr.status})` + reject(new Error(msg)) + } + + xhr.onerror = () => reject(new Error('خطای شبکه در آپلود')) + xhr.ontimeout = () => + reject(new Error('زمان آپلود به پایان رسید — لطفاً دوباره تلاش کنید')) + xhr.onabort = () => reject(new Error('آپلود لغو شد')) + + xhr.send(formData) + }) + } catch (error) { + const err = error instanceof Error ? error : new Error('خطا در آماده‌سازی تصویر') + options.messageError(err.message) + try { + onError?.(err) + } catch { + // ignore ant internal errors + } + options.onFailed?.(err, { uid }) + } + })() + } +} + +/** @deprecated از createImageBeforeUpload + createImageCustomRequest استفاده کنید */ +export function createWebpBeforeUpload(messageError: (msg: string) => void) { + return createImageBeforeUpload(messageError) +}