fix: make admin media uploads reliable

This commit is contained in:
Server Migration
2026-08-16 17:03:36 +03:30
parent b64f528c0d
commit 861773b05b
9 changed files with 119 additions and 28 deletions
+18 -2
View File
@@ -3,9 +3,12 @@
<label class="file-uploader__dropzone-wrap"> <label class="file-uploader__dropzone-wrap">
<div <div
class="file-uploader__dropzone" class="file-uploader__dropzone"
:class="{ 'file-uploader__dropzone--active': isDragging }" :class="{
'file-uploader__dropzone--active': isDragging,
'file-uploader__dropzone--disabled': disabled,
}"
@dragover.prevent @dragover.prevent
@dragenter="isDragging = true" @dragenter="onDragEnter"
@dragleave="isDragging = false" @dragleave="isDragging = false"
@drop="handleDrop" @drop="handleDrop"
> >
@@ -19,6 +22,7 @@
class="file-uploader__input" class="file-uploader__input"
:accept="accept" :accept="accept"
:multiple="multiple" :multiple="multiple"
:disabled="disabled"
@change="handleFileSelect" @change="handleFileSelect"
/> />
</div> </div>
@@ -75,6 +79,7 @@ const props = defineProps({
multiple: { type: Boolean, default: true }, multiple: { type: Boolean, default: true },
maxFiles: { type: Number, default: 10 }, maxFiles: { type: Number, default: 10 },
maxSize: { type: Number, default: 0 }, maxSize: { type: Number, default: 0 },
disabled: { type: Boolean, default: false },
}) })
const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error']) const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
@@ -82,6 +87,7 @@ const emit = defineEmits(['update:modelValue', 'select', 'remove', 'error'])
const isDragging = ref(false) const isDragging = ref(false)
const handleFileSelect = (event) => { const handleFileSelect = (event) => {
if (props.disabled) return
const files = [...event.target.files] const files = [...event.target.files]
emitFiles(files) emitFiles(files)
event.target.value = '' event.target.value = ''
@@ -90,10 +96,15 @@ const handleFileSelect = (event) => {
const handleDrop = (event) => { const handleDrop = (event) => {
event.preventDefault() event.preventDefault()
isDragging.value = false isDragging.value = false
if (props.disabled) return
const files = [...event.dataTransfer.files] const files = [...event.dataTransfer.files]
emitFiles(files) emitFiles(files)
} }
const onDragEnter = () => {
if (!props.disabled) isDragging.value = true
}
const emitFiles = (files) => { const emitFiles = (files) => {
if (props.modelValue.length + files.length > props.maxFiles) { if (props.modelValue.length + files.length > props.maxFiles) {
emit('error', `حداکثر تعداد فایل مجاز ${props.maxFiles} عدد است.`) emit('error', `حداکثر تعداد فایل مجاز ${props.maxFiles} عدد است.`)
@@ -155,6 +166,11 @@ const formatFileSize = (bytes) => {
&--active { &--active {
border-color: var(--color-primary); border-color: var(--color-primary);
} }
&--disabled {
cursor: not-allowed;
opacity: 0.55;
}
} }
&__upload-icon { &__upload-icon {
+9 -3
View File
@@ -140,9 +140,15 @@ export const COURSE_CONTENT_TYPE = Object.freeze({
}) })
export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({ export const COURSE_CONTENT_TYPE_ACCEPT = Object.freeze({
video: 'video/*', video: '.mp4,.webm,video/mp4,video/webm',
voice: 'audio/*', voice: '.mp3,.wav,.m4a,.webm,audio/mpeg,audio/wav,audio/mp4,audio/webm',
text: '.pdf,.doc,.docx,.txt', text: '.pdf,application/pdf',
})
export const COURSE_CONTENT_TYPE_MAX_SIZE = Object.freeze({
video: 300 * 1024 * 1024,
voice: 50 * 1024 * 1024,
text: 50 * 1024 * 1024,
}) })
export const SESSION_TYPE = Object.freeze({ export const SESSION_TYPE = Object.freeze({
@@ -33,7 +33,7 @@
<SelectField <SelectField
v-model="form.teacherId" v-model="form.teacherId"
name="teacherId" name="teacherId"
label="استاد" label="استاد (اختیاری)"
:options="teacherOptions" :options="teacherOptions"
option-label="name" option-label="name"
option-value="id" option-value="id"
@@ -251,12 +251,12 @@ watch(
{ immediate: true } { immediate: true }
) )
const uploadMutation = useUploadMediaMutation() const coverUploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => { const onImageCropped = async (file) => {
try { try {
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' }) const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData) const response = await coverUploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
image.value = { url: payload?.url, ...payload } image.value = { url: payload?.url, ...payload }
form.value.coverMediaId = payload?.id form.value.coverMediaId = payload?.id
@@ -270,16 +270,29 @@ const onImageError = (msg) => toast.error(msg)
const addMutation = useAddAdminCourseMutation() const addMutation = useAddAdminCourseMutation()
const updateMutation = useUpdateAdminCourseMutation() const updateMutation = useUpdateAdminCourseMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value) const submitting = computed(
() =>
coverUploadMutation.isPending.value ||
addMutation.isPending.value ||
updateMutation.isPending.value
)
const onSubmit = async () => { const onSubmit = async () => {
if (coverUploadMutation.isPending.value) {
toast.info('لطفاً تا پایان بارگذاری تصویر دوره منتظر بمانید.')
return
}
const { isValid, payload } = await validate(form.value) const { isValid, payload } = await validate(form.value)
if (!isValid) return if (!isValid) return
try {
if (isEditMode.value) { if (isEditMode.value) {
await updateMutation.mutateAsync({ id: courseId.value, payload }) await updateMutation.mutateAsync({ id: courseId.value, payload })
} else { } else {
await addMutation.mutateAsync(payload) await addMutation.mutateAsync(payload)
} }
} catch {
return
}
await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all }) await queryClient.invalidateQueries({ queryKey: adminCoursesKeys.all })
router.push({ name: 'admin-courses' }) router.push({ name: 'admin-courses' })
} }
@@ -69,7 +69,12 @@ const courseIcon = gallery.courseIcon
const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null)) const routeTermId = computed(() => (route.params.termId ? Number(route.params.termId) : null))
const onAdd = () => { const onAdd = () => {
router.push({ name: 'admin-add-course' }).catch(() => {}) router
.push({
name: 'admin-add-course',
query: routeTermId.value ? { termId: routeTermId.value } : undefined,
})
.catch(() => {})
} }
const tabs = [ const tabs = [
+2 -1
View File
@@ -2,7 +2,8 @@ import { array, boolean, mixed, number, object, string } from 'yup'
export const courseSchema = object().shape({ export const courseSchema = object().shape({
title: string().required().min(3).max(255), title: string().required().min(3).max(255),
teacherId: mixed().required(), // The backend intentionally allows courses without an assigned teacher.
teacherId: mixed().nullable().notRequired(),
capacity: number() capacity: number()
.typeError('ظرفیت باید به‌صورت عدد وارد شود') .typeError('ظرفیت باید به‌صورت عدد وارد شود')
.required() .required()
@@ -95,7 +95,8 @@
:accept="contentAccept" :accept="contentAccept"
:multiple="false" :multiple="false"
:max-files="1" :max-files="1"
:disabled="!form.contentType" :max-size="contentMaxSize"
:disabled="!form.contentType || contentUploadMutation.isPending.value"
@select="onContentSelect" @select="onContentSelect"
@remove="onContentRemove" @remove="onContentRemove"
@error="onContentError" @error="onContentError"
@@ -162,7 +163,12 @@ import TextareaField from '@/components/form/TextareaField.vue'
import { sessionSchema } from '@/features/admin/sessions/schema' import { sessionSchema } from '@/features/admin/sessions/schema'
import { useAdminCoursesListQuery } from '@/services/query/admin-courses' import { useAdminCoursesListQuery } from '@/services/query/admin-courses'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue' import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT, SESSION_TYPE } from '@/enums' import {
COURSE_CONTENT_TYPE,
COURSE_CONTENT_TYPE_ACCEPT,
COURSE_CONTENT_TYPE_MAX_SIZE,
SESSION_TYPE,
} from '@/enums'
import { import {
adminSessionsKeys, adminSessionsKeys,
useAddAdminSessionMutation, useAddAdminSessionMutation,
@@ -175,6 +181,7 @@ const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null)) const sessionId = computed(() => (route.params.id ? Number(route.params.id) : null))
const presetCourseId = computed(() => (route.query.courseId ? Number(route.query.courseId) : null))
const isEditMode = computed(() => !!sessionId.value) const isEditMode = computed(() => !!sessionId.value)
const sessionTypeOptions = [ const sessionTypeOptions = [
@@ -189,7 +196,7 @@ const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, labe
const form = ref({ const form = ref({
title: '', title: '',
courseId: '', courseId: presetCourseId.value ?? '',
contentType: '', contentType: '',
type: '', type: '',
link: '', link: '',
@@ -202,6 +209,9 @@ const coverMediaId = ref(null)
const contentMediaId = ref(null) const contentMediaId = ref(null)
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*') const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const contentMaxSize = computed(
() => COURSE_CONTENT_TYPE_MAX_SIZE[form.value.contentType] || 50 * 1024 * 1024
)
const { validate, validateAt, errors } = useYup(sessionSchema) const { validate, validateAt, errors } = useYup(sessionSchema)
@@ -276,7 +286,8 @@ watch(
{ immediate: true } { immediate: true }
) )
const uploadMutation = useUploadMediaMutation() const contentUploadMutation = useUploadMediaMutation()
const coverUploadMutation = useUploadMediaMutation()
const purposeForContentType = (contentType) => { const purposeForContentType = (contentType) => {
if (contentType === 'video') return 'video' if (contentType === 'video') return 'video'
@@ -293,7 +304,7 @@ const onContentSelect = async (files) => {
purpose: purposeForContentType(form.value.contentType), purpose: purposeForContentType(form.value.contentType),
context: 'session', context: 'session',
}) })
const response = await uploadMutation.mutateAsync(formData) const response = await contentUploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId const id = payload?.id ?? payload?.uploadId
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }] contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
@@ -313,7 +324,7 @@ const onContentError = (msg) => toast.error(msg)
const onImageCropped = async (file) => { const onImageCropped = async (file) => {
try { try {
const formData = objectToFormData({ file, purpose: 'cover', context: 'session' }) const formData = objectToFormData({ file, purpose: 'cover', context: 'session' })
const response = await uploadMutation.mutateAsync(formData) const response = await coverUploadMutation.mutateAsync(formData)
const payload = response?.data ?? response const payload = response?.data ?? response
image.value = { url: payload?.url, ...payload } image.value = { url: payload?.url, ...payload }
coverMediaId.value = payload?.id ?? null coverMediaId.value = payload?.id ?? null
@@ -346,17 +357,35 @@ const buildPayload = (values) => {
const addMutation = useAddAdminSessionMutation() const addMutation = useAddAdminSessionMutation()
const updateMutation = useUpdateAdminSessionMutation() const updateMutation = useUpdateAdminSessionMutation()
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value) const submitting = computed(
() =>
contentUploadMutation.isPending.value ||
coverUploadMutation.isPending.value ||
addMutation.isPending.value ||
updateMutation.isPending.value
)
const onSubmit = async () => { const onSubmit = async () => {
if (contentUploadMutation.isPending.value || coverUploadMutation.isPending.value) {
toast.info('لطفاً تا پایان بارگذاری محتوای جلسه منتظر بمانید.')
return
}
const { isValid } = await validate(form.value) const { isValid } = await validate(form.value)
if (!isValid) return if (!isValid) return
if (form.value.contentType && contentMediaId.value == null) {
toast.error('برای نوع محتوای انتخاب‌شده، فایل جلسه را بارگذاری کنید.')
return
}
const payload = buildPayload(form.value) const payload = buildPayload(form.value)
try {
if (isEditMode.value) { if (isEditMode.value) {
await updateMutation.mutateAsync({ id: sessionId.value, payload }) await updateMutation.mutateAsync({ id: sessionId.value, payload })
} else { } else {
await addMutation.mutateAsync(payload) await addMutation.mutateAsync(payload)
} }
} catch {
return
}
// The list query is inactive while we're on the form page, and the global // The list query is inactive while we're on the form page, and the global
// `refetchOnMount: false` means navigating back won't refetch a merely-stale // `refetchOnMount: false` means navigating back won't refetch a merely-stale
// query — so force a refetch of all matching queries, active or not. // query — so force a refetch of all matching queries, active or not.
@@ -109,7 +109,12 @@ const onFilterApply = () => resetPagination()
const onFilterReset = () => resetPagination() const onFilterReset = () => resetPagination()
const onAdd = () => { const onAdd = () => {
router.push({ name: 'admin-add-session' }).catch(() => {}) router
.push({
name: 'admin-add-session',
query: routeCourseId.value ? { courseId: routeCourseId.value } : undefined,
})
.catch(() => {})
} }
const onEdit = (session) => { const onEdit = (session) => {
+5 -1
View File
@@ -43,4 +43,8 @@ export const apiSaveRegisterData = (data) => http.post(endpoints.registerData, {
export const apiGetRegistrationQuestionVideo = () => export const apiGetRegistrationQuestionVideo = () =>
http.get(endpoints.getRegistrationQuestionVideo) http.get(endpoints.getRegistrationQuestionVideo)
export const apiUploadMedia = (formData) => http.post(endpoints.uploadMedia, formData) // Media uploads (especially course videos) can legitimately take several
// minutes on slower connections. Keep the normal API timeout short, but give
// this endpoint enough time to complete the backend's 300 MB upload contract.
export const apiUploadMedia = (formData) =>
http.post(endpoints.uploadMedia, formData, { timeout: 10 * 60 * 1000 })
+12
View File
@@ -24,6 +24,18 @@ export function handleApiError(error_) {
const data = error_?.response?.data const data = error_?.response?.data
const status = error_?.response?.status const status = error_?.response?.status
if (error_?.code === 'ECONNABORTED') {
toast.error(
'زمان بارگذاری فایل به پایان رسید. لطفاً اتصال اینترنت را بررسی و دوباره تلاش کنید.'
)
return
}
if (status === 413) {
toast.error('حجم فایل از حداکثر مجاز بیشتر است.')
return
}
if (status === 422 && data?.errors) { if (status === 422 && data?.errors) {
Object.values(data.errors).forEach((messages) => { Object.values(data.errors).forEach((messages) => {
const list = Array.isArray(messages) ? messages : [messages] const list = Array.isArray(messages) ? messages : [messages]