Files
banu-front/src/features/admin/exams/components/ExamQuestionBuilder.vue
T
sajjadtalkhabi d42b7aaa7b
Deploy banu-front / deploy (push) Successful in 1m21s
fix: education
2026-05-28 19:49:00 +03:30

432 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="exam-question-builder">
<div
v-for="(question, questionIndex) in questions"
:key="question.id"
class="exam-question-builder__card"
:class="{ 'exam-question-builder__card--readonly': isLocked(question) }"
>
<div class="exam-question-builder__head">
<p class="exam-question-builder__title">
سوال شماره {{ questionIndex + 1 }}
<span v-if="isLocked(question)" class="exam-question-builder__lock">(ذخیره شده)</span>
</p>
<button
type="button"
:disabled="disabled || isLocked(question)"
class="exam-question-builder__remove"
aria-label="حذف سوال"
@click="removeQuestion(question.id)"
>
<SvgIcon name="close" :size="20" color="#b1b1b1" />
</button>
</div>
<div class="exam-question-builder__row">
<div class="exam-question-builder__col exam-question-builder__col--main">
<label class="exam-question-builder__label">متن سوال</label>
<textarea
:value="question.questionText"
:disabled="disabled || isLocked(question)"
rows="1"
placeholder="لطفا سوال خود را وارد کنید"
class="exam-question-builder__textarea"
@input="updateQuestion(question.id, { questionText: $event.target.value })"
/>
</div>
<div class="exam-question-builder__col exam-question-builder__col--score">
<label class="exam-question-builder__label">بارم نمره</label>
<input
:value="question.score"
:disabled="disabled || isLocked(question)"
type="text"
inputmode="numeric"
placeholder="1"
class="exam-question-builder__input"
@input="updateQuestion(question.id, { score: $event.target.value })"
/>
</div>
</div>
<div class="exam-question-builder__answers-row">
<div class="exam-question-builder__answers">
<p class="exam-question-builder__label">گزینه‌ها</p>
<div class="exam-question-builder__answers-list">
<div
v-for="option in question.options"
:key="option.id"
class="exam-question-builder__answer"
>
<input
:value="option.optionText"
:disabled="disabled || isLocked(question)"
type="text"
placeholder="متن گزینه"
class="exam-question-builder__answer-input"
@input="updateOption(question.id, option.id, $event.target.value)"
/>
<button
type="button"
:disabled="disabled || isLocked(question)"
class="exam-question-builder__answer-remove"
aria-label="حذف گزینه"
@click="removeOption(question.id, option.id)"
>
<SvgIcon name="close" :size="14" color="#b1b1b1" />
</button>
</div>
<button
type="button"
:disabled="disabled || isLocked(question)"
class="exam-question-builder__answer-add"
aria-label="افزودن گزینه"
@click="addOption(question.id)"
>
<SvgIcon name="plus" :size="16" color="#b1b1b1" />
</button>
</div>
</div>
<div class="exam-question-builder__correct">
<SelectField
:model-value="correctOptionId(question)"
:name="`correctAnswer-${question.id}`"
:options="correctOptions(question)"
option-label="label"
option-value="value"
label="گزینه صحیح"
:disabled="disabled || isLocked(question) || question.options.length === 0"
@update:model-value="(value) => setCorrectOption(question.id, value)"
/>
</div>
</div>
</div>
<button
type="button"
:disabled="disabled"
class="exam-question-builder__add"
aria-label="افزودن سوال"
@click="addQuestion"
>
<SvgIcon name="plus" :size="20" color="#b1b1b1" />
</button>
</div>
</template>
<script setup>
import { computed } from 'vue'
import SvgIcon from '@/components/icons/SvgIcon.vue'
import SelectField from '@/components/form/SelectField.vue'
const props = defineProps({
modelValue: { type: Array, default: () => [] },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue'])
const defaultLabels = ['گزینه اول', 'گزینه دوم', 'گزینه سوم', 'گزینه چهارم']
const questions = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []))
// Backend has no PATCH/DELETE for questions/options — once a question came
// from the server (numeric id, no `__local`), the form locks editing it.
const isLocked = (question) => question?.__local !== true
const cloneQuestions = () =>
questions.value.map((q) => ({
...q,
options: Array.isArray(q.options) ? q.options.map((o) => ({ ...o })) : [],
}))
const createId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
const createOption = () => ({ id: createId('option'), optionText: '', isCorrect: false })
const createQuestion = () => ({
id: createId('question'),
questionText: '',
score: '',
options: [createOption(), createOption()],
__local: true,
})
const emitQuestions = (next) => emit('update:modelValue', next)
const addQuestion = () => emitQuestions([...cloneQuestions(), createQuestion()])
const removeQuestion = (questionId) => {
const next = cloneQuestions().filter((q) => q.id !== questionId)
emitQuestions(next.length > 0 ? next : [createQuestion()])
}
const updateQuestion = (questionId, patch) => {
const next = cloneQuestions().map((q) => (q.id === questionId ? { ...q, ...patch } : q))
emitQuestions(next)
}
const addOption = (questionId) => {
const next = cloneQuestions().map((q) => {
if (q.id !== questionId) return q
return { ...q, options: [...q.options, createOption()] }
})
emitQuestions(next)
}
const removeOption = (questionId, optionId) => {
const next = cloneQuestions().map((q) => {
if (q.id !== questionId) return q
const options = q.options.filter((o) => o.id !== optionId)
return { ...q, options }
})
emitQuestions(next)
}
const updateOption = (questionId, optionId, optionText) => {
const next = cloneQuestions().map((q) => {
if (q.id !== questionId) return q
return {
...q,
options: q.options.map((o) => (o.id === optionId ? { ...o, optionText } : o)),
}
})
emitQuestions(next)
}
const setCorrectOption = (questionId, optionId) => {
const next = cloneQuestions().map((q) => {
if (q.id !== questionId) return q
return {
...q,
options: q.options.map((o) => ({ ...o, isCorrect: String(o.id) === String(optionId) })),
}
})
emitQuestions(next)
}
const correctOptionId = (question) => question.options.find((o) => o.isCorrect)?.id ?? null
const optionLabel = (index) => defaultLabels[index] || `گزینه ${index + 1}`
const correctOptions = (question) =>
question.options.map((o, idx) => ({ value: o.id, label: optionLabel(idx) }))
</script>
<style lang="scss" scoped>
.exam-question-builder {
display: flex;
flex-direction: column;
gap: 0.75rem;
&__card {
border: 1px solid #e7e2dd;
border-radius: 1.5rem;
padding: 0.875rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 2.5%);
&--readonly {
background: rgba(0, 0, 0, 2%);
}
}
&__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
&__title {
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-weight: 500;
color: #4b4b4b;
margin: 0;
}
&__lock {
font-size: 0.7rem;
color: #9c9c9c;
margin-inline-start: 0.25rem;
}
&__remove {
width: 2rem;
height: 2rem;
border-radius: 9999px;
border: none;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f8f8f8;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__row {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
@media (min-width: 1024px) {
grid-template-columns: 5fr 1fr;
}
}
&__col--main {
min-width: 0;
}
&__label {
display: block;
font-family: var(--font-family-fa);
font-size: 0.875rem;
font-weight: 300;
color: var(--color-prim-gray);
margin-bottom: 0.25rem;
}
&__textarea,
&__input {
width: 100%;
border: 1px solid var(--color-thd-gray);
border-radius: 1.5rem;
padding: 0.5rem 1.25rem;
font-family: var(--font-family-fa);
font-size: 0.9rem;
color: #4b4b4b;
outline: none;
background: transparent;
&:focus {
box-shadow: 0 0 0 1px var(--color-validate, var(--color-primary));
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
&__textarea {
resize: none;
min-height: 2.35rem;
}
&__input {
height: 2.35rem;
font-family: var(--font-family-en);
}
&__answers-row {
margin-top: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
@media (min-width: 1280px) {
flex-direction: row;
align-items: flex-end;
justify-content: space-between;
}
}
&__answers {
flex: 1;
min-width: 0;
}
&__answers-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
&__answer {
display: flex;
align-items: center;
gap: 0.5rem;
border: 1px solid var(--color-thd-gray);
border-radius: 1.5rem;
padding: 0.375rem 0.875rem;
min-width: 10rem;
}
&__answer-input {
border: none;
outline: none;
background: transparent;
font-family: var(--font-family-fa);
font-size: 0.875rem;
color: #4b4b4b;
width: 100%;
}
&__answer-remove,
&__answer-add {
border: none;
background: transparent;
cursor: pointer;
width: 1.5rem;
height: 1.5rem;
border-radius: 9999px;
display: flex;
align-items: center;
justify-content: center;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
&__answer-add {
width: 2rem;
height: 2rem;
background: rgba(104, 104, 104, 5%);
}
&__correct {
width: 100%;
@media (min-width: 1280px) {
width: 14rem;
}
}
&__add {
height: 3rem;
width: 100%;
border-radius: 1rem;
border: 1px dashed #e7e2dd;
background: #f7f7f7;
color: #b1b1b1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
</style>