Play levels 7-13 in the app and pin E2E to the local thirteen-stage backend commit. Co-authored-by: Cursor <cursoragent@cursor.com>
217 lines
10 KiB
Vue
217 lines
10 KiB
Vue
<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>
|