Files
gold_land_user/src/modules/gold-land/components/castle-exam/CastleExamPlayer.vue
T
safavimohadeseh78andCursor c53a67831d
Frontend CI / unit (test, type-check, build) (push) Successful in 7m21s
Frontend CI / browser E2E (frontend + pinned backend) (push) Successful in 11m38s
feat: complete the thirteen-stage learning experience and E2E journey
Play levels 7-13 in the app and pin E2E to the local thirteen-stage backend commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 19:42:04 +03:30

217 lines
10 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.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import PrimaryButton from '@/components/common/PrimaryButton.vue'
import { illustrations } from '@/assets/illustrations'
import { ApiClientError, ApiNetworkError, StaleSessionError } from '@/api/errors'
import { runMeMutation } from '@/api/sync'
import { goldLandApi } from '@/modules/gold-land/services/goldLand.api'
import { clearExamAnswers, sectionIds, unansweredCount } from '@/modules/gold-land/castle-exam/castleState'
import { setRiskAnswer } from '@/modules/gold-land/risk-return/riskChoices'
import type { CastleExamContent, CompleteLevelResponse } from '@/modules/gold-land/types/goldLand.types'
const props = defineProps<{ levelId: number }>()
const emit = defineEmits<{ backToMap: [focusLevel?: number] }>()
const router = useRouter()
type Phase = 'intro' | 'questions' | 'review' | 'result'
const loading = ref(true)
const error = ref('')
const submitError = ref('')
const submitting = ref(false)
const phase = ref<Phase>('intro')
const exam = ref<CastleExamContent | null>(null)
const sectionIndex = ref(0)
const questionIndex = ref(0)
const answers = ref<Record<string, string>>({})
const result = ref<CompleteLevelResponse | null>(null)
const sections = computed(() => sectionIds(exam.value?.questions ?? []).map((id) => exam.value?.sections.find((section) => section.id === id) ?? { id, title: id }))
const sectionQuestions = computed(() => exam.value?.questions.filter((question) => question.sectionId === sections.value[sectionIndex.value]?.id) ?? [])
const current = computed(() => sectionQuestions.value[questionIndex.value])
const answered = computed(() => (exam.value?.questions.length ?? 0) - unansweredCount(exam.value?.questions.map((question) => question.id) ?? [], answers.value))
const feedback = computed(() => {
const value = result.value?.feedback
if (!value || !('completed' in value)) return null
return value
})
const passed = computed(() => (result.value?.score ?? 0) >= 60 && feedback.value?.completed === true)
function resetAnswers() {
answers.value = clearExamAnswers()
result.value = null
sectionIndex.value = 0
questionIndex.value = 0
phase.value = 'questions'
submitError.value = ''
}
async function loadExam() {
loading.value = true
error.value = ''
result.value = null
phase.value = 'intro'
try {
const detail = await goldLandApi.getLevel(props.levelId)
if (detail.level.status === 'locked') {
error.value = 'این مرحله هنوز قفل است.'
return
}
if (detail.level.status === 'coming_soon') {
error.value = 'این مرحله به‌زودی باز می‌شود.'
return
}
if (detail.activity?.type !== 'castle-exam' || !detail.activity.castleExam) {
error.value = 'محتوای این مرحله در دسترس نیست.'
return
}
await goldLandApi.startLevel(props.levelId)
exam.value = detail.activity.castleExam
} catch (err) {
error.value = err instanceof Error ? err.message : 'خطا در دریافت آزمون'
} finally {
loading.value = false
}
}
function goPrev() {
if (questionIndex.value > 0) {
questionIndex.value -= 1
return
}
if (sectionIndex.value === 0) return
sectionIndex.value -= 1
questionIndex.value = Math.max(0, sectionQuestions.value.length - 1)
}
function editQuestion(questionId: string) {
const question = exam.value?.questions.find((item) => item.id === questionId)
if (!question) return
sectionIndex.value = sections.value.findIndex((section) => section.id === question.sectionId)
questionIndex.value = (exam.value?.questions.filter((item) => item.sectionId === question.sectionId).findIndex((item) => item.id === questionId)) ?? 0
phase.value = 'questions'
}
function nextQuestion() {
if (!current.value || !answers.value[current.value.id]) return
if (questionIndex.value < sectionQuestions.value.length - 1) {
questionIndex.value += 1
return
}
if (sectionIndex.value < sections.value.length - 1) {
sectionIndex.value += 1
questionIndex.value = 0
return
}
phase.value = 'review'
}
async function submitExam() {
if (!exam.value || submitting.value) return
if (unansweredCount(exam.value.questions.map((question) => question.id), answers.value) > 0) {
submitError.value = 'هنوز به همه سؤال‌ها پاسخ نداده‌ای.'
return
}
submitting.value = true
submitError.value = ''
try {
const response = await runMeMutation(
() => goldLandApi.completeLevel(props.levelId, exam.value!.questions.map((question) => ({
questionId: question.id,
answerId: answers.value[question.id] ?? '',
}))),
(value) => value.me,
)
result.value = response
phase.value = 'result'
} catch (err) {
if (err instanceof StaleSessionError) {
resetAnswers()
return
}
submitError.value = err instanceof ApiNetworkError
? 'اتصال برقرار نشد. پاسخ‌ها حفظ شده‌اند. دوباره بفرست.'
: err instanceof ApiClientError
? err.message
: err instanceof Error
? err.message
: 'ارسال نتیجه ناموفق بود'
} finally {
submitting.value = false
}
}
onMounted(loadExam)
</script>
<template>
<div class="castle-exam">
<p v-if="loading">در حال بارگذاری آزمون...</p>
<template v-else-if="error">
<p role="alert">{{ error }}</p>
<PrimaryButton variant="purple" full-width @click="loadExam">تلاش دوباره</PrimaryButton>
</template>
<template v-else-if="exam && phase === 'intro'">
<img :src="illustrations.professor" alt="" class="castle-exam__guide" />
<p class="castle-exam__guide-name">{{ exam.guideTitle }}</p>
<h2>{{ exam.title }}</h2>
<p>{{ exam.intro }}</p>
<PrimaryButton variant="gold" full-width @click="phase = 'questions'">شروع سؤال‌ها</PrimaryButton>
</template>
<template v-else-if="exam && phase === 'questions' && current">
<p>{{ sections[sectionIndex]?.title }}، سؤال {{ questionIndex + 1 }} از {{ sectionQuestions.length }}</p>
<p>پاسخ‌داده‌شده: {{ answered }} از {{ exam.questions.length }}. بی‌پاسخ: {{ exam.questions.length - answered }}</p>
<fieldset>
<legend>{{ current.prompt }}</legend>
<label v-for="option in current.options" :key="option.id">
<input type="radio" :name="current.id" :value="option.id" :checked="answers[current.id] === option.id" @change="answers = setRiskAnswer(answers, current.id, option.id)" />
<span>{{ option.label }}</span>
</label>
</fieldset>
<div class="castle-exam__nav">
<PrimaryButton variant="purple" :disabled="sectionIndex === 0 && questionIndex === 0" @click="goPrev">قبلی</PrimaryButton>
<PrimaryButton variant="gold" :disabled="!answers[current.id]" @click="nextQuestion">{{ sectionIndex === sections.length - 1 && questionIndex === sectionQuestions.length - 1 ? 'مرور پاسخ‌ها' : 'بعدی' }}</PrimaryButton>
</div>
</template>
<template v-else-if="exam && phase === 'review'">
<h2>مرور پاسخ‌ها</h2>
<p>پاسخ‌داده‌شده: {{ answered }} از {{ exam.questions.length }}</p>
<article v-for="question in exam.questions" :key="question.id">
<p>{{ question.sectionTitle }}: {{ question.prompt }}</p>
<p>{{ answers[question.id] ? question.options.find((option) => option.id === answers[question.id])?.label : 'هنوز پاسخی نیست' }}</p>
<button type="button" @click="editQuestion(question.id)">تغییر این پاسخ</button>
</article>
<p v-if="submitError" role="alert">{{ submitError }}</p>
<PrimaryButton variant="gold" full-width :disabled="submitting" @click="submitExam">{{ submitting ? 'در حال ارسال...' : 'ثبت نتیجه' }}</PrimaryButton>
<PrimaryButton v-if="submitError" variant="purple" full-width :disabled="submitting" @click="submitExam">ارسال دوباره</PrimaryButton>
</template>
<template v-else-if="result && feedback && exam">
<h2>نتیجه این تلاش</h2>
<p>امتیاز این تلاش: {{ result.score }} از ۱۰۰.</p>
<p v-if="passed">ستاره: {{ result.stars }} از ۳.</p>
<p v-else-if="result.previousStars > 0">این تلاش کامل نشد. ستاره ذخیره‌شده همچنان {{ result.previousStars }} از ۳ است.</p>
<p v-else>این تلاش کامل نشد و هنوز ستاره‌ای ثبت نشده.</p>
<p v-if="result.reward.xp > 0 || result.reward.coins > 0">پاداش: {{ result.reward.xp }} تجربه و {{ result.reward.coins }} سکه.</p>
<p v-else>این بار پاداش تازه‌ای اضافه نشد.</p>
<p v-if="passed">{{ feedback.summary }}</p>
<article v-for="item in feedback.questions" :key="item.id">
<p>{{ item.earned ? 'درست' : 'قابل بهتر شدن' }}: {{ exam.questions.find((question) => question.id === item.id)?.options.find((option) => option.id === item.selectedAnswerId)?.label }}</p>
<p>{{ item.explanation }}</p>
<button v-if="!item.earned" type="button" @click="router.push(`/gold-land/levels/${item.sourceLevelId}`)">مرور مرحله {{ item.sourceLevelId }}</button>
</article>
<PrimaryButton variant="purple" full-width @click="resetAnswers">تلاش مجدد</PrimaryButton>
<PrimaryButton variant="gold" full-width @click="emit('backToMap', props.levelId)">بازگشت به نقشه</PrimaryButton>
</template>
</div>
</template>
<style scoped>
.castle-exam { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
.castle-exam p, .castle-exam legend { line-height: 1.8; }
.castle-exam__guide { width: 96px; height: 96px; object-fit: contain; align-self: center; }
.castle-exam__guide-name { text-align: center; font-weight: 800; margin: 0; }
.castle-exam__nav { display: flex; gap: 8px; }
label, article { border: 1px solid rgba(118, 75, 190, 0.35); border-radius: var(--radius-lg); padding: 12px; }
label { display: flex; gap: 8px; }
button { min-height: 44px; }
</style>