324 lines
8.7 KiB
Vue
324 lines
8.7 KiB
Vue
<template>
|
||
<!--
|
||
Multi-file gallery uploader. Same `file/create` endpoint as
|
||
ImageUploadInput (one file per request), but files can be picked or dropped
|
||
in bulk; each uploads on its own with progress, and the bound value stays
|
||
the ordered list of stored URLs.
|
||
-->
|
||
<div class="gallery-upload">
|
||
<a-upload-dragger
|
||
accept="image/*"
|
||
multiple
|
||
:show-upload-list="false"
|
||
:custom-request="doUpload"
|
||
:before-upload="beforeUpload"
|
||
>
|
||
<div class="gallery-upload__zone">
|
||
<CloudUploadOutlined class="gallery-upload__icon" />
|
||
<p class="gallery-upload__title">{{ placeholder }}</p>
|
||
<p class="gallery-upload__hint">
|
||
تصاویر را اینجا بکشید و رها کنید یا کلیک کنید — انتخاب چند تصویر بهصورت همزمان امکانپذیر است
|
||
</p>
|
||
</div>
|
||
</a-upload-dragger>
|
||
|
||
<div v-if="items.length" class="gallery-upload__grid">
|
||
<div
|
||
v-for="item in items"
|
||
:key="item.uid"
|
||
class="tile"
|
||
:class="{ 'is-uploading': item.status === 'uploading' }"
|
||
>
|
||
<img :src="item.url ?? 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" />
|
||
</div>
|
||
|
||
<div v-else class="tile__overlay">
|
||
<button type="button" class="tile__btn" title="نمایش" @click="preview(item)">
|
||
<EyeOutlined />
|
||
</button>
|
||
<button type="button" class="tile__btn tile__btn--danger" title="حذف" @click="remove(item.uid)">
|
||
<DeleteOutlined />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="items.length" class="gallery-upload__meta">
|
||
<span>{{ doneCount }} تصویر</span>
|
||
<span v-if="pendingCount" class="gallery-upload__pending">
|
||
<LoadingOutlined /> در حال بارگذاری {{ pendingCount }} تصویر…
|
||
</span>
|
||
</div>
|
||
|
||
<a-modal :open="previewVisible" :footer="null" @cancel="previewVisible = false">
|
||
<img :src="previewSrc" style="width: 100%" alt="" />
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
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';
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
/** Ordered list of stored file URLs. */
|
||
modelValue?: string[];
|
||
/** Free-form tag recorded on each upload, e.g. 'product.gallery'. */
|
||
purpose?: string;
|
||
placeholder?: string;
|
||
}>(),
|
||
{ modelValue: () => [], purpose: undefined, placeholder: 'افزودن تصاویر گالری' },
|
||
);
|
||
|
||
const emit = defineEmits<{ (e: 'update:modelValue', v: string[]): void }>();
|
||
|
||
const MAX_PARALLEL = 3;
|
||
|
||
interface Item {
|
||
uid: string;
|
||
status: 'uploading' | 'done';
|
||
url?: string;
|
||
/** Local object URL shown while the file is still uploading. */
|
||
preview?: string;
|
||
percent: number;
|
||
}
|
||
|
||
const items = ref<Item[]>([]);
|
||
const previewVisible = ref(false);
|
||
const previewSrc = ref('');
|
||
|
||
const doneUrls = () => items.value.filter((i) => i.status === 'done' && i.url).map((i) => i.url!);
|
||
const doneCount = computed(() => items.value.filter((i) => i.status === 'done').length);
|
||
const pendingCount = computed(() => items.value.filter((i) => i.status === 'uploading').length);
|
||
|
||
function sync() {
|
||
emit('update:modelValue', doneUrls());
|
||
}
|
||
|
||
/** Keeps the tiles in step when the parent loads an existing record. */
|
||
watch(
|
||
() => props.modelValue,
|
||
(urls) => {
|
||
if (JSON.stringify(doneUrls()) === JSON.stringify(urls ?? [])) return;
|
||
const pending = items.value.filter((i) => i.status === 'uploading');
|
||
items.value = [
|
||
...(urls ?? []).map((url) => ({ uid: url, status: 'done' as const, url, percent: 100 })),
|
||
...pending,
|
||
];
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
function beforeUpload(file: File) {
|
||
if (!file.type.startsWith('image/')) {
|
||
message.error(`«${file.name}» تصویر نیست`);
|
||
return Upload.LIST_IGNORE;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// A small queue so a 30-file drop doesn't open 30 requests at once.
|
||
let active = 0;
|
||
const queue: Array<() => void> = [];
|
||
function runQueued<T>(job: () => Promise<T>): Promise<T> {
|
||
return new Promise<T>((resolve, reject) => {
|
||
const start = () => {
|
||
active++;
|
||
job()
|
||
.then(resolve, reject)
|
||
.finally(() => {
|
||
active--;
|
||
queue.shift()?.();
|
||
});
|
||
};
|
||
if (active < MAX_PARALLEL) start();
|
||
else queue.push(start);
|
||
});
|
||
}
|
||
|
||
const find = (uid: string) => items.value.find((i) => i.uid === uid);
|
||
|
||
function doUpload({ file }: { file: File & { uid?: string } }) {
|
||
const uid = file.uid ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
items.value.push({ uid, status: 'uploading', preview: URL.createObjectURL(file), percent: 0 });
|
||
|
||
runQueued(async () => {
|
||
// removed by the user while still waiting in the queue
|
||
if (!find(uid)) return null;
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
if (props.purpose) form.append('purpose', props.purpose);
|
||
// Let the browser set the multipart boundary.
|
||
const { data } = await api.post('file/create', form, {
|
||
onUploadProgress: (e) => {
|
||
const item = find(uid);
|
||
if (item && e.total) item.percent = Math.round((e.loaded / e.total) * 100);
|
||
},
|
||
});
|
||
return data as { url?: string };
|
||
})
|
||
.then((data) => {
|
||
const item = find(uid);
|
||
if (!item || !data?.url) return;
|
||
if (item.preview) URL.revokeObjectURL(item.preview);
|
||
Object.assign(item, { status: 'done', url: data.url, preview: undefined, percent: 100 });
|
||
sync();
|
||
})
|
||
.catch((e) => {
|
||
const msg =
|
||
(e as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||
`بارگذاری «${file.name}» ناموفق بود`;
|
||
message.error(msg);
|
||
dropItem(uid);
|
||
});
|
||
}
|
||
|
||
function dropItem(uid: string) {
|
||
const idx = items.value.findIndex((i) => i.uid === uid);
|
||
if (idx === -1) return;
|
||
const [gone] = items.value.splice(idx, 1);
|
||
if (gone.preview) URL.revokeObjectURL(gone.preview);
|
||
}
|
||
|
||
function remove(uid: string) {
|
||
dropItem(uid);
|
||
sync();
|
||
}
|
||
|
||
function preview(item: Item) {
|
||
previewSrc.value = item.url ?? item.preview ?? '';
|
||
previewVisible.value = true;
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
items.value.forEach((i) => i.preview && URL.revokeObjectURL(i.preview));
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.gallery-upload {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.gallery-upload__zone {
|
||
padding: 18px 12px;
|
||
}
|
||
.gallery-upload__icon {
|
||
font-size: 16px;
|
||
color: #1c2d56;
|
||
opacity: 0.75;
|
||
}
|
||
.gallery-upload__title {
|
||
margin: 8px 0 2px;
|
||
font-weight: 500;
|
||
font-size: 14px;
|
||
color: #101828;
|
||
}
|
||
.gallery-upload__hint {
|
||
margin: 0;
|
||
font-size: 12px;
|
||
color: #8a94a6;
|
||
}
|
||
|
||
/* ── square thumbnails ── */
|
||
.gallery-upload__grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.tile {
|
||
position: relative;
|
||
aspect-ratio: 1;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
border: 1px solid #eef0f5;
|
||
background: #f5f7fb;
|
||
}
|
||
.tile img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.tile.is-uploading img {
|
||
filter: brightness(0.6);
|
||
}
|
||
.tile__progress {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
.tile__overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
background: rgba(16, 24, 40, 0.5);
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
}
|
||
.tile:hover .tile__overlay,
|
||
.tile:focus-within .tile__overlay {
|
||
opacity: 1;
|
||
}
|
||
.tile__btn {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: grid;
|
||
place-items: center;
|
||
border: none;
|
||
border-radius: 9px;
|
||
background: rgba(255, 255, 255, 0.92);
|
||
color: #1c2d56;
|
||
font-size: 15px;
|
||
cursor: pointer;
|
||
transition: transform 0.15s ease;
|
||
}
|
||
.tile__btn:hover {
|
||
transform: scale(1.08);
|
||
}
|
||
.tile__btn--danger {
|
||
color: #ea5455;
|
||
}
|
||
/* touch screens have no hover: keep the actions visible */
|
||
@media (hover: none) {
|
||
.tile__overlay {
|
||
opacity: 1;
|
||
align-items: flex-end;
|
||
justify-content: flex-end;
|
||
padding: 6px;
|
||
gap: 4px;
|
||
background: linear-gradient(to top, rgba(16, 24, 40, 0.55), transparent 55%);
|
||
}
|
||
.tile__btn {
|
||
width: 28px;
|
||
height: 28px;
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
.gallery-upload__meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
font-size: 12px;
|
||
color: #8a94a6;
|
||
}
|
||
.gallery-upload__pending {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #1c2d56;
|
||
}
|
||
</style>
|