This commit is contained in:
sajjadtalkhabi
2026-05-21 15:51:32 +03:30
parent 8990ceaddd
commit 74227d5021
71 changed files with 2014 additions and 1666 deletions
+90 -83
View File
@@ -31,32 +31,17 @@
:error="errors.title"
@blur="validateAt('title', form.title)"
/>
<TextField
v-model="form.durationMinutes"
name="durationMinutes"
label="مدت آزمون"
inputmode="numeric"
:convert-digits="true"
:error="errors.durationMinutes"
@blur="validateAt('durationMinutes', form.durationMinutes)"
/>
<TextField
v-model="form.passingScore"
name="passingScore"
label="حداقل نمره قبولی"
label="حد نصاب قبولی"
inputmode="numeric"
:convert-digits="true"
:error="errors.passingScore"
@blur="validateAt('passingScore', form.passingScore)"
/>
<DatePickerField
v-model="form.endDate"
name="endDate"
label="تاریخ اعتبار"
:error="errors.endDate"
/>
<div class="exam-form__toggle-cell">
<ToggleSwitch v-model="form.randomize" label="به صورت رندوم باشد" />
<ToggleSwitch v-model="form.isActive" label="آزمون فعال باشد" />
</div>
</div>
</div>
@@ -116,13 +101,13 @@ import { examSchema } from '@/features/admin/exams/schema'
import SelectField from '@/components/form/SelectField.vue'
import ToggleSwitch from '@/components/form/ToggleSwitch.vue'
import TextareaField from '@/components/form/TextareaField.vue'
import DatePickerField from '@/components/form/DatePickerField.vue'
import { useAdminSessionsListQuery } from '@/services/query/admin-sessions'
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
import ExamQuestionBuilder from '@/features/admin/exams/components/ExamQuestionBuilder.vue'
import {
adminExamsKeys,
useAddAdminExamMutation,
useAddAdminExamQuestionMutation,
useAdminExamQuery,
useUpdateAdminExamMutation,
} from '@/services/query/admin-exams'
@@ -137,32 +122,27 @@ const isEditMode = computed(() => !!examId.value)
const form = ref({
title: '',
sessionId: '',
endDate: '',
durationMinutes: '',
passingScore: '',
randomize: true,
isActive: true,
description: '',
})
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
const blankOption = () => ({ id: createId('option'), optionText: '', isCorrect: false })
const blankQuestion = () => ({
id: createId('question'),
title: '',
score: '',
correctAnswerId: null,
answers: [
{ id: createId('answer'), title: '' },
{ id: createId('answer'), title: '' },
],
questionText: '',
position: 1,
options: [blankOption(), { ...blankOption(), id: createId('option') }],
__local: true,
})
const questions = ref([blankQuestion()])
const questionError = ref('')
const schema = examSchema
const { validate, validateAt, errors } = useYup(schema)
const { validate, validateAt, errors } = useYup(examSchema)
const sessionSearch = ref('')
const sessionFilters = computed(() => ({ title: sessionSearch.value }))
@@ -185,20 +165,20 @@ const { data: existingExam } = useAdminExamQuery(examId, {
enabled: () => !!examId.value,
})
const normalizeQuestions = (raw = []) => {
const normalizeExistingQuestions = (raw = []) => {
if (!Array.isArray(raw) || raw.length === 0) return [blankQuestion()]
return raw.map((q, qIdx) => {
const answersSrc = q.answers || q.options || q.choices || []
const answers = (Array.isArray(answersSrc) ? answersSrc : []).map((a, aIdx) => ({
id: a?.id || createId(`answer-${qIdx}-${aIdx}`),
title: typeof a === 'string' ? a : a?.title || a?.text || a?.label || '',
}))
const options = Array.isArray(q.options) ? q.options : []
return {
id: q.id || createId(`question-${qIdx}`),
title: q.title || q.question || q.text || '',
score: q.score ?? q.barom ?? '',
correctAnswerId: q.correctAnswerId || q.correctOptionId || q.correctAnswer?.id || null,
answers: answers.length > 0 ? answers : blankQuestion().answers,
id: q.id ?? createId(`question-${qIdx}`),
questionText: q.questionText || '',
position: q.position ?? qIdx + 1,
options: options.map((o, oIdx) => ({
id: o.id ?? createId(`option-${qIdx}-${oIdx}`),
optionText: o.optionText || '',
isCorrect: !!o.isCorrect,
})),
// No `__local` flag — these came from the server, so the builder will lock them.
}
})
}
@@ -209,62 +189,89 @@ watch(existingExam, (exam) => {
form.value = {
title: exam.title || '',
sessionId: exam.session?.id || exam.sessionId || '',
endDate: exam.endDate || '',
durationMinutes: exam.durationMinutes ?? '',
passingScore: exam.passingScore ?? '',
randomize: exam.randomize ?? true,
passingScore: exam.passScore ?? '',
isActive: exam.isActive ?? true,
description: exam.description || '',
}
questions.value = normalizeQuestions(exam.questions)
questions.value = normalizeExistingQuestions(exam.questions)
})
const validateQuestionList = () => {
const list = questions.value
if (list.some((q) => !String(q.title || '').trim() || !String(q.score || '').trim())) {
questionError.value = 'لطفا متن سوال و بارم هر سوال را وارد کنید.'
const validateLocalQuestions = () => {
const localOnes = questions.value.filter((q) => q.__local === true)
if (!isEditMode.value && localOnes.length === 0) {
questionError.value = 'حداقل یک سوال اضافه کنید.'
return null
}
if (list.some((q) => q.answers.filter((a) => a.title?.trim()).length < 2)) {
questionError.value = 'برای هر سوال حداقل دو گزینه وارد کنید.'
return null
}
if (
list.some((q) => {
if (!q.correctAnswerId) return true
return !q.answers.some((a) => String(a.id) === String(q.correctAnswerId) && a.title?.trim())
})
) {
questionError.value = 'گزینه صحیح هر سوال را از گزینه‌های موجود انتخاب کنید.'
return null
for (const q of localOnes) {
if (!String(q.questionText || '').trim()) {
questionError.value = 'متن همه سوالات را وارد کنید.'
return null
}
const validOptions = q.options.filter((o) => String(o.optionText || '').trim())
if (validOptions.length < 2) {
questionError.value = 'برای هر سوال حداقل دو گزینه وارد کنید.'
return null
}
if (!validOptions.some((o) => o.isCorrect)) {
questionError.value = 'گزینه صحیح هر سوال را انتخاب کنید.'
return null
}
}
questionError.value = ''
return list.map((q) => ({
id: q.id,
title: q.title.trim(),
score: q.score,
correctAnswerId: q.correctAnswerId,
answers: q.answers.filter((a) => a.title?.trim()).map((a) => ({ id: a.id, title: a.title })),
return localOnes.map((q, idx) => ({
questionText: q.questionText.trim(),
position: Number(q.position) || idx + 1,
options: q.options
.filter((o) => String(o.optionText || '').trim())
.map((o) => ({
optionText: o.optionText.trim(),
isCorrect: !!o.isCorrect,
})),
}))
}
const addMutation = useAddAdminExamMutation()
const updateMutation = useUpdateAdminExamMutation()
const buildExamPayload = (values) => ({
sessionId: values.sessionId,
title: values.title,
description: values.description,
passingScore: Number(values.passingScore) || 0,
isActive: values.isActive,
})
const submitting = computed(() => addMutation.isPending.value || updateMutation.isPending.value)
const addExamMutation = useAddAdminExamMutation()
const updateExamMutation = useUpdateAdminExamMutation()
const addQuestionMutation = useAddAdminExamQuestionMutation()
const submitting = computed(
() =>
addExamMutation.isPending.value ||
updateExamMutation.isPending.value ||
addQuestionMutation.isPending.value
)
const postQuestionsSequentially = async (id, list) => {
for (const payload of list) {
// Sequential so question position ordering is preserved on the backend.
// eslint-disable-next-line no-await-in-loop
await addQuestionMutation.mutateAsync({ examId: id, payload })
}
}
const onSubmit = async () => {
const { isValid, payload } = await validate(form.value)
const cleanQuestions = validateQuestionList()
if (!isValid || !cleanQuestions) return
const finalPayload = {
...payload,
questions: cleanQuestions,
questionsCount: cleanQuestions.length,
}
const { isValid } = await validate(form.value)
const newQuestions = validateLocalQuestions()
if (!isValid || !newQuestions) return
const examPayload = buildExamPayload(form.value)
let targetExamId = examId.value
if (isEditMode.value) {
await updateMutation.mutateAsync({ id: examId.value, payload: finalPayload })
await updateExamMutation.mutateAsync({ id: targetExamId, payload: examPayload })
} else {
await addMutation.mutateAsync(finalPayload)
const created = await addExamMutation.mutateAsync(examPayload)
targetExamId = created?.data?.id ?? created?.id ?? targetExamId
}
if (targetExamId && newQuestions.length > 0) {
await postQuestionsSequentially(targetExamId, newQuestions)
}
await queryClient.invalidateQueries({ queryKey: adminExamsKeys.all })
router.push({ name: 'admin-exams' })
@@ -306,7 +313,7 @@ const onCancel = () => router.push({ name: 'admin-exams' })
}
@media (min-width: 1280px) {
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(4, 1fr);
}
}