feat: compress uploads and serve resized image variants via /api/img
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,7 +29,7 @@
|
||||
class="tile"
|
||||
:class="{ 'is-uploading': item.status === 'uploading' }"
|
||||
>
|
||||
<img :src="item.url ?? item.preview" alt="" />
|
||||
<img :src="imageUrl(item.url, 400) ?? item.preview" alt="" />
|
||||
|
||||
<div v-if="item.status === 'uploading'" class="tile__progress">
|
||||
<a-progress type="circle" :percent="item.percent" :size="40" :stroke-width="8" stroke-color="#fff" trail-color="rgba(255,255,255,.3)" :show-info="false" />
|
||||
@@ -64,6 +64,8 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
import { CloudUploadOutlined, DeleteOutlined, EyeOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
import { checkImageFile, uploadErrorMessage } from '../../utils/upload';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -116,8 +118,9 @@ watch(
|
||||
);
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error(`«${file.name}» تصویر نیست`);
|
||||
const problem = checkImageFile(file);
|
||||
if (problem) {
|
||||
message.error(problem);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
@@ -171,10 +174,7 @@ function doUpload({ file }: { file: File & { uid?: string } }) {
|
||||
sync();
|
||||
})
|
||||
.catch((e) => {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
`بارگذاری «${file.name}» ناموفق بود`;
|
||||
message.error(msg);
|
||||
message.error(uploadErrorMessage(e, file.name));
|
||||
dropItem(uid);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
:max-count="1"
|
||||
accept="image/*"
|
||||
:custom-request="doUpload"
|
||||
:before-upload="beforeUpload"
|
||||
@remove="handleRemove"
|
||||
@preview="handlePreview"
|
||||
>
|
||||
@@ -37,9 +38,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { message, Upload } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import api from '../../api/client';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
import { checkImageFile, uploadErrorMessage } from '../../utils/upload';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -67,6 +70,7 @@ interface UiFile {
|
||||
name: string;
|
||||
status: 'done';
|
||||
url: string;
|
||||
thumbUrl?: string;
|
||||
}
|
||||
const fileList = ref<UiFile[]>([]);
|
||||
|
||||
@@ -77,7 +81,9 @@ watch(
|
||||
if (!url) {
|
||||
fileList.value = [];
|
||||
} else if (fileList.value[0]?.url !== url) {
|
||||
fileList.value = [{ uid: url, name: url.split('/').pop() ?? 'file', status: 'done', url }];
|
||||
fileList.value = [
|
||||
{ uid: url, name: url.split('/').pop() ?? 'file', status: 'done', url, thumbUrl: imageUrl(url, 400) },
|
||||
];
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -85,6 +91,15 @@ watch(
|
||||
|
||||
const placeholder = computed(() => props.placeholder);
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
const problem = checkImageFile(file);
|
||||
if (problem) {
|
||||
message.error(problem);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function doUpload({ file }: { file: File }) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
@@ -98,10 +113,7 @@ async function doUpload({ file }: { file: File }) {
|
||||
emit('update:modelValue', data.url);
|
||||
emit('ok', data);
|
||||
} catch (e) {
|
||||
const msg =
|
||||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'بارگذاری فایل ناموفق بود';
|
||||
message.error(msg);
|
||||
message.error(uploadErrorMessage(e, file.name));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Uploaded images through the API's resize endpoint (GET <api>/img/:file?w=).
|
||||
// Anything that is not a plain upload path (blob:, data:, bundled assets,
|
||||
// absolute URLs) is returned untouched.
|
||||
const UPLOAD_PREFIXES = ['/uploads/', '/api/uploads/'];
|
||||
|
||||
/** Must match IMAGE_WIDTHS in apps/api image-variants.service.ts. */
|
||||
export type ImageWidth = 200 | 400 | 600 | 800 | 1200 | 1600;
|
||||
|
||||
const apiBase = (() => {
|
||||
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/';
|
||||
return base.endsWith('/') ? base : `${base}/`;
|
||||
})();
|
||||
|
||||
export function imageUrl(url: string | null | undefined, width: ImageWidth): string | undefined {
|
||||
if (!url) return undefined;
|
||||
const prefix = UPLOAD_PREFIXES.find((p) => url.startsWith(p));
|
||||
if (!prefix) return url;
|
||||
const name = url.slice(prefix.length);
|
||||
if (!name || name.includes('/')) return url;
|
||||
return `${apiBase}img/${encodeURIComponent(name)}?w=${width}`;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Checks and error wording shared by the image uploaders. The limits mirror
|
||||
// AdminUploadsService in apps/api.
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
const IMAGE_TYPES = /^image\/(jpeg|png|gif|webp|svg\+xml)$/;
|
||||
|
||||
/** Null when the file may be uploaded, otherwise the message to show. */
|
||||
export function checkImageFile(file: File): string | null {
|
||||
if (!IMAGE_TYPES.test(file.type)) {
|
||||
return `«${file.name}» پشتیبانی نمیشود؛ فقط تصویر JPG، PNG، WebP، GIF یا SVG`;
|
||||
}
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
return `حجم «${file.name}» بیشتر از ۸ مگابایت است؛ فایل کوچکتری انتخاب کنید`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The message for a failed upload request. A 413 comes from the reverse proxy,
|
||||
* not the API, so its body is HTML rather than `{ message }`.
|
||||
*/
|
||||
export function uploadErrorMessage(e: unknown, fileName?: string): string {
|
||||
const res = (e as { response?: { status?: number; data?: unknown } })?.response;
|
||||
if (!res) return 'اتصال به سرور برقرار نشد؛ اینترنت را بررسی کنید و دوباره تلاش کنید';
|
||||
if (res.status === 413) {
|
||||
return `حجم ${fileName ? `«${fileName}»` : 'فایل'} بیشتر از حد مجاز سرور است`;
|
||||
}
|
||||
if (res.status === 403) return 'اجازهٔ بارگذاری فایل را ندارید';
|
||||
|
||||
const raw = (res.data as { message?: unknown } | undefined)?.message;
|
||||
const serverMessage = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (typeof serverMessage === 'string' && serverMessage) return serverMessage;
|
||||
|
||||
return fileName
|
||||
? `بارگذاری «${fileName}» انجام نشد؛ دوباره تلاش کنید`
|
||||
: 'بارگذاری فایل انجام نشد؛ دوباره تلاش کنید';
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
>
|
||||
<template #description>
|
||||
<div v-for="hit in surveyMajorityHits" :key="hit.id" class="survey-majority__row">
|
||||
<img v-if="hit.imageUrl" :src="hit.imageUrl" alt="" />
|
||||
<img v-if="hit.imageUrl" :src="imageUrl(hit.imageUrl, 200)" alt="" />
|
||||
<span>
|
||||
<b>{{ hit.title || 'بدون عنوان' }}</b>
|
||||
— ▲ {{ hit.likes }} لایک (حد: {{ surveyMajorityThreshold }})
|
||||
@@ -314,6 +314,7 @@ import {
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { jdate, price } from '../utils/filters';
|
||||
import { imageUrl } from '../utils/image';
|
||||
import { useLayoutStore } from '../stores/layout';
|
||||
import { useUsersStore } from '../stores/users';
|
||||
import api from '../api/client';
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="text && text.image ? text.image : noImage"
|
||||
:src="text && text.image ? imageUrl(text.image, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -54,6 +54,7 @@ import { buildCategoryModel } from './CategoryTbl';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
|
||||
import api from '../../../api/client';
|
||||
import { imageUrl } from '../../../utils/image';
|
||||
|
||||
const users = useUsersStore();
|
||||
const hasPermission = (rid: string) => Boolean(users.hasPermission(rid));
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<template #image="{ record }">
|
||||
<img
|
||||
v-if="record.ProductImage && record.ProductImage.length"
|
||||
:src="record.ProductImage[0].image"
|
||||
:src="imageUrl(record.ProductImage[0].image, 200)"
|
||||
class="product-thumb"
|
||||
alt=""
|
||||
/>
|
||||
@@ -122,6 +122,7 @@ import AccountingSyncButton from '../../../components/accounting/AccountingSyncB
|
||||
import { buildProductListModel } from './productListModel';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import { isBranch } from '../../../utils';
|
||||
import { imageUrl } from '../../../utils/image';
|
||||
import { useDeleteHandler } from '../../../composables/useDeleteHandler';
|
||||
import api from '../../../api/client';
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
>
|
||||
<template #description>
|
||||
<div v-for="hit in majorityHits" :key="hit.id" class="majority-hit">
|
||||
<img v-if="hit.imageUrl" :src="hit.imageUrl" class="thumb" alt="" />
|
||||
<img v-if="hit.imageUrl" :src="imageUrl(hit.imageUrl, 200)" class="thumb" alt="" />
|
||||
<div class="majority-hit__text">
|
||||
<b>{{ hit.title || 'بدون عنوان' }}</b>
|
||||
<div>▲ {{ hit.likes }} لایک · {{ hit.statusLabel }} · {{ hit.suggestedWeightGrams }} گرم</div>
|
||||
@@ -59,7 +59,7 @@
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'image'">
|
||||
<img v-if="record.imageUrl" :src="record.imageUrl" class="thumb" alt="" />
|
||||
<img v-if="record.imageUrl" :src="imageUrl(record.imageUrl, 200)" class="thumb" alt="" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'title'">
|
||||
{{ record.title || '—' }}
|
||||
@@ -148,6 +148,7 @@ import { onMounted, reactive, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useUsersStore } from '../../../stores/users';
|
||||
import api from '../../../api/client';
|
||||
import { imageUrl } from '../../../utils/image';
|
||||
import ImageUploadInput from '../../../components/upload/ImageUploadInput.vue';
|
||||
|
||||
type SurveyRow = {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { optionText } from '../../../utils/select';
|
||||
import { imageUrl } from '../../../utils/image';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
@@ -173,7 +174,7 @@ function getVariant(item: CartItem): CartVariant & Record<string, any> {
|
||||
}
|
||||
|
||||
function getImage(product: Record<string, any>) {
|
||||
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
|
||||
return imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? noImage;
|
||||
}
|
||||
|
||||
/** Same as the admin builder: 0 stock means uncapped, which keeps pre-orders orderable. */
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { optionText } from '../../utils/select';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
@@ -207,7 +208,7 @@ const columns = [
|
||||
];
|
||||
|
||||
function getImage(product: Record<string, any>) {
|
||||
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
|
||||
return imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? noImage;
|
||||
}
|
||||
|
||||
function addToCart(record: Record<string, any>) {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
</XTable>
|
||||
@@ -96,6 +96,7 @@ import { message } from 'ant-design-vue';
|
||||
import XTable from '../../components/x-table/XTable.vue';
|
||||
import XTbl, { Xtc } from '../../components/x-table/model';
|
||||
import api from '../../api/client';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
|
||||
const noImage = '/img/no-items.png';
|
||||
const loading = ref(false);
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
width="90px"
|
||||
height="90px"
|
||||
style="object-fit: scale-down"
|
||||
:src="record.thumb && record.thumb.full ? record.thumb.full : noImage"
|
||||
:src="record.thumb && record.thumb.full ? imageUrl(record.thumb.full, 200) : noImage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { optionText } from '../../utils/select';
|
||||
import { imageUrl } from '../../utils/image';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { message } from 'ant-design-vue';
|
||||
@@ -294,7 +295,7 @@ function getVariant(item: CartItem): CartVariant & Record<string, any> {
|
||||
}
|
||||
|
||||
function getImage(product: Record<string, any>) {
|
||||
return product?.thumb?.full ?? product?.ProductImage?.[0]?.image ?? noImage;
|
||||
return imageUrl(product?.thumb?.full ?? product?.ProductImage?.[0]?.image, 200) ?? noImage;
|
||||
}
|
||||
|
||||
/** legacy totalWeight — note it does NOT multiply by qty. Preserved. */
|
||||
|
||||
Reference in New Issue
Block a user