B
This commit is contained in:
2026-07-18 09:56:03 +02:00
6 changed files with 420 additions and 118 deletions
@@ -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 @@
<template v-else>
<BaseIcon icon="vuesax-linear:gallery-add" size="40" class="index-image-uploader__icon" />
<span class="index-image-uploader__cta">افزودن تصویر شاخص</span>
<span class="index-image-uploader__hint">JPG، PNG یا WEBP — حداکثر ۲۰۰ کیلوبایت</span>
<span class="index-image-uploader__hint">JPG، PNG یا WEBP — حداکثر ۱ مگابایت (تبدیل خودکار به WebP)</span>
<span class="index-image-uploader__hint">نسبت مربعی برای نمایش بهتر توصیه می‌شود</span>
</template>
</div>
@@ -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 @@
<script setup lang="ts">
import type { UploadChangeParam, UploadProps } from 'ant-design-vue'
import { Upload, message } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { LoadingOutlined } from '@ant-design/icons-vue'
import { usePrefix } from '@core/composable/usePrefix'
import { uploadURL } from '@root/config'
import { getToken } from '@/utils'
import { fixFileCapture } from '@/utils/fixFileCapture'
import { normalizeUploadFileResponse } from '@/utils/file'
import {
createImageBeforeUpload,
createImageCustomRequest
} from '@/utils/prepareImageForUpload'
import { computed, onMounted, ref } from 'vue'
const { prefixCls } = usePrefix('index-image-uploader')
@@ -99,9 +105,9 @@ const previewImage = ref('')
const previewTitle = ref('تصویر شاخص')
const loading = ref(false)
const fileHeader = {
const fileHeader = computed(() => ({
Authorization: `Bearer ${getToken()}`
}
}))
interface Props {
fileList?: Array<FileList>
@@ -150,58 +156,58 @@ function removeImage() {
}
}
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
if (!file.size || file.size < 1) {
message.error('فایل خالی است')
return Upload.LIST_IGNORE
const beforeUpload: UploadProps['beforeUpload'] = createImageBeforeUpload((msg) =>
message.error(msg)
)
function applyUploadedIndexImage(body: unknown) {
loading.value = false
const fileResource = normalizeUploadFileResponse(body)
if (!fileResource?.id || !fileResource.full_src) {
fileList.value = []
message.error('آپلود انجام شد ولی آدرس تصویر ساخته نشد')
return
}
const isLt200k = file.size / 1024 < 200
if (!isLt200k) {
message.error('حداکثر حجم بارگذاری ۲۰۰ کیلوبایت می‌باشد')
return Upload.LIST_IGNORE
}
return true
fileList.value = [
{
uid: String(fileResource.id),
name: fileResource.name || 'image',
status: 'done',
url: fileResource.full_src,
thumbUrl: fileResource.full_src,
response: body
}
]
message.success('تصویر شاخص با موفقیت بارگذاری شد')
emits('ok', fileResource.id, body as Record<string, any>)
}
const customRequest: UploadProps['customRequest'] = createImageCustomRequest({
action: () => uploadURL,
getHeaders: () => fileHeader.value,
messageError: (msg) => message.error(msg),
onStarted: () => {
loading.value = true
},
onUploaded: applyUploadedIndexImage,
onFailed: () => {
loading.value = false
fileList.value = []
}
})
function handleChange(info: UploadChangeParam) {
fixFileCapture()
// success/error از onUploaded/onFailed اعمال می‌شود (fileList کنترل‌شده گاهی done را می‌بلعد)
if (info.file.status === 'uploading') {
loading.value = true
return
}
if (info.file.status === 'error') {
loading.value = false
fileList.value = []
message.error('خطا در بارگذاری')
}
if (info.file.status === 'done') {
loading.value = false
const fileResource = normalizeUploadFileResponse(info.file.response)
if (!fileResource?.id || !fileResource.full_src) {
fileList.value = []
message.error('خطا در ساخت آدرس تصویر')
return
}
fileList.value = [
{
...info.file,
uid: String(fileResource.id),
status: 'done',
url: fileResource.full_src,
thumbUrl: fileResource.full_src
}
]
message.success('تصویر شاخص با موفقیت بارگذاری شد')
emits('ok', fileResource.id, info.file.response)
}
if (info.file.status === 'removed') {
loading.value = false
emits('delete', info.file.uid)
}
}
@@ -13,6 +13,7 @@
:headers="fileHeader"
:showUploadList="true"
:before-upload="beforeUpload"
:custom-request="customRequest"
@change="handleChange"
>
<div>
@@ -27,20 +28,24 @@
<script setup lang="ts">
import type {UploadChangeParam, UploadProps} from 'ant-design-vue'
import {Upload, message} from 'ant-design-vue'
import {message} from 'ant-design-vue'
import {uploadURL} from '@root/config'
import {getToken} from '@/utils'
import {fixFileCapture} from '@/utils/fixFileCapture'
import {normalizeUploadFileResponse} from '@/utils/file'
import {
createImageBeforeUpload,
createImageCustomRequest
} from '@/utils/prepareImageForUpload'
import {usePrefix} from '@core/composable/usePrefix'
import {computed, onMounted, ref} from 'vue'
const {prefixCls} = usePrefix('product-image-uploader')
const loading = ref<boolean>(false)
const fileHeader = {
const fileHeader = computed(() => ({
Authorization: `Bearer ${getToken()}`
}
}))
interface Props {
fileList?: Array<FileList>
@@ -59,58 +64,66 @@ const fileList = computed({
}
})
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
if (!file.size || file.size < 1) {
message.error('فایل خالی است')
return Upload.LIST_IGNORE
const beforeUpload: UploadProps['beforeUpload'] = createImageBeforeUpload((msg) =>
message.error(msg)
)
function applyUploadedProductImage(body: unknown, meta: { uid?: string }) {
loading.value = false
const fileResource = normalizeUploadFileResponse(body)
if (!fileResource?.id || !fileResource.full_src) {
if (meta.uid) {
fileList.value = fileList.value.filter((item) => item.uid !== meta.uid)
}
message.error('خطا در ساخت آدرس تصویر')
return
}
const isLt240k = file.size / 1024 < 240
if (!isLt240k) {
message.error('حداکثر حجم بارگذاری ۲۴۰ کیلوبایت می‌باشد')
return Upload.LIST_IGNORE
const doneItem = {
uid: String(fileResource.id),
name: fileResource.name || 'image',
status: 'done' as const,
url: fileResource.full_src,
thumbUrl: fileResource.full_src,
response: body
}
return true
const matched = meta.uid && fileList.value.some((item) => item.uid === meta.uid)
fileList.value = matched
? fileList.value.map((item) => (item.uid === meta.uid ? doneItem : item))
: [
...fileList.value.filter((item) => item.status !== 'uploading'),
doneItem
]
message.success('تصویر با موفقیت بارگذاری شد')
emits('ok', fileResource.id, body)
}
const customRequest: UploadProps['customRequest'] = createImageCustomRequest({
action: () => uploadURL,
getHeaders: () => fileHeader.value,
messageError: (msg) => message.error(msg),
onStarted: () => {
loading.value = true
},
onUploaded: applyUploadedProductImage,
onFailed: (_err, meta) => {
loading.value = false
if (meta.uid) {
fileList.value = fileList.value.filter((item) => item.uid !== meta.uid)
}
}
})
function handleChange(info: UploadChangeParam) {
fixFileCapture()
if (info.file.status === 'uploading') {
loading.value = true
return
}
if (info.file.status === 'error') {
loading.value = false
message.error('خطا در بارگذاری')
}
if (info.file.status === 'done') {
loading.value = false
const fileResource = normalizeUploadFileResponse(info.file.response)
if (!fileResource?.id || !fileResource.full_src) {
fileList.value = fileList.value.filter((item) => item.uid !== info.file.uid)
message.error('خطا در ساخت آدرس تصویر')
return
}
fileList.value = fileList.value.map((item) =>
item.uid === info.file.uid
? {
...item,
uid: String(fileResource.id),
status: 'done',
url: fileResource.full_src,
thumbUrl: fileResource.full_src,
}
: item,
)
message.success('تصویر با موفقیت بارگذاری شد')
emits('ok', fileResource.id, info.file.response)
}
if (info.file.status === 'removed') {
emits('delete', info.file.uid)
}
+41 -17
View File
@@ -8,6 +8,8 @@
:action="url"
:headers="fileHeader"
accept="image/*"
:before-upload="beforeUpload"
:custom-request="customRequest"
@change="handelChange"
>
<div
@@ -41,8 +43,14 @@ import { uploadURL } from '@root/config'
import { onMounted, ref, watch, computed } from 'vue'
import { LoadingOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import type { UploadProps } from 'ant-design-vue'
import { getToken } from '@/utils'
import { fixFileCapture } from '@/utils/fixFileCapture'
import {
createImageBeforeUpload,
createImageCustomRequest
} from '@/utils/prepareImageForUpload'
import { normalizeUploadFileResponse, toAbsoluteFileUrl } from '@/utils/file'
const loading = ref<boolean>(false)
const percent = ref<number>(0)
@@ -68,29 +76,45 @@ const emit = defineEmits<{
(e: 'errorUpload'): void
}>()
function handelChange({ file, fileList }) {
if (Object.hasOwn(file, 'percent')) {
const beforeUpload: UploadProps['beforeUpload'] = createImageBeforeUpload((msg) =>
message.error(msg)
)
const customRequest: UploadProps['customRequest'] = createImageCustomRequest({
action: () => url.value || uploadURL,
getHeaders: () => fileHeader.value,
messageError: (msg) => message.error(msg),
onStarted: () => {
loading.value = true
percent.value = 0
emit('startUpload')
},
onUploaded: (body) => {
const fileResource = normalizeUploadFileResponse(body)
const record = body && typeof body === 'object' ? (body as Record<string, any>) : undefined
imageUrl.value =
fileResource?.full_src ||
toAbsoluteFileUrl(record?.full_src || record?.src) ||
''
loading.value = false
percent.value = 100
emit('upload', body)
message.success('فایل با موفقیت آپلود شد')
},
onFailed: () => {
loading.value = false
emit('errorUpload')
}
})
function handelChange({ file }) {
if (Object.hasOwn(file, 'percent') && file.percent != null) {
percent.value = Math.floor(file.percent)
}
fixFileCapture()
if (file.status === 'uploading') {
loading.value = true
emit('startUpload')
return
}
if (file.status === 'error') {
loading.value = false
emit('errorUpload')
message.error('خطا در آپلود فایل')
}
if (file.status == 'done') {
imageUrl.value = file.response.full_src
loading.value = false
emit('upload', file.response)
message.success('فایل با موفقیت آپلود شد')
} else if (fileList.status == 'removed') {
}
}
@@ -8,6 +8,8 @@
:show-upload-list="false"
:action="uploadURL"
:headers="fileHeader"
:before-upload="beforeUpload"
:custom-request="customRequest"
@change="onChange"
>
<div class="banner-image-upload__empty">
@@ -18,7 +20,7 @@
<template v-else>
<BaseIcon icon="vuesax-linear:gallery-add" size="40" class="banner-image-upload__icon" />
<span class="banner-image-upload__cta">افزودن تصویر بنر</span>
<span class="banner-image-upload__hint">کلیک کنید — تصویر افقی با نسبت ۱۶:۹</span>
<span class="banner-image-upload__hint">حداکثر ۱ مگابایت — تبدیل خودکار به WebP — نسبت ۱۶:۹</span>
</template>
</div>
</a-upload>
@@ -32,6 +34,8 @@
:show-upload-list="false"
:action="uploadURL"
:headers="fileHeader"
:before-upload="beforeUpload"
:custom-request="customRequest"
@change="onChange"
>
<button type="button" class="banner-image-upload__btn banner-image-upload__btn--primary">
@@ -56,6 +60,11 @@ import { message } from 'ant-design-vue'
import { uploadURL } from '@root/config'
import { getToken } from '@/utils'
import { normalizeUploadFileResponse } from '@/utils/file'
import {
createImageBeforeUpload,
createImageCustomRequest
} from '@/utils/prepareImageForUpload'
import type { UploadProps } from 'ant-design-vue'
defineProps<{
imageUrl?: string | null
@@ -72,27 +81,35 @@ const fileHeader = computed(() => ({
Authorization: `Bearer ${getToken()}`,
}))
function onChange({ file }: { file: { status?: string; response?: unknown } }) {
if (file.status === 'uploading') {
const beforeUpload: UploadProps['beforeUpload'] = createImageBeforeUpload((msg) =>
message.error(msg)
)
const customRequest: UploadProps['customRequest'] = createImageCustomRequest({
action: () => uploadURL,
getHeaders: () => fileHeader.value,
messageError: (msg) => message.error(msg),
onStarted: () => {
loading.value = true
return
}
if (file.status === 'error') {
},
onUploaded: (body) => {
loading.value = false
message.error('خطا در آپلود تصویر')
return
}
if (file.status === 'done') {
loading.value = false
const fileResource = normalizeUploadFileResponse(file.response)
const fileResource = normalizeUploadFileResponse(body)
if (!fileResource?.full_src) {
message.error('خطا در ساخت آدرس تصویر')
return
}
emit('upload', fileResource.full_src)
message.success('تصویر با موفقیت آپلود شد')
},
onFailed: () => {
loading.value = false
}
})
function onChange({ file }: { file: { status?: string } }) {
if (file.status === 'uploading') {
loading.value = true
}
}
</script>
@@ -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
+238
View File
@@ -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<HTMLImageElement> {
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<Blob | null> {
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<Blob> {
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<File> {
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<NonNullable<UploadProps['customRequest']>>[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<string, string>
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<void>((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)
}