chore: commit pending admin-panel, api auth and parity doc changes
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Haiku 5.5
parent
55856cac89
commit
cf014e1f6c
@@ -0,0 +1,323 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user