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 @@
افزودن تصویر شاخص
- JPG، PNG یا WEBP — حداکثر ۲۰۰ کیلوبایت
+ JPG، PNG یا WEBP — حداکثر ۱ مگابایت (تبدیل خودکار به WebP)
نسبت مربعی برای نمایش بهتر توصیه میشود
@@ -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)
+}