fix
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="student-exam-attempt">
|
||||
<p class="student-exam-attempt__title">{{ attempt.title || 'آزمون' }}</p>
|
||||
|
||||
<div class="student-exam-attempt__meta">
|
||||
<div class="student-exam-attempt__stat">
|
||||
<SvgIcon name="scroll" :size="11" color="#5d5d5d" />
|
||||
<span class="student-exam-attempt__stat-label">نمره :</span>
|
||||
<span class="student-exam-attempt__stat-value">{{ scoreText }}</span>
|
||||
</div>
|
||||
<div class="student-exam-attempt__stat">
|
||||
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
|
||||
<span class="student-exam-attempt__stat-label">زمان آزمون :</span>
|
||||
<span class="student-exam-attempt__stat-value">{{ dateText }}</span>
|
||||
</div>
|
||||
<div class="student-exam-attempt__status" :class="`student-exam-attempt__status--${tone}`">
|
||||
<SvgIcon name="pencil" :size="11" :color="toneColor" />
|
||||
<span class="student-exam-attempt__status-label">وضعیت :</span>
|
||||
<span class="student-exam-attempt__status-value">{{ statusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import { formatJalaaliDate } from '@/utils/date-utils'
|
||||
|
||||
const TONE_MAP = { passed: 'success', failed: 'danger' }
|
||||
const TONE_COLOR = { success: '#007074', danger: '#740002' }
|
||||
|
||||
const props = defineProps({
|
||||
attempt: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const scoreText = computed(() => (props.attempt.score == null ? '—' : `${props.attempt.score}`))
|
||||
const dateText = computed(() => {
|
||||
if (props.attempt.faTime && props.attempt.faDate)
|
||||
return `${props.attempt.faTime} ${props.attempt.faDate}`
|
||||
if (props.attempt.faDate) return props.attempt.faDate
|
||||
return formatJalaaliDate(props.attempt.date) || '—'
|
||||
})
|
||||
const tone = computed(() => TONE_MAP[props.attempt.status] || 'success')
|
||||
const toneColor = computed(() => TONE_COLOR[tone.value] || '#5d5d5d')
|
||||
const statusLabel = computed(
|
||||
() => props.attempt.statusLabel || (tone.value === 'success' ? 'قبول شده' : 'قبول نشده')
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.student-exam-attempt {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(255, 255, 255, 58%);
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.625rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0;
|
||||
text-align: end;
|
||||
flex: 0 0 auto;
|
||||
order: 4;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(107, 107, 107, 4%);
|
||||
font-family: var(--font-family-fa);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
color: #5d5d5d;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
color: #5d5d5d;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
font-family: var(--font-family-fa);
|
||||
white-space: nowrap;
|
||||
|
||||
&--success {
|
||||
background: rgba(0, 112, 116, 4%);
|
||||
color: #007074;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: rgba(116, 0, 2, 4%);
|
||||
color: #740002;
|
||||
}
|
||||
}
|
||||
|
||||
&__status-label {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
&__status-value {
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="student-exam-item">
|
||||
<p class="student-exam-item__title">{{ exam.title || '—' }}</p>
|
||||
|
||||
<div class="student-exam-item__stats">
|
||||
<div class="student-exam-item__stat student-exam-item__stat--duration">
|
||||
<span class="student-exam-item__stat-value">{{ durationText }}</span>
|
||||
<span class="student-exam-item__stat-label">مدت آزمون :</span>
|
||||
<SvgIcon name="calendar" :size="11" color="#5d5d5d" />
|
||||
</div>
|
||||
<div class="student-exam-item__stat">
|
||||
<span class="student-exam-item__stat-label">نمره قبولی :</span>
|
||||
<span class="student-exam-item__stat-value">{{ passingScoreText }}</span>
|
||||
<SvgIcon name="users-three" :size="11" color="#5d5d5d" />
|
||||
</div>
|
||||
<div class="student-exam-item__stat">
|
||||
<span class="student-exam-item__stat-label">تعداد سوال :</span>
|
||||
<span class="student-exam-item__stat-value">{{ questionsCountText }}</span>
|
||||
<SvgIcon name="file" :size="11" color="#5d5d5d" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="student-exam-item__actions">
|
||||
<BaseButton
|
||||
text="شروع آزمون"
|
||||
custom-class="student-exam-item__btn"
|
||||
@click="emit('start', exam)"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="14" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
exam: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['start'])
|
||||
|
||||
const durationText = computed(() =>
|
||||
props.exam.durationMinutes == null ? '—' : `${props.exam.durationMinutes} دقیقه`
|
||||
)
|
||||
const passingScoreText = computed(() =>
|
||||
props.exam.passingScore == null ? '—' : `${props.exam.passingScore}`
|
||||
)
|
||||
const questionsCountText = computed(() =>
|
||||
props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}`
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.student-exam-item {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: rgba(255, 255, 255, 58%);
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.625rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1 1 18%;
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 0.95rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0;
|
||||
text-align: end;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
order: 3;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
order: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__stats {
|
||||
flex: 1 1 50%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(107, 107, 107, 4%);
|
||||
font-family: var(--font-family-fa);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__stat--duration {
|
||||
border-radius: 1.375rem;
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-weight: 300;
|
||||
font-size: 0.7rem;
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
color: #5d5d5d;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
min-width: 6rem;
|
||||
padding: 0 1rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="seq seq--full">
|
||||
<p class="seq__title">{{ orderText }}{{ question.title }}</p>
|
||||
|
||||
<div class="seq__answers">
|
||||
<CheckboxField
|
||||
v-for="answer in question.answers"
|
||||
:key="answer.id"
|
||||
:model-value="selectedId === answer.id"
|
||||
:label="answer.title"
|
||||
@change="onSelect(answer.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import CheckboxField from '@/components/form/CheckboxField.vue'
|
||||
|
||||
const props = defineProps({
|
||||
question: { type: Object, required: true },
|
||||
order: { type: Number, default: 0 },
|
||||
modelValue: { type: [String, Number, null], default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const selectedId = computed(() => props.modelValue)
|
||||
|
||||
const orderText = computed(() => (props.order ? `${props.order} - ` : ''))
|
||||
|
||||
const onSelect = (id) => {
|
||||
emit('update:modelValue', id === selectedId.value ? null : id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.seq {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem 0;
|
||||
|
||||
&--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.55;
|
||||
color: #a7a7a7;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__answers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div class="ses">
|
||||
<div class="ses__info">
|
||||
<p class="ses__title">{{ exam.title || '—' }}</p>
|
||||
|
||||
<div class="ses__stats">
|
||||
<div class="ses__stat">
|
||||
<SvgIcon name="check" :size="12" color="#5d5d5d" />
|
||||
<span class="ses__stat-label">تعداد آزمونها :</span>
|
||||
<span class="ses__stat-value">{{ attemptsCountText }}</span>
|
||||
</div>
|
||||
<div class="ses__stat">
|
||||
<SvgIcon name="scroll" :size="12" color="#5d5d5d" />
|
||||
<span class="ses__stat-label">تعداد سوال :</span>
|
||||
<span class="ses__stat-value">{{ questionsCountText }}</span>
|
||||
</div>
|
||||
<div class="ses__stat">
|
||||
<SvgIcon name="users-three" :size="12" color="#5d5d5d" />
|
||||
<span class="ses__stat-label">نمره قبولی :</span>
|
||||
<span class="ses__stat-value">{{ passingScoreText }}</span>
|
||||
</div>
|
||||
<div class="ses__stat ses__stat--pill">
|
||||
<SvgIcon name="calendar" :size="11" color="#535353" />
|
||||
<span class="ses__stat-label">مدت آزمون :</span>
|
||||
<span class="ses__stat-value">{{ durationText }}</span>
|
||||
</div>
|
||||
<div class="ses__status" :class="`ses__status--${statusTone}`">
|
||||
<span class="ses__status-label">وضعیت :</span>
|
||||
<span class="ses__status-value">{{ statusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ses__action">
|
||||
<BaseButton
|
||||
:text="startButtonText"
|
||||
:loading="starting"
|
||||
custom-class="ses__start-btn"
|
||||
@click="emit('start')"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
|
||||
const TONE_MAP = {
|
||||
passed: 'success',
|
||||
failed: 'danger',
|
||||
in_progress: 'success',
|
||||
not_started: 'neutral',
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
exam: { type: Object, required: true },
|
||||
starting: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['start'])
|
||||
|
||||
const attemptsCountText = computed(() =>
|
||||
props.exam.attemptsCount == null
|
||||
? `${(props.exam.attempts || []).length} آزمون`
|
||||
: `${props.exam.attemptsCount} آزمون`
|
||||
)
|
||||
const questionsCountText = computed(() =>
|
||||
props.exam.questionsCount == null ? '—' : `${props.exam.questionsCount}`
|
||||
)
|
||||
const passingScoreText = computed(() =>
|
||||
props.exam.passingScore == null ? '—' : `${props.exam.passingScore}`
|
||||
)
|
||||
const durationText = computed(() =>
|
||||
props.exam.durationMinutes == null ? '—' : `${props.exam.durationMinutes} دقیقه`
|
||||
)
|
||||
|
||||
const statusLabel = computed(() => props.exam.statusLabel || '—')
|
||||
const statusTone = computed(() => TONE_MAP[props.exam.status] || 'neutral')
|
||||
|
||||
const startButtonText = computed(() =>
|
||||
(props.exam.attempts || []).length > 0 ? 'شروع آزمون مجدد' : 'شروع آزمون'
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ses {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: rgba(255, 255, 255, 40%);
|
||||
box-shadow: 0 6.75px 19.425px rgba(0, 0, 0, 4%);
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 2rem;
|
||||
min-height: 9.9rem;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0;
|
||||
line-height: 1.246;
|
||||
}
|
||||
|
||||
&__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(107, 107, 107, 4%);
|
||||
font-family: var(--font-family-fa);
|
||||
white-space: nowrap;
|
||||
|
||||
&--pill {
|
||||
border-radius: 1.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__stat-label {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
color: #535353;
|
||||
}
|
||||
|
||||
&__stat-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
color: #5d5d5d;
|
||||
}
|
||||
|
||||
&__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
font-family: var(--font-family-fa);
|
||||
white-space: nowrap;
|
||||
|
||||
&--success {
|
||||
background: rgba(0, 112, 116, 4%);
|
||||
color: #007074;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background: rgba(116, 0, 2, 4%);
|
||||
color: #740002;
|
||||
}
|
||||
|
||||
&--neutral {
|
||||
background: rgba(107, 107, 107, 4%);
|
||||
color: #535353;
|
||||
}
|
||||
}
|
||||
|
||||
&__status-label {
|
||||
font-weight: 400;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
&__status-value {
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
&__start-btn {
|
||||
min-width: 12rem;
|
||||
padding: 0 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="se">
|
||||
<BoxedIconTitleBlock
|
||||
class="se__heading"
|
||||
:title="exam?.title || 'آزمون'"
|
||||
:desc="exam?.desc || 'جزئیات آزمون را مشاهده و سپس شروع آزمون را بزنید'"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="pencil" :size="24" color="var(--color-primary)" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading && !exam" :rows="2" :cols-per-row="1" />
|
||||
<StudentExamSummary
|
||||
v-else-if="exam"
|
||||
:exam="exam"
|
||||
:starting="startMutation.isPending.value"
|
||||
@start="onStart"
|
||||
/>
|
||||
|
||||
<SimpleTitleIconBlock title="تعداد تلاش ها برای این آزمون" class="se__list-title">
|
||||
<template #header-icon>
|
||||
<SvgIcon name="list-bullets" :size="16" color="#bcbcbc" />
|
||||
</template>
|
||||
</SimpleTitleIconBlock>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading && attempts.length === 0" :rows="2" :cols-per-row="1" />
|
||||
<div v-else-if="attempts.length > 0">
|
||||
<StudentExamAttemptItem v-for="attempt in attempts" :key="attempt.id" :attempt="attempt" />
|
||||
</div>
|
||||
<div v-else class="se__empty">
|
||||
<SvgIcon name="scroll" :size="128" color="#c3c3c3" />
|
||||
<p class="se__empty-title">دانشجوی گرامی</p>
|
||||
<p class="se__empty-desc">شما تاکنون هیچ آزمونی را به شروع نکرده اید.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { toast } from 'vue3-toastify'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import SimpleTitleIconBlock from '@/components/blocks/SimpleTitleIconBlock.vue'
|
||||
import StudentExamSummary from '@/features/student/exams/components/StudentExamSummary.vue'
|
||||
import StudentExamAttemptItem from '@/features/student/exams/components/StudentExamAttemptItem.vue'
|
||||
import {
|
||||
useStartStudentExamAttemptMutation,
|
||||
useStudentExamQuery,
|
||||
} from '@/services/query/student-exams'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const examId = computed(() => route.params.id)
|
||||
|
||||
const { data: exam, isLoading } = useStudentExamQuery(examId, {
|
||||
enabled: () => !!examId.value,
|
||||
})
|
||||
|
||||
const attempts = computed(() => exam.value?.attempts ?? [])
|
||||
|
||||
const startMutation = useStartStudentExamAttemptMutation()
|
||||
|
||||
const onStart = async () => {
|
||||
try {
|
||||
await startMutation.mutateAsync({ id: examId.value })
|
||||
router.push({ name: 'student-take-exam', params: { id: examId.value } })
|
||||
} catch {
|
||||
toast.error('شروع آزمون با خطا مواجه شد.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.se {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
&__heading {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
&__list-title {
|
||||
margin-block: 0.25rem;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2.5rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
&__empty-title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
color: #8e8e8e;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
&__empty-desc {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 400;
|
||||
color: #4b4b4b;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="ste">
|
||||
<div class="ste__header">
|
||||
<BoxedIconTitleBlock
|
||||
class="exam-form__heading"
|
||||
:title="exam?.title"
|
||||
desc="لطفا سوالات به دقت خوانده و جواب درست را علامت بزنید"
|
||||
>
|
||||
<template #icon>
|
||||
<SvgIcon name="pencil" :size="24" color="var(--color-primary)" />
|
||||
</template>
|
||||
</BoxedIconTitleBlock>
|
||||
|
||||
<div class="ste__timer">
|
||||
<span class="ste__timer-label">زمان باقی مانده :</span>
|
||||
<span class="ste__timer-value">{{ timerText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkeletonLoaderBlock v-if="isLoading && !exam" :rows="3" :cols-per-row="2" />
|
||||
|
||||
<div v-else class="ste__body">
|
||||
<div class="ste__questions">
|
||||
<StudentExamQuestion
|
||||
v-for="(question, index) in questions"
|
||||
:key="question.id"
|
||||
:question="question"
|
||||
:order="index + 1"
|
||||
:model-value="answers[question.id] ?? null"
|
||||
@update:model-value="(value) => onAnswer(question.id, value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ste__actions">
|
||||
<BaseButton
|
||||
text="اتمام آزمون"
|
||||
:loading="submitMutation.isPending.value"
|
||||
custom-class="ste__submit"
|
||||
@click="onSubmit"
|
||||
>
|
||||
<template #appendIcon>
|
||||
<SvgIcon name="arrow-left" :size="18" color="#fff" />
|
||||
</template>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { toast } from 'vue3-toastify'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import SvgIcon from '@/components/icons/SvgIcon.vue'
|
||||
import SkeletonLoaderBlock from '@/components/blocks/SkeletonLoaderBlock.vue'
|
||||
import BoxedIconTitleBlock from '@/components/blocks/BoxedIconTitleBlock.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import StudentExamQuestion from '@/features/student/exams/components/StudentExamQuestion.vue'
|
||||
import {
|
||||
useStudentExamQuery,
|
||||
useSubmitStudentExamAttemptMutation,
|
||||
} from '@/services/query/student-exams'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const examId = computed(() => route.params.id)
|
||||
|
||||
const { data: exam, isLoading } = useStudentExamQuery(examId, {
|
||||
enabled: () => !!examId.value,
|
||||
})
|
||||
|
||||
const questions = computed(() => exam.value?.questions ?? [])
|
||||
|
||||
const answers = reactive({})
|
||||
|
||||
const onAnswer = (questionId, value) => {
|
||||
if (value === null || value === undefined) delete answers[questionId]
|
||||
else answers[questionId] = value
|
||||
}
|
||||
|
||||
const remaining = ref(0)
|
||||
let intervalId = null
|
||||
|
||||
const startTimer = (minutes) => {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
remaining.value = Math.max(0, Math.floor(minutes * 60))
|
||||
intervalId = setInterval(() => {
|
||||
if (remaining.value <= 0) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
onTimeout()
|
||||
return
|
||||
}
|
||||
remaining.value -= 1
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => exam.value?.durationMinutes,
|
||||
(minutes) => {
|
||||
if (minutes != null) startTimer(minutes)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
})
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const timerText = computed(() => {
|
||||
const total = remaining.value
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const s = total % 60
|
||||
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
|
||||
})
|
||||
|
||||
const submitMutation = useSubmitStudentExamAttemptMutation()
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await submitMutation.mutateAsync({
|
||||
id: examId.value,
|
||||
payload: { answers: { ...answers } },
|
||||
})
|
||||
toast.success('پاسخهای شما با موفقیت ثبت شد.')
|
||||
router.push({ name: 'student-exam', params: { id: examId.value } })
|
||||
} catch {
|
||||
toast.error('ثبت پاسخها با خطا مواجه شد.')
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
if (Object.keys(answers).length === 0) {
|
||||
toast.warning('حداقل به یک سوال پاسخ دهید.')
|
||||
return
|
||||
}
|
||||
submit()
|
||||
}
|
||||
|
||||
const onTimeout = () => {
|
||||
toast.info('زمان آزمون به پایان رسید.')
|
||||
submit()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!examId.value) router.push({ name: 'student-dashboard' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ste {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
&__heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.4rem;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: #4b4b4b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
font-family: var(--font-family-fa);
|
||||
font-weight: 300;
|
||||
font-size: 0.8125rem;
|
||||
color: #8c8c8c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__timer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(245, 93, 109, 8%);
|
||||
align-self: flex-end;
|
||||
font-family: var(--font-family-fa);
|
||||
white-space: nowrap;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__timer-label {
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: #f55d6d;
|
||||
}
|
||||
|
||||
&__timer-value {
|
||||
font-family: var(--font-family-en);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
color: #f55d6d;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
&__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
&__questions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem 2rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-thd-gray);
|
||||
}
|
||||
|
||||
&__submit {
|
||||
min-width: 14rem;
|
||||
padding: 0 2rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user