|
|
|
|
<template>
|
|
|
|
|
<div class="exam-page">
|
|
|
|
|
<!-- HEADER SERIO -->
|
|
|
|
|
<a-card class="top-card" :bordered="false">
|
|
|
|
|
<div class="topbar">
|
|
|
|
|
<div class="top-left">
|
|
|
|
|
<div class="title">
|
|
|
|
|
<div class="proceso">
|
|
|
|
|
{{ examenInfo.proceso || "Proceso no disponible" }}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="sub">
|
|
|
|
|
<span class="area">{{ examenInfo.area || "Área no disponible" }}</span>
|
|
|
|
|
<span class="sep">•</span>
|
|
|
|
|
<span class="prog">Pregunta {{ indiceActual + 1 }} / {{ totalPreguntas }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="meta">
|
|
|
|
|
<span><b>Duración:</b> {{ examenInfo.duracion }} min</span>
|
|
|
|
|
<span class="sep">•</span>
|
|
|
|
|
<span><b>Intentos:</b> {{ examenInfo.intentos }} / {{ examenInfo.intentos_maximos }}</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Progreso (donde corresponde) -->
|
|
|
|
|
<a-progress class="progress" :percent="porcentajeCompletado" :show-info="false" />
|
|
|
|
|
<div class="progressText">
|
|
|
|
|
Progreso: <b>{{ preguntasRespondidas }}</b> guardadas de <b>{{ totalPreguntas }}</b>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="top-right">
|
|
|
|
|
<div class="timerLabel">Tiempo restante</div>
|
|
|
|
|
<a-statistic-countdown
|
|
|
|
|
class="timer"
|
|
|
|
|
:value="timerValue"
|
|
|
|
|
@finish="finalizarExamenAutomaticamente"
|
|
|
|
|
format="HH:mm:ss"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
<!-- LOADING -->
|
|
|
|
|
<a-card v-if="cargandoInicio" class="card" :bordered="false">
|
|
|
|
|
<a-skeleton active />
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
<!-- CONTENIDO -->
|
|
|
|
|
<a-card v-else-if="preguntaActual" class="card question-card" :bordered="false">
|
|
|
|
|
<!-- Cabecera pregunta -->
|
|
|
|
|
<div class="q-header">
|
|
|
|
|
<div class="q-left">
|
|
|
|
|
<div class="q-number">Pregunta {{ indiceActual + 1 }}</div>
|
|
|
|
|
|
|
|
|
|
<div class="q-tags">
|
|
|
|
|
<span v-if="preguntaActual.curso" class="chip">
|
|
|
|
|
{{ preguntaActual.curso }}
|
|
|
|
|
</span>
|
|
|
|
|
<span class="chip muted">
|
|
|
|
|
{{ estadoTexto(preguntaActual) }}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Enunciado -->
|
|
|
|
|
|
|
|
|
|
<div class="enunciado">
|
|
|
|
|
<MarkdownLatex :content="preguntaActual.enunciado" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Extra -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="preguntaActual.extra && preguntaActual.extra !== preguntaActual.enunciado"
|
|
|
|
|
class="extra"
|
|
|
|
|
>
|
|
|
|
|
<MarkdownLatex :content="preguntaActual.extra" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Respuestas -->
|
|
|
|
|
<div class="answer">
|
|
|
|
|
<!-- Opciones -->
|
|
|
|
|
<div v-if="tieneOpciones(preguntaActual)">
|
|
|
|
|
<a-radio-group
|
|
|
|
|
v-model:value="preguntaActual.respuestaSeleccionada"
|
|
|
|
|
:disabled="preguntaActual.estado === 'respondida' || guardando || finalizando"
|
|
|
|
|
class="radio-group"
|
|
|
|
|
>
|
|
|
|
|
<a-space direction="vertical" style="width: 100%">
|
|
|
|
|
<a-radio
|
|
|
|
|
v-for="op in preguntaActual.opcionesOrdenadas"
|
|
|
|
|
:key="op.key"
|
|
|
|
|
:value="op.key.toString()"
|
|
|
|
|
class="opt"
|
|
|
|
|
>
|
|
|
|
|
<span class="optKey">{{ getLetraOpcion(op.key) }})</span>
|
|
|
|
|
<span class="optText">
|
|
|
|
|
<MarkdownLatex :content="op.texto" />
|
|
|
|
|
</span>
|
|
|
|
|
</a-radio>
|
|
|
|
|
</a-space>
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Abierta -->
|
|
|
|
|
<div v-else>
|
|
|
|
|
<a-textarea
|
|
|
|
|
v-model:value="preguntaActual.respuestaTexto"
|
|
|
|
|
:rows="6"
|
|
|
|
|
placeholder="Escribe tu respuesta..."
|
|
|
|
|
:disabled="preguntaActual.estado === 'respondida' || guardando || finalizando"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- NAV: SOLO 2 BOTONES -->
|
|
|
|
|
<div class="nav">
|
|
|
|
|
<a-button
|
|
|
|
|
:disabled="indiceActual === 0 || guardando || finalizando"
|
|
|
|
|
@click="irAnterior"
|
|
|
|
|
class="btn"
|
|
|
|
|
>
|
|
|
|
|
Atrás
|
|
|
|
|
</a-button>
|
|
|
|
|
|
|
|
|
|
<a-button
|
|
|
|
|
type="primary"
|
|
|
|
|
:loading="guardando || finalizando"
|
|
|
|
|
@click="siguienteAccion"
|
|
|
|
|
class="btn primary"
|
|
|
|
|
>
|
|
|
|
|
{{ esUltima ? "Guardar y finalizar" : "Siguiente" }}
|
|
|
|
|
</a-button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Nota sobria -->
|
|
|
|
|
<div class="note">
|
|
|
|
|
Al presionar <b>Siguiente</b>, tu respuesta se guarda. Evita recargar o cerrar durante el examen.
|
|
|
|
|
</div>
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
<!-- SIN PREGUNTAS -->
|
|
|
|
|
<a-card v-else class="card" :bordered="false">
|
|
|
|
|
<a-alert
|
|
|
|
|
type="warning"
|
|
|
|
|
show-icon
|
|
|
|
|
message="No hay preguntas para mostrar"
|
|
|
|
|
description="Verifica que el examen tenga preguntas generadas."
|
|
|
|
|
/>
|
|
|
|
|
<div class="nav" style="margin-top: 12px">
|
|
|
|
|
<a-button @click="router.push({ name: 'DashboardPostulante' })" class="btn">
|
|
|
|
|
Volver
|
|
|
|
|
</a-button>
|
|
|
|
|
</div>
|
|
|
|
|
</a-card>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
/* ============ LAYOUT SERIO ============ */
|
|
|
|
|
.exam-page {
|
|
|
|
|
max-width: 920px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
background: #f3f4f6; /* más neutral */
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============ HEADER ============ */
|
|
|
|
|
.top-card {
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.topbar {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 14px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.top-left {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 260px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.proceso {
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
color: #111827;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
line-height: 1.2;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sub {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
color: #4b5563;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.area {
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
color: #111827;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sep {
|
|
|
|
|
opacity: 0.55;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.meta {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.progress {
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.progressText {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Timer sobrio */
|
|
|
|
|
.top-right {
|
|
|
|
|
min-width: 190px;
|
|
|
|
|
text-align: right;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.timerLabel {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.timer :deep(.ant-statistic-content) {
|
|
|
|
|
font-size: 26px;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
color: #111827;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============ CARDS ============ */
|
|
|
|
|
.card {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
background: #fff;
|
|
|
|
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.question-card {
|
|
|
|
|
padding-bottom: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============ PREGUNTA ============ */
|
|
|
|
|
.q-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.q-number {
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
color: #111827;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.q-tags {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chip {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
height: 26px;
|
|
|
|
|
padding: 0 10px;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: #eef2ff;
|
|
|
|
|
color: #1f2937;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chip.muted {
|
|
|
|
|
background: #f3f4f6;
|
|
|
|
|
color: #374151;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Enunciado serio */
|
|
|
|
|
.enunciado {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
padding: 14px 14px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
background: #fbfbfc;
|
|
|
|
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
|
|
|
|
line-height: 1.75;
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
color: #111827;
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.extra {
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
color: #111827;
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============ RESPUESTAS ============ */
|
|
|
|
|
.answer {
|
|
|
|
|
margin-top: 14px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.radio-group {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opt {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
padding: 12px 12px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
border: 1px solid rgba(0, 0, 0, 0.10);
|
|
|
|
|
background: #fff;
|
|
|
|
|
transition: 0.12s ease;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opt:hover {
|
|
|
|
|
border-color: rgba(0, 0, 0, 0.18);
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opt :deep(.ant-radio) {
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.optKey {
|
|
|
|
|
min-width: 22px;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
color: #111827;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.optText {
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
color: #111827;
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============ NAV (SOLO 2 BOTONES) ============ */
|
|
|
|
|
.nav {
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
height: 40px;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
min-width: 140px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.primary {
|
|
|
|
|
min-width: 200px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Nota discreta */
|
|
|
|
|
.note {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Responsive */
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
.top-right {
|
|
|
|
|
text-align: left;
|
|
|
|
|
}
|
|
|
|
|
.nav {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
.btn,
|
|
|
|
|
.primary {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, watch, h } from "vue";
|
|
|
|
|
import { useRoute, useRouter } from "vue-router";
|
|
|
|
|
import { useExamenStore } from "../../store/examen.store";
|
|
|
|
|
import { message, Modal,Spin } from "ant-design-vue";
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const examenStore = useExamenStore();
|
|
|
|
|
|
|
|
|
|
const guardando = ref(false);
|
|
|
|
|
const finalizando = ref(false);
|
|
|
|
|
const timerValue = ref(null);
|
|
|
|
|
let timerIntervalId = null;
|
|
|
|
|
|
|
|
|
|
const preguntasLocal = ref([]);
|
|
|
|
|
const indiceActual = ref(0);
|
|
|
|
|
const cargandoInicio = ref(false);
|
|
|
|
|
const initOnce = ref(false);
|
|
|
|
|
import MarkdownLatex from '../../views/administrador/cursos/MarkdownLatex.vue'
|
|
|
|
|
/* INFO EXAMEN */
|
|
|
|
|
const examenInfo = computed(() => {
|
|
|
|
|
if (!examenStore.examenActual) {
|
|
|
|
|
return { proceso: null, area: null, duracion: 60, intentos: 0, intentos_maximos: 3 };
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
proceso: examenStore.examenActual?.proceso || "Proceso no disponible",
|
|
|
|
|
area: examenStore.examenActual?.area || "Área no disponible",
|
|
|
|
|
duracion: examenStore.examenActual?.duracion || 60,
|
|
|
|
|
intentos: examenStore.examenActual?.intentos || 0,
|
|
|
|
|
intentos_maximos: examenStore.examenActual?.intentos_maximos || 3,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* TRANSFORMAR */
|
|
|
|
|
const transformarPreguntas = (arr) => {
|
|
|
|
|
if (!Array.isArray(arr)) return [];
|
|
|
|
|
return arr.map((p) => ({
|
|
|
|
|
...p,
|
|
|
|
|
opcionesOrdenadas: p.opciones ? [...p.opciones].sort((a, b) => a.key - b.key) : [],
|
|
|
|
|
respuestaSeleccionada: p.respuestaSeleccionada ?? null,
|
|
|
|
|
respuestaTexto: p.respuestaTexto ?? "",
|
|
|
|
|
}));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
() => examenStore.preguntas,
|
|
|
|
|
(nuevas) => {
|
|
|
|
|
if (!Array.isArray(nuevas) || nuevas.length === 0) return;
|
|
|
|
|
if (preguntasLocal.value.length === 0) {
|
|
|
|
|
preguntasLocal.value = transformarPreguntas(nuevas);
|
|
|
|
|
indiceActual.value = 0;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
/* COMPUTEDS */
|
|
|
|
|
const totalPreguntas = computed(() => preguntasLocal.value.length);
|
|
|
|
|
const preguntaActual = computed(() => preguntasLocal.value[indiceActual.value] || null);
|
|
|
|
|
const esUltima = computed(() => indiceActual.value >= totalPreguntas.value - 1);
|
|
|
|
|
|
|
|
|
|
const preguntasRespondidas = computed(() =>
|
|
|
|
|
preguntasLocal.value.filter((p) => p.estado === "respondida").length
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const porcentajeCompletado = computed(() => {
|
|
|
|
|
if (!totalPreguntas.value) return 0;
|
|
|
|
|
return Math.round((preguntasRespondidas.value / totalPreguntas.value) * 100);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/* HELPERS */
|
|
|
|
|
const tieneOpciones = (p) => !!(p?.opciones && p.opciones.length);
|
|
|
|
|
|
|
|
|
|
const getLetraOpcion = (key) => {
|
|
|
|
|
const letras = ["A", "B", "C", "D", "E", "F", "G", "H"];
|
|
|
|
|
return letras[key] || `Opción ${key}`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const tieneRespuestaLocal = (p) => {
|
|
|
|
|
if (!p) return false;
|
|
|
|
|
if (tieneOpciones(p)) return !!p.respuestaSeleccionada;
|
|
|
|
|
return !!(p.respuestaTexto && p.respuestaTexto.trim());
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const estadoTexto = (p) => {
|
|
|
|
|
if (p.estado === "respondida") return "Guardada";
|
|
|
|
|
if (tieneRespuestaLocal(p)) return "Sin guardar";
|
|
|
|
|
return "Pendiente";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* NAV */
|
|
|
|
|
const irAnterior = () => {
|
|
|
|
|
if (indiceActual.value > 0) indiceActual.value--;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const guardarRespuestaDePregunta = async (p) => {
|
|
|
|
|
if (!p) return { success: false, message: "No hay pregunta" };
|
|
|
|
|
|
|
|
|
|
// Si ya fue guardada
|
|
|
|
|
if (p.estado === "respondida") return { success: true };
|
|
|
|
|
|
|
|
|
|
let respuesta = null;
|
|
|
|
|
|
|
|
|
|
// 🔹 Pregunta con opciones
|
|
|
|
|
if (tieneOpciones(p)) {
|
|
|
|
|
if (p.respuestaSeleccionada) {
|
|
|
|
|
const sel = p.respuestaSeleccionada.toString();
|
|
|
|
|
const op = p.opcionesOrdenadas.find(
|
|
|
|
|
(x) => x.key.toString() === sel
|
|
|
|
|
);
|
|
|
|
|
respuesta = op ? op.texto : sel;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// 🔹 Pregunta abierta
|
|
|
|
|
else {
|
|
|
|
|
if (p.respuestaTexto && p.respuestaTexto.trim()) {
|
|
|
|
|
respuesta = p.respuestaTexto.trim();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ⚡ Si no hay respuesta → se envía null (BLANCO)
|
|
|
|
|
return await examenStore.responderPregunta(p.id, respuesta);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* ACCIÓN SIGUIENTE: GUARDA Y AVANZA / FINALIZA */
|
|
|
|
|
const siguienteAccion = async () => {
|
|
|
|
|
const p = preguntaActual.value;
|
|
|
|
|
if (!p) return;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
guardando.value = true;
|
|
|
|
|
|
|
|
|
|
const r = await guardarRespuestaDePregunta(p);
|
|
|
|
|
if (!r?.success) {
|
|
|
|
|
message.warning(r?.message || "Completa tu respuesta antes de continuar");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p.estado = "respondida";
|
|
|
|
|
|
|
|
|
|
if (!esUltima.value) {
|
|
|
|
|
indiceActual.value++;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// última: finalizar
|
|
|
|
|
await finalizarExamen();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
message.error("Error al guardar");
|
|
|
|
|
} finally {
|
|
|
|
|
guardando.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const finalizarExamen = async () => {
|
|
|
|
|
const examenId = Number(route.params.examenId)
|
|
|
|
|
|
|
|
|
|
// Modal de proceso
|
|
|
|
|
const modal = Modal.info({
|
|
|
|
|
title: "Procesando calificación",
|
|
|
|
|
content: h("div", { style: "display:flex;align-items:center;gap:12px;" }, [
|
|
|
|
|
h(Spin, { size: "large" }),
|
|
|
|
|
h("div", [
|
|
|
|
|
h("div", { style: "font-weight:600;" }, "Finalizando evaluación"),
|
|
|
|
|
h("div", { style: "margin-top:4px;color:rgba(0,0,0,.65);" }, "Estamos calificando sus respuestas. Por favor, espere..."),
|
|
|
|
|
]),
|
|
|
|
|
]),
|
|
|
|
|
maskClosable: false,
|
|
|
|
|
closable: false,
|
|
|
|
|
okButtonProps: { style: { display: "none" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
finalizando.value = true
|
|
|
|
|
|
|
|
|
|
const r = await examenStore.finalizarExamen(examenId)
|
|
|
|
|
|
|
|
|
|
// ✅ Caso especial: ya estaba finalizado (409)
|
|
|
|
|
const msg = (r?.message || "").toLowerCase()
|
|
|
|
|
if (!r?.success && (msg.includes("ya está finalizado") || msg.includes("ya esta finalizado"))) {
|
|
|
|
|
modal.update({
|
|
|
|
|
title: "Evaluación finalizada",
|
|
|
|
|
content: "El examen ya se encuentra finalizado. Redirigiendo a la página de resultados...",
|
|
|
|
|
okButtonProps: { style: { display: "none" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
modal.destroy()
|
|
|
|
|
router.push({ name: "PanelResultados", params: { examenId } })
|
|
|
|
|
}, 1200)
|
|
|
|
|
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ❌ Otro error
|
|
|
|
|
if (!r?.success) {
|
|
|
|
|
modal.update({
|
|
|
|
|
title: "No se pudo finalizar",
|
|
|
|
|
content: r?.message || "No fue posible finalizar el examen. Inténtelo nuevamente.",
|
|
|
|
|
okButtonProps: { style: { display: "none" } },
|
|
|
|
|
})
|
|
|
|
|
setTimeout(() => modal.destroy(), 1800)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ✅ Éxito normal: simular 5 segundos “calificando”
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
modal.update({
|
|
|
|
|
title: "Calificación completada",
|
|
|
|
|
content: "Redirigiendo a la página de resultados...",
|
|
|
|
|
okButtonProps: { style: { display: "none" } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
modal.destroy()
|
|
|
|
|
router.push({ name: "PanelResultados", params: { examenId } })
|
|
|
|
|
}, 900)
|
|
|
|
|
}, 5000)
|
|
|
|
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
modal.update({
|
|
|
|
|
title: "Error",
|
|
|
|
|
content: "Ocurrió un problema al finalizar el examen. Por favor, inténtelo nuevamente.",
|
|
|
|
|
okButtonProps: { style: { display: "none" } },
|
|
|
|
|
})
|
|
|
|
|
setTimeout(() => modal.destroy(), 2000)
|
|
|
|
|
} finally {
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
finalizando.value = false
|
|
|
|
|
}, 5000)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const finalizarExamenAutomaticamente = () => {
|
|
|
|
|
message.error("Tiempo agotado. El examen se finalizará.");
|
|
|
|
|
finalizarExamen();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const calcularTiempoRestante = () => {
|
|
|
|
|
if (examenStore.examenActual?.hora_inicio && examenInfo.value.duracion) {
|
|
|
|
|
const inicio = new Date(examenStore.examenActual.hora_inicio);
|
|
|
|
|
const fin = inicio.getTime() + examenInfo.value.duracion * 60 * 1000;
|
|
|
|
|
timerValue.value = fin;
|
|
|
|
|
if (Date.now() > fin) finalizarExamenAutomaticamente();
|
|
|
|
|
} else {
|
|
|
|
|
timerValue.value = Date.now() + examenInfo.value.duracion * 60 * 1000;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const iniciarSesionExamen = async () => {
|
|
|
|
|
if (initOnce.value) return;
|
|
|
|
|
initOnce.value = true;
|
|
|
|
|
|
|
|
|
|
const examenId = route.params.examenId;
|
|
|
|
|
if (!examenId) {
|
|
|
|
|
message.error("No se encontró el examen");
|
|
|
|
|
router.push({ name: "DashboardPostulante" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
cargandoInicio.value = true;
|
|
|
|
|
|
|
|
|
|
const r = await examenStore.iniciarExamen(examenId);
|
|
|
|
|
if (!r?.success) {
|
|
|
|
|
message.error(r?.message || "No se pudo iniciar el examen");
|
|
|
|
|
router.push({ name: "DashboardPostulante" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(examenStore.preguntas) && examenStore.preguntas.length) {
|
|
|
|
|
preguntasLocal.value = transformarPreguntas(examenStore.preguntas);
|
|
|
|
|
indiceActual.value = 0;
|
|
|
|
|
} else {
|
|
|
|
|
message.error("No hay preguntas");
|
|
|
|
|
router.push({ name: "DashboardPostulante" });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
calcularTiempoRestante();
|
|
|
|
|
if (timerIntervalId) clearInterval(timerIntervalId);
|
|
|
|
|
timerIntervalId = setInterval(calcularTiempoRestante, 30000);
|
|
|
|
|
} finally {
|
|
|
|
|
cargandoInicio.value = false;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onMounted(iniciarSesionExamen);
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
if (timerIntervalId) clearInterval(timerIntervalId);
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
|
|
|
|
.exam-page {
|
|
|
|
|
max-width: 920px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
background: #f6f7f9;
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.top-card {
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
background: #ffffff;
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.topbar {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 14px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.top-left {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 260px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.title .proceso {
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
color: #111827;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.title .sub {
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
color: #4b5563;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.area {
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
color: #111827;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sep {
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.meta {
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.progress {
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.progressText {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.top-right {
|
|
|
|
|
min-width: 180px;
|
|
|
|
|
text-align: right;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.timerLabel {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
margin-bottom: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.timer :deep(.ant-statistic-content) {
|
|
|
|
|
font-size: 26px;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
color: #111827;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.card {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
background: #fff;
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.question-card {
|
|
|
|
|
padding-bottom: 6px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.q-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.q-number {
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
color: #111827;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.q-tags {
|
|
|
|
|
margin-top: 6px;
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chip {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
height: 26px;
|
|
|
|
|
padding: 0 10px;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
background: #eef2ff;
|
|
|
|
|
color: #1f2937;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chip.muted {
|
|
|
|
|
background: #f3f4f6;
|
|
|
|
|
color: #374151;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.enunciado {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
padding: 14px 14px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
background: #fbfbfc;
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.06);
|
|
|
|
|
line-height: 1.75;
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
color: #111827;
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.extra {
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
padding: 12px 14px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
background: #fff;
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.08);
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
color: #111827;
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.answer {
|
|
|
|
|
margin-top: 14px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.radio-group {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opt {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
padding: 12px 12px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
border: 1px solid rgba(0,0,0,0.10);
|
|
|
|
|
background: #fff;
|
|
|
|
|
transition: 0.12s ease;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opt:hover {
|
|
|
|
|
border-color: rgba(0,0,0,0.20);
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opt :deep(.ant-radio) {
|
|
|
|
|
margin-top: 2px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.optKey {
|
|
|
|
|
min-width: 22px;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
color: #111827;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.optText {
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
color: #111827;
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav {
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.btn {
|
|
|
|
|
height: 40px;
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
min-width: 140px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.primary {
|
|
|
|
|
min-width: 200px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
.note {
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
.top-right {
|
|
|
|
|
text-align: left;
|
|
|
|
|
}
|
|
|
|
|
.nav {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
.btn, .primary {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|