|
|
|
|
<template>
|
|
|
|
|
<div class="examen-panel">
|
|
|
|
|
<!-- Header con información del examen -->
|
|
|
|
|
<a-card>
|
|
|
|
|
<div class="examen-header">
|
|
|
|
|
<div class="header-info">
|
|
|
|
|
<h2>{{ examenInfo.proceso || 'Proceso no disponible' }}</h2>
|
|
|
|
|
<p><strong>Área:</strong> {{ examenInfo.area || 'Área no disponible' }}</p>
|
|
|
|
|
<p><strong>Duración:</strong> {{ examenInfo.duracion }} minutos</p>
|
|
|
|
|
<p><strong>Intentos:</strong> {{ examenInfo.intentos }} / {{ examenInfo.intentos_maximos }}</p>
|
|
|
|
|
<p><strong>Tiempo restante:</strong></p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="timer">
|
|
|
|
|
<a-statistic-countdown
|
|
|
|
|
:value="timerValue"
|
|
|
|
|
@finish="finalizarExamenAutomaticamente"
|
|
|
|
|
format="HH:mm:ss"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
<!-- Loading inicial -->
|
|
|
|
|
<a-card v-if="cargandoInicio" style="margin-top: 16px;">
|
|
|
|
|
<a-skeleton active />
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
<!-- Pregunta actual (UNA por pantalla) -->
|
|
|
|
|
<a-card
|
|
|
|
|
v-else-if="preguntaActual"
|
|
|
|
|
class="pregunta-card"
|
|
|
|
|
style="margin-top: 16px;"
|
|
|
|
|
>
|
|
|
|
|
<template #title>
|
|
|
|
|
<div class="pregunta-header">
|
|
|
|
|
<span class="pregunta-numero">
|
|
|
|
|
Pregunta {{ indiceActual + 1 }} de {{ totalPreguntas }}
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<span class="curso-tag">
|
|
|
|
|
<a-tag color="blue">{{ preguntaActual.curso }}</a-tag>
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
|
|
<a-tag :color="tagColorEstado(preguntaActual)">
|
|
|
|
|
{{ tagTextoEstado(preguntaActual) }}
|
|
|
|
|
</a-tag>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<!-- Enunciado -->
|
|
|
|
|
<div class="enunciado" v-html="preguntaActual.enunciado"></div>
|
|
|
|
|
|
|
|
|
|
<!-- Contenido adicional -->
|
|
|
|
|
<div
|
|
|
|
|
v-if="preguntaActual.extra && preguntaActual.extra !== preguntaActual.enunciado"
|
|
|
|
|
class="extra"
|
|
|
|
|
v-html="preguntaActual.extra"
|
|
|
|
|
></div>
|
|
|
|
|
|
|
|
|
|
<!-- Opciones múltiples (NO guarda en change) -->
|
|
|
|
|
<div class="opciones" v-if="preguntaActual.opciones && preguntaActual.opciones.length">
|
|
|
|
|
<a-radio-group
|
|
|
|
|
v-model:value="preguntaActual.respuestaSeleccionada"
|
|
|
|
|
:disabled="preguntaActual.estado === 'respondida'"
|
|
|
|
|
>
|
|
|
|
|
<a-space direction="vertical" style="width: 100%;">
|
|
|
|
|
<a-radio
|
|
|
|
|
v-for="opcion in preguntaActual.opcionesOrdenadas"
|
|
|
|
|
:key="opcion.key"
|
|
|
|
|
:value="opcion.key.toString()"
|
|
|
|
|
class="opcion-radio"
|
|
|
|
|
>
|
|
|
|
|
<span class="opcion-key">{{ getLetraOpcion(opcion.key) }}.</span>
|
|
|
|
|
<span v-html="opcion.texto" class="opcion-texto"></span>
|
|
|
|
|
</a-radio>
|
|
|
|
|
</a-space>
|
|
|
|
|
</a-radio-group>
|
|
|
|
|
|
|
|
|
|
<div v-if="preguntaActual.respuestaSeleccionada" class="seleccion-actual">
|
|
|
|
|
<a-alert
|
|
|
|
|
:message="`Ha seleccionado: ${getTextoOpcionSeleccionada(preguntaActual)}`"
|
|
|
|
|
type="info"
|
|
|
|
|
show-icon
|
|
|
|
|
style="margin-top: 12px;"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Pregunta abierta (NO guarda en blur) -->
|
|
|
|
|
<div v-else class="pregunta-abierta">
|
|
|
|
|
<a-textarea
|
|
|
|
|
v-model:value="preguntaActual.respuestaTexto"
|
|
|
|
|
placeholder="Escriba su respuesta aquí..."
|
|
|
|
|
:rows="4"
|
|
|
|
|
:disabled="preguntaActual.estado === 'respondida'"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Botones por pregunta -->
|
|
|
|
|
<div class="nav-preguntas">
|
|
|
|
|
<a-button :disabled="indiceActual === 0" @click="irAnterior">
|
|
|
|
|
Anterior
|
|
|
|
|
</a-button>
|
|
|
|
|
|
|
|
|
|
<div class="nav-derecha">
|
|
|
|
|
<a-button
|
|
|
|
|
type="primary"
|
|
|
|
|
:loading="guardando"
|
|
|
|
|
:disabled="preguntaActual.estado === 'respondida'"
|
|
|
|
|
@click="guardarYContinuar"
|
|
|
|
|
>
|
|
|
|
|
Guardar respuesta y continuar
|
|
|
|
|
</a-button>
|
|
|
|
|
|
|
|
|
|
<a-button
|
|
|
|
|
:disabled="indiceActual >= totalPreguntas - 1"
|
|
|
|
|
@click="irSiguiente"
|
|
|
|
|
>
|
|
|
|
|
Siguiente
|
|
|
|
|
</a-button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Debug -->
|
|
|
|
|
<div v-if="debugMode" class="debug-info">
|
|
|
|
|
<a-alert
|
|
|
|
|
:message="`DEBUG | id: ${preguntaActual.id} | estado: ${preguntaActual.estado} | seleccion: ${preguntaActual.respuestaSeleccionada || ''}`"
|
|
|
|
|
type="warning"
|
|
|
|
|
show-icon
|
|
|
|
|
style="margin-top: 12px;"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
<!-- Si no hay pregunta -->
|
|
|
|
|
<a-card v-else style="margin-top: 16px;">
|
|
|
|
|
<a-alert
|
|
|
|
|
type="warning"
|
|
|
|
|
show-icon
|
|
|
|
|
message="No hay preguntas para mostrar"
|
|
|
|
|
description="Verifique que el examen tenga preguntas generadas."
|
|
|
|
|
/>
|
|
|
|
|
<div style="margin-top: 12px; text-align: right;">
|
|
|
|
|
<a-button @click="router.push({ name: 'DashboardPostulante' })">Volver</a-button>
|
|
|
|
|
</div>
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
<!-- Resumen y botones -->
|
|
|
|
|
<a-card style="margin-top: 24px;">
|
|
|
|
|
<div class="resumen-examen">
|
|
|
|
|
<h3>Resumen del Examen</h3>
|
|
|
|
|
<p><strong>Total preguntas:</strong> {{ totalPreguntas }}</p>
|
|
|
|
|
<p><strong>Respondidas (guardadas):</strong> {{ preguntasRespondidas }} de {{ totalPreguntas }}</p>
|
|
|
|
|
<p><strong>Progreso:</strong></p>
|
|
|
|
|
|
|
|
|
|
<a-progress
|
|
|
|
|
:percent="porcentajeCompletado"
|
|
|
|
|
:stroke-color="{
|
|
|
|
|
'0%': '#108ee9',
|
|
|
|
|
'100%': '#87d068',
|
|
|
|
|
}"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="action-buttons" style="margin-top: 24px; text-align: center;">
|
|
|
|
|
<a-button
|
|
|
|
|
type="primary"
|
|
|
|
|
size="large"
|
|
|
|
|
:loading="finalizando"
|
|
|
|
|
@click="finalizarExamen"
|
|
|
|
|
:disabled="!todasRespondidas"
|
|
|
|
|
>
|
|
|
|
|
{{
|
|
|
|
|
todasRespondidas
|
|
|
|
|
? 'Finalizar Examen'
|
|
|
|
|
: `Responda todas las preguntas (${totalPreguntas - preguntasRespondidas} pendientes)`
|
|
|
|
|
}}
|
|
|
|
|
</a-button>
|
|
|
|
|
|
|
|
|
|
<a-button
|
|
|
|
|
type="default"
|
|
|
|
|
size="large"
|
|
|
|
|
style="margin-left: 12px;"
|
|
|
|
|
@click="guardarYSalir"
|
|
|
|
|
>
|
|
|
|
|
Guardar y salir
|
|
|
|
|
</a-button>
|
|
|
|
|
</div>
|
|
|
|
|
</a-card>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
|
|
|
import { useExamenStore } from '../../store/examen.store'
|
|
|
|
|
import { message, Modal } from 'ant-design-vue'
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
const examenStore = useExamenStore()
|
|
|
|
|
|
|
|
|
|
const finalizando = ref(false)
|
|
|
|
|
const guardando = ref(false)
|
|
|
|
|
const debugMode = ref(false)
|
|
|
|
|
|
|
|
|
|
const timerValue = ref(null)
|
|
|
|
|
let timerIntervalId = null
|
|
|
|
|
|
|
|
|
|
const preguntasLocal = ref([])
|
|
|
|
|
const indiceActual = ref(0)
|
|
|
|
|
|
|
|
|
|
const initOnce = ref(false)
|
|
|
|
|
const cargandoInicio = ref(false)
|
|
|
|
|
|
|
|
|
|
/* ---------------------------
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/* ---------------------------
|
|
|
|
|
TRANSFORMACIÓN (UNA VEZ)
|
|
|
|
|
---------------------------- */
|
|
|
|
|
const transformarPreguntas = (arr) => {
|
|
|
|
|
if (!Array.isArray(arr)) return []
|
|
|
|
|
|
|
|
|
|
return arr.map((pregunta) => {
|
|
|
|
|
let respuestaKey = null
|
|
|
|
|
|
|
|
|
|
if (pregunta.opciones && pregunta.respuesta) {
|
|
|
|
|
const opcionCorrecta = pregunta.opciones.find(op =>
|
|
|
|
|
op.texto === pregunta.respuesta ||
|
|
|
|
|
op.key.toString() === pregunta.respuesta.toString()
|
|
|
|
|
)
|
|
|
|
|
respuestaKey = opcionCorrecta ? opcionCorrecta.key : null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const opcionesOrdenadas = pregunta.opciones
|
|
|
|
|
? [...pregunta.opciones].sort((a, b) => a.key - b.key)
|
|
|
|
|
: []
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...pregunta,
|
|
|
|
|
respuestaKey,
|
|
|
|
|
opcionesOrdenadas,
|
|
|
|
|
// estado viene del backend: 'pendiente' o 'respondida'
|
|
|
|
|
respuestaSeleccionada: null,
|
|
|
|
|
respuestaTexto: ''
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ✅ IMPORTANTE:
|
|
|
|
|
No reseteamos preguntasLocal si ya existe, para no perder respuestas locales.
|
|
|
|
|
*/
|
|
|
|
|
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(() => {
|
|
|
|
|
return preguntasLocal.value[indiceActual.value] || null
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const preguntasRespondidas = computed(() => {
|
|
|
|
|
return preguntasLocal.value.filter(p => p.estado === 'respondida').length
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const porcentajeCompletado = computed(() => {
|
|
|
|
|
if (totalPreguntas.value === 0) return 0
|
|
|
|
|
return Math.round((preguntasRespondidas.value / totalPreguntas.value) * 100)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const todasRespondidas = computed(() => {
|
|
|
|
|
return preguntasLocal.value.every(p => p.estado === 'respondida')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
/* ---------------------------
|
|
|
|
|
HELPERS UI
|
|
|
|
|
---------------------------- */
|
|
|
|
|
const getLetraOpcion = (key) => {
|
|
|
|
|
const letras = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
|
|
|
|
|
return letras[key] || `Opción ${key}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getTextoOpcionSeleccionada = (pregunta) => {
|
|
|
|
|
if (!pregunta?.respuestaSeleccionada) return ''
|
|
|
|
|
const opcion = pregunta.opcionesOrdenadas.find(
|
|
|
|
|
op => op.key.toString() === pregunta.respuestaSeleccionada.toString()
|
|
|
|
|
)
|
|
|
|
|
return opcion ? opcion.texto : 'Opción no encontrada'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tieneRespuestaLocal = (p) => {
|
|
|
|
|
const tieneOpciones = p?.opciones && p.opciones.length
|
|
|
|
|
if (tieneOpciones) return !!p.respuestaSeleccionada
|
|
|
|
|
return !!(p.respuestaTexto && p.respuestaTexto.trim())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tagTextoEstado = (p) => {
|
|
|
|
|
if (p.estado === 'respondida') return 'Respondida'
|
|
|
|
|
if (tieneRespuestaLocal(p)) return 'Sin guardar'
|
|
|
|
|
return 'Pendiente'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const tagColorEstado = (p) => {
|
|
|
|
|
if (p.estado === 'respondida') return 'green'
|
|
|
|
|
if (tieneRespuestaLocal(p)) return 'gold'
|
|
|
|
|
return 'orange'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------
|
|
|
|
|
NAVEGACIÓN
|
|
|
|
|
---------------------------- */
|
|
|
|
|
const irSiguiente = () => {
|
|
|
|
|
if (indiceActual.value < totalPreguntas.value - 1) indiceActual.value++
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const irAnterior = () => {
|
|
|
|
|
if (indiceActual.value > 0) indiceActual.value--
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------
|
|
|
|
|
GUARDADO (BOTÓN)
|
|
|
|
|
---------------------------- */
|
|
|
|
|
const guardarRespuestaDePregunta = async (p) => {
|
|
|
|
|
if (!p) return { success: false, message: 'No hay pregunta' }
|
|
|
|
|
if (p.estado === 'respondida') return { success: true }
|
|
|
|
|
|
|
|
|
|
const tieneOpciones = p.opciones && p.opciones.length
|
|
|
|
|
|
|
|
|
|
if (tieneOpciones) {
|
|
|
|
|
if (!p.respuestaSeleccionada) return { success: false, message: 'Seleccione una opción' }
|
|
|
|
|
|
|
|
|
|
const respuestaString = p.respuestaSeleccionada.toString()
|
|
|
|
|
const opcionSeleccionada = p.opcionesOrdenadas.find(op => op.key.toString() === respuestaString)
|
|
|
|
|
const textoRespuesta = opcionSeleccionada ? opcionSeleccionada.texto : respuestaString
|
|
|
|
|
|
|
|
|
|
return await examenStore.responderPregunta(p.id, textoRespuesta)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!p.respuestaTexto || !p.respuestaTexto.trim()) {
|
|
|
|
|
return { success: false, message: 'Escriba una respuesta' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return await examenStore.responderPregunta(p.id, p.respuestaTexto.trim())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const guardarYContinuar = async () => {
|
|
|
|
|
const p = preguntaActual.value
|
|
|
|
|
if (!p) return
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
guardando.value = true
|
|
|
|
|
const result = await guardarRespuestaDePregunta(p)
|
|
|
|
|
|
|
|
|
|
if (!result?.success) {
|
|
|
|
|
message.error(result?.message || 'Error al guardar respuesta')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p.estado = 'respondida'
|
|
|
|
|
message.success('Respuesta guardada')
|
|
|
|
|
|
|
|
|
|
if (indiceActual.value < totalPreguntas.value - 1) {
|
|
|
|
|
indiceActual.value++
|
|
|
|
|
} else {
|
|
|
|
|
message.info('Ya estás en la última pregunta')
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e)
|
|
|
|
|
message.error('Error al guardar respuesta')
|
|
|
|
|
} finally {
|
|
|
|
|
guardando.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------
|
|
|
|
|
FINALIZAR / GUARDAR Y SALIR
|
|
|
|
|
---------------------------- */
|
|
|
|
|
const finalizarExamen = async () => {
|
|
|
|
|
if (!todasRespondidas.value) {
|
|
|
|
|
message.warning(`Por favor responda todas las preguntas. ${totalPreguntas.value - preguntasRespondidas.value} pendientes.`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
title: '¿Está seguro de finalizar el examen?',
|
|
|
|
|
content: 'Una vez finalizado no podrá modificar sus respuestas.',
|
|
|
|
|
okText: 'Sí, finalizar',
|
|
|
|
|
cancelText: 'Cancelar',
|
|
|
|
|
onOk: async () => {
|
|
|
|
|
try {
|
|
|
|
|
finalizando.value = true
|
|
|
|
|
const result = await examenStore.finalizarExamen(route.params.examenId)
|
|
|
|
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
message.success('Examen finalizado correctamente')
|
|
|
|
|
router.push({
|
|
|
|
|
// ⚠️ Ajusta el name si tu ruta se llama distinto
|
|
|
|
|
name: 'PanelResultados',
|
|
|
|
|
params: { examenId: route.params.examenId }
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
message.error(result.message || 'Error al finalizar examen')
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
message.error('Error al finalizar examen')
|
|
|
|
|
console.error(error)
|
|
|
|
|
} finally {
|
|
|
|
|
finalizando.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const guardarYSalir = async () => {
|
|
|
|
|
const pendientes = preguntasLocal.value.filter(p => p.estado !== 'respondida' && tieneRespuestaLocal(p))
|
|
|
|
|
|
|
|
|
|
if (pendientes.length > 0) {
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
title: 'Guardar respuestas pendientes',
|
|
|
|
|
content: `Tiene ${pendientes.length} respuesta(s) lista(s) pero no guardada(s). ¿Desea guardarlas antes de salir?`,
|
|
|
|
|
okText: 'Guardar y salir',
|
|
|
|
|
cancelText: 'Salir sin guardar',
|
|
|
|
|
onOk: async () => {
|
|
|
|
|
try {
|
|
|
|
|
for (const p of pendientes) {
|
|
|
|
|
const r = await guardarRespuestaDePregunta(p)
|
|
|
|
|
if (r?.success) p.estado = 'respondida'
|
|
|
|
|
}
|
|
|
|
|
message.success('Respuestas guardadas')
|
|
|
|
|
router.push({ name: 'DashboardPostulante' })
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e)
|
|
|
|
|
message.error('Error al guardar respuestas')
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onCancel: () => {
|
|
|
|
|
router.push({ name: 'DashboardPostulante' })
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
router.push({ name: 'DashboardPostulante' })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------
|
|
|
|
|
TIMER
|
|
|
|
|
---------------------------- */
|
|
|
|
|
const finalizarExamenAutomaticamente = () => {
|
|
|
|
|
message.warning('Tiempo agotado. El examen se finalizará automáticamente.')
|
|
|
|
|
finalizarExamen()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const calcularTiempoRestante = () => {
|
|
|
|
|
// preferir hora_inicio del backend
|
|
|
|
|
if (examenStore.examenActual?.hora_inicio && examenInfo.value.duracion) {
|
|
|
|
|
const horaInicio = new Date(examenStore.examenActual.hora_inicio)
|
|
|
|
|
const duracionMs = examenInfo.value.duracion * 60 * 1000
|
|
|
|
|
const tiempoFinal = horaInicio.getTime() + duracionMs
|
|
|
|
|
timerValue.value = tiempoFinal
|
|
|
|
|
|
|
|
|
|
if (Date.now() > tiempoFinal) finalizarExamenAutomaticamente()
|
|
|
|
|
} else {
|
|
|
|
|
// fallback
|
|
|
|
|
timerValue.value = Date.now() + (examenInfo.value.duracion * 60 * 1000)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------------------------
|
|
|
|
|
INICIO EXAMEN (EJECUTAR 1 SOLA VEZ)
|
|
|
|
|
---------------------------- */
|
|
|
|
|
const iniciarSesionExamen = async () => {
|
|
|
|
|
if (initOnce.value) return
|
|
|
|
|
initOnce.value = true
|
|
|
|
|
|
|
|
|
|
const examenId = route.params.examenId
|
|
|
|
|
if (!examenId) {
|
|
|
|
|
message.error('No se encontró el ID del examen')
|
|
|
|
|
router.push({ name: 'DashboardPostulante' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
cargandoInicio.value = true
|
|
|
|
|
|
|
|
|
|
// ✅ AQUÍ SÍ se ejecuta iniciarExamen con su examen_id
|
|
|
|
|
const r = await examenStore.iniciarExamen(examenId)
|
|
|
|
|
|
|
|
|
|
if (!r?.success) {
|
|
|
|
|
message.error(r?.message || 'No se pudo iniciar el examen')
|
|
|
|
|
router.push({ name: 'DashboardPostulante' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inicializar preguntasLocal con lo que vino del backend
|
|
|
|
|
if (Array.isArray(examenStore.preguntas) && examenStore.preguntas.length > 0) {
|
|
|
|
|
preguntasLocal.value = transformarPreguntas(examenStore.preguntas)
|
|
|
|
|
indiceActual.value = 0
|
|
|
|
|
} else {
|
|
|
|
|
message.error('No se encontraron preguntas para este examen')
|
|
|
|
|
router.push({ name: 'DashboardPostulante' })
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Timer basado en hora_inicio real
|
|
|
|
|
calcularTiempoRestante()
|
|
|
|
|
if (timerIntervalId) clearInterval(timerIntervalId)
|
|
|
|
|
timerIntervalId = setInterval(calcularTiempoRestante, 30000)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e)
|
|
|
|
|
message.error('Error al iniciar el examen')
|
|
|
|
|
router.push({ name: 'DashboardPostulante' })
|
|
|
|
|
} finally {
|
|
|
|
|
cargandoInicio.value = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
iniciarSesionExamen()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
if (timerIntervalId) clearInterval(timerIntervalId)
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.examen-panel {
|
|
|
|
|
max-width: 1200px;
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
padding: 20px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.examen-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 20px;
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
background: #f0f9ff;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
border: 1px solid #d6e4ff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header-info h2 {
|
|
|
|
|
margin: 0 0 8px 0;
|
|
|
|
|
color: #1890ff;
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header-info p {
|
|
|
|
|
margin: 4px 0;
|
|
|
|
|
color: #595959;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.timer {
|
|
|
|
|
text-align: right;
|
|
|
|
|
min-width: 180px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.timer :deep(.ant-statistic-content) {
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
color: #fa541c;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pregunta-card {
|
|
|
|
|
margin-bottom: 24px;
|
|
|
|
|
border: 1px solid #e8e8e8;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.08);
|
|
|
|
|
transition: box-shadow 0.3s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pregunta-card:hover {
|
|
|
|
|
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.12);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pregunta-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
padding-bottom: 12px;
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pregunta-numero {
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
color: #1890ff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.curso-tag {
|
|
|
|
|
flex-grow: 1;
|
|
|
|
|
margin-left: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.enunciado {
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
margin: 20px 0;
|
|
|
|
|
padding: 20px;
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
border-left: 4px solid #1890ff;
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.extra {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
margin: 16px 0;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
background: #fff7e6;
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
border: 1px solid #ffd591;
|
|
|
|
|
color: #d46b08;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opciones {
|
|
|
|
|
margin-top: 24px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opcion-radio {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding: 16px 20px;
|
|
|
|
|
margin: 10px 0;
|
|
|
|
|
border: 2px solid #d9d9d9;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
transition: all 0.3s;
|
|
|
|
|
min-height: 60px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opcion-radio:hover {
|
|
|
|
|
border-color: #40a9ff;
|
|
|
|
|
background: #f0f9ff;
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opcion-radio:deep(.ant-radio) {
|
|
|
|
|
margin-right: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opcion-key {
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
color: #1890ff;
|
|
|
|
|
margin-right: 12px;
|
|
|
|
|
min-width: 24px;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opcion-texto {
|
|
|
|
|
flex: 1;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.seleccion-actual {
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pregunta-abierta textarea {
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.resumen-examen {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.resumen-examen h3 {
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
color: #1890ff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.resumen-examen p {
|
|
|
|
|
margin: 8px 0;
|
|
|
|
|
color: #595959;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.action-buttons {
|
|
|
|
|
padding: 24px 0;
|
|
|
|
|
border-top: 1px solid #f0f0f0;
|
|
|
|
|
margin-top: 32px;
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-preguntas {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin-top: 20px;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-derecha {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
margin-left: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.ant-card-head) {
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.ant-card-body) {
|
|
|
|
|
padding: 24px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* Responsive */
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
.examen-panel {
|
|
|
|
|
padding: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.examen-header {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: stretch;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.timer {
|
|
|
|
|
text-align: left;
|
|
|
|
|
margin-top: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pregunta-header {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.curso-tag {
|
|
|
|
|
margin-left: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.enunciado {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
font-size: 15px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.opcion-radio {
|
|
|
|
|
padding: 12px 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.action-buttons {
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.action-buttons button {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-derecha {
|
|
|
|
|
width: 100%;
|
|
|
|
|
margin-left: 0;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.nav-derecha button {
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</style>
|