fix: design

This commit is contained in:
sajjadtalkhabi
2026-05-19 22:53:05 +03:30
parent c82701888a
commit 8990ceaddd
44 changed files with 2678 additions and 435 deletions
@@ -119,7 +119,7 @@ import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useUploadMediaMutation } from '@/services/query/auth'
import { useAdminTermsListQuery } from '@/services/query/admin-terms'
import { offeredCourseSchema } from '@/features/admin/courses/schema'
import { useAdminCourseTemplatesListQuery } from '@/services/query/admin-course-templates'
@@ -213,11 +213,11 @@ watch(existingCourse, (course) => {
if (course.image) image.value = { url: course.image }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'course' })
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
@@ -48,13 +48,35 @@
name="defaultTeacherId"
label="استاد"
:options="teacherOptions"
option-label="label"
option-label="name"
option-value="id"
:searchable="true"
:on-search="searchTeachers"
:error="errors.defaultTeacherId"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.defaultCapacity"
name="defaultCapacity"
label="ظرفیت (نفر)"
inputmode="numeric"
:convert-digits="true"
:error="errors.defaultCapacity"
@blur="validateAt('defaultCapacity', form.defaultCapacity)"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.sessionsCount"
name="sessionsCount"
label="تعداد جلسه"
inputmode="numeric"
:convert-digits="true"
:error="errors.sessionsCount"
@blur="validateAt('sessionsCount', form.sessionsCount)"
/>
</div>
<div class="course-form__cell course-form__cell--third">
<SelectField
v-model="form.prerequisites"
@@ -69,25 +91,36 @@
/>
</div>
<div class="course-form__cell course-form__cell--third">
<TextField
v-model="form.defaultCapacity"
name="defaultCapacity"
label="ظرفیت (نفر)"
inputmode="numeric"
:convert-digits="true"
:error="errors.defaultCapacity"
@blur="validateAt('defaultCapacity', form.defaultCapacity)"
<SelectField
v-model="form.contentType"
name="contentType"
label="نوع فایل دوره"
:options="contentTypeOptions"
option-label="label"
option-value="value"
:error="errors.contentType"
@change="onContentTypeChange"
/>
</div>
<div class="course-form__cell course-form__cell--third course-form__toggle-cell">
<ToggleSwitch v-model="form.isActiveByDefault" label="دوره فعال باشد" />
</div>
<div class="course-form__cell course-form__cell--full">
<TextareaField
v-model="form.description"
name="description"
label="توضیحات"
label="توضیحات دوره"
:row="5"
:error="errors.description"
/>
</div>
<div class="course-form__cell course-form__cell--full">
<label class="course-form__uploader-label">فایل دوره</label>
<FileUploader
v-model="contentFiles"
:accept="contentAccept"
:multiple="false"
:max-files="1"
@select="onContentSelect"
@remove="onContentRemove"
@error="onContentError"
/>
</div>
</div>
@@ -134,13 +167,14 @@ import SvgIcon from '@/components/icons/SvgIcon.vue'
import TextField from '@/components/form/TextField.vue'
import SelectField from '@/components/form/SelectField.vue'
import LineTitleBlock from '@/components/LineTitleBlock.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import FileUploader from '@/components/form/FileUploader.vue'
import ImageCropper from '@/components/form/ImageCropper.vue'
import { objectToFormData } from '@/utils/object-to-formdata'
import { useUploadMediaMutation } from '@/services/query/auth'
import TextareaField from '@/components/form/TextareaField.vue'
import { useUploadTemporaryMutation } from '@/services/query/auth'
import { useAdminUsersListQuery } from '@/services/query/admin-users'
import { courseTemplateSchema } from '@/features/admin/courses/schema'
import { COURSE_CONTENT_TYPE, COURSE_CONTENT_TYPE_ACCEPT } from '@/enums'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import {
adminCourseTemplatesKeys,
@@ -155,23 +189,33 @@ const router = useRouter()
const queryClient = useQueryClient()
const courseId = computed(() => (route.params.id ? Number(route.params.id) : null))
const termId = computed(() => (route.query.termId ? Number(route.query.termId) : null))
const isEditMode = computed(() => !!courseId.value)
const form = ref({
title: '',
defaultTeacherId: '',
prerequisites: [],
defaultCapacity: '',
sessionsCount: '',
prerequisites: [],
contentType: '',
contentMediaId: null,
description: '',
imageId: null,
isActiveByDefault: false,
coverMediaId: null,
termId: termId.value,
})
const image = ref(null)
const contentFiles = ref([])
const schema = courseTemplateSchema
const contentTypeOptions = Object.entries(COURSE_CONTENT_TYPE).map(([value, label]) => ({
value,
label,
}))
const { validate, validateAt, errors } = useYup(schema)
const contentAccept = computed(() => COURSE_CONTENT_TYPE_ACCEPT[form.value.contentType] || '*')
const { validate, validateAt, errors } = useYup(courseTemplateSchema)
const teacherSearch = ref('')
const teacherFilters = computed(() => ({ name: teacherSearch.value }))
@@ -222,24 +266,37 @@ watch(existingCourse, (course) => {
form.value = {
title: course.title || '',
defaultTeacherId: teacher?.id || course.defaultTeacherId || '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
defaultCapacity: course.defaultCapacity ?? course.capacity ?? '',
sessionsCount: course.sessionsCount ?? '',
prerequisites: prereqs.map((p) => p.courseId ?? p.id).filter(Boolean),
contentType: course.contentType || '',
contentMediaId: course.contentMediaId || null,
description: course.description || '',
imageId: course.imageId || null,
isActiveByDefault: course.isActiveByDefault ?? course.isActive ?? false,
coverMediaId: course.coverMediaId || null,
termId: termId.value,
}
if (course.coverUrl) image.value = { url: course.coverUrl }
if (course.contentMedia) {
contentFiles.value = [
{
id: course.contentMedia.id,
name: course.contentMedia.name || course.contentMedia.fileName || 'file',
size: course.contentMedia.size ?? 0,
url: course.contentMedia.url,
},
]
}
if (course.image) image.value = { url: course.image }
})
const uploadMutation = useUploadTemporaryMutation()
const uploadMutation = useUploadMediaMutation()
const onImageCropped = async (file) => {
try {
const formData = objectToFormData({ file, subType: 'avatar', context: 'course' })
const formData = objectToFormData({ file, purpose: 'cover', context: 'course' })
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
image.value = { url: payload?.url, uploadId: payload?.uploadId, ...payload }
form.value.imageId = payload?.uploadId || payload?.id
image.value = { url: payload?.url, ...payload }
form.value.coverMediaId = payload?.id
} catch {
/* handled globally */
}
@@ -247,6 +304,38 @@ const onImageCropped = async (file) => {
const onImageError = (msg) => toast.error(msg)
const onContentTypeChange = () => {
contentFiles.value = []
form.value.contentMediaId = null
}
const onContentSelect = async (files) => {
const file = files?.[0]
if (!file) return
try {
const formData = objectToFormData({
file,
purpose: 'content',
context: 'course',
type: form.value.contentType,
})
const response = await uploadMutation.mutateAsync(formData)
const payload = response?.data ?? response
const id = payload?.id ?? payload?.uploadId
contentFiles.value = [{ id, name: file.name, size: file.size, url: payload?.url }]
form.value.contentMediaId = id
} catch {
contentFiles.value = []
}
}
const onContentRemove = () => {
contentFiles.value = []
form.value.contentMediaId = null
}
const onContentError = (msg) => toast.error(msg)
const addMutation = useAddAdminCourseTemplateMutation()
const updateMutation = useUpdateAdminCourseTemplateMutation()
@@ -345,9 +434,14 @@ const onCancel = () => router.push({ name: 'admin-courses' })
}
}
&__toggle-cell {
display: flex;
align-items: center;
&__uploader-label {
display: block;
margin-bottom: 0.5rem;
font-family: var(--font-family-fa);
font-weight: 300;
line-height: 1.5rem;
font-size: 0.875rem;
color: var(--color-prim-gray);
}
&__divider {
@@ -164,7 +164,7 @@ const { data: offeredData, isLoading: offeredPending } = useAdminCoursesListQuer
}
)
const templates = computed(() => templatesData.value?.data ?? [])
const templates = computed(() => templatesData.value?.data?.items ?? [])
const templatesPaginationMeta = computed(() => ({
page: templatesPagination.value.page,
perPage: templatesPagination.value.perPage,
+7 -3
View File
@@ -1,11 +1,15 @@
import { array, boolean, object, string } from 'yup'
import { array, boolean, mixed, number, object, string } from 'yup'
export const courseTemplateSchema = object().shape({
title: string().required().min(3).max(255),
defaultTeacherId: string().required(),
defaultCapacity: string().required(),
defaultTeacherId: mixed().required(),
defaultCapacity: number().required().min(1),
sessionsCount: number().required().min(1),
prerequisites: array().nullable().default([]),
contentType: string().oneOf(['video', 'voice', 'text']).required(),
contentMediaId: number().nullable().notRequired(),
description: string().nullable().notRequired(),
termId: string().nullable(),
})
export const offeredCourseSchema = object().shape({