This commit is contained in:
sajjadtalkhabi
2026-05-15 17:12:51 +03:30
parent b8ced45375
commit c82701888a
101 changed files with 8125 additions and 96 deletions
@@ -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>