You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

906 lines
19 KiB
Vue

2 months ago
<template>
2 months ago
<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>
2 months ago
</div>
2 months ago
2 months ago
<div class="top-right">
<div class="timerLabel">Tiempo restante</div>
2 months ago
<a-statistic-countdown
2 months ago
class="timer"
2 months ago
:value="timerValue"
@finish="finalizarExamenAutomaticamente"
format="HH:mm:ss"
/>
</div>
</div>
</a-card>
2 months ago
<!-- LOADING -->
<a-card v-if="cargandoInicio" class="card" :bordered="false">
2 months ago
<a-skeleton active />
</a-card>
2 months ago
<!-- 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>
2 months ago
</div>
2 months ago
</div>
2 months ago
<!-- Enunciado -->
2 months ago
<div class="enunciado" v-html="preguntaActual.enunciado"></div>
2 months ago
2 months ago
<!-- Extra -->
2 months ago
<div
v-if="preguntaActual.extra && preguntaActual.extra !== preguntaActual.enunciado"
class="extra"
v-html="preguntaActual.extra"
></div>
2 months ago
<!-- 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" v-html="op.texto"></span>
</a-radio>
</a-space>
</a-radio-group>
2 months ago
</div>
2 months ago
<!-- Abierta -->
<div v-else>
<a-textarea
v-model:value="preguntaActual.respuestaTexto"
:rows="6"
placeholder="Escribe tu respuesta..."
:disabled="preguntaActual.estado === 'respondida' || guardando || finalizando"
/>
</div>
2 months ago
</div>
2 months ago
2 months ago
<!-- NAV: SOLO 2 BOTONES -->
<div class="nav">
<a-button
:disabled="indiceActual === 0 || guardando || finalizando"
@click="irAnterior"
class="btn"
>
Atrás
2 months ago
</a-button>
2 months ago
<a-button
type="primary"
:loading="guardando || finalizando"
@click="siguienteAccion"
class="btn primary"
>
{{ esUltima ? "Guardar y finalizar" : "Siguiente" }}
</a-button>
2 months ago
</div>
2 months ago
<!-- Nota sobria -->
<div class="note">
Al presionar <b>Siguiente</b>, tu respuesta se guarda. Evita recargar o cerrar durante el examen.
2 months ago
</div>
</a-card>
2 months ago
<!-- SIN PREGUNTAS -->
<a-card v-else class="card" :bordered="false">
2 months ago
<a-alert
type="warning"
show-icon
message="No hay preguntas para mostrar"
2 months ago
description="Verifica que el examen tenga preguntas generadas."
2 months ago
/>
2 months ago
<div class="nav" style="margin-top: 12px">
<a-button @click="router.push({ name: 'DashboardPostulante' })" class="btn">
Volver
2 months ago
</a-button>
</div>
</a-card>
</div>
</template>
2 months ago
<style scoped>
/* ============ LAYOUT SERIO ============ */
.exam-page {
max-width: 920px;
margin: 0 auto;
padding: 16px;
background: #f3f4f6; /* más neutral */
min-height: 100vh;
}
2 months ago
2 months ago
/* ============ HEADER ============ */
.top-card {
border-radius: 14px;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.06);
}
2 months ago
2 months ago
.topbar {
display: flex;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
align-items: flex-start;
}
2 months ago
2 months ago
.top-left {
flex: 1;
min-width: 260px;
}
2 months ago
2 months ago
.proceso {
font-weight: 900;
color: #111827;
font-size: 16px;
line-height: 1.2;
}
2 months ago
2 months ago
.sub {
margin-top: 6px;
color: #4b5563;
font-size: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
2 months ago
2 months ago
.area {
font-weight: 800;
color: #111827;
}
2 months ago
2 months ago
.sep {
opacity: 0.55;
}
2 months ago
2 months ago
.meta {
margin-top: 8px;
font-size: 12px;
color: #6b7280;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
2 months ago
2 months ago
.progress {
margin-top: 10px;
}
2 months ago
2 months ago
.progressText {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
}
2 months ago
2 months ago
/* Timer sobrio */
.top-right {
min-width: 190px;
text-align: right;
2 months ago
}
2 months ago
.timerLabel {
font-size: 12px;
color: #6b7280;
font-weight: 700;
margin-bottom: 2px;
}
2 months ago
2 months ago
.timer :deep(.ant-statistic-content) {
font-size: 26px;
font-weight: 900;
color: #111827;
}
2 months ago
2 months ago
/* ============ CARDS ============ */
.card {
margin-top: 12px;
border-radius: 16px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06);
}
2 months ago
2 months ago
.question-card {
padding-bottom: 6px;
}
2 months ago
2 months ago
/* ============ PREGUNTA ============ */
.q-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.q-number {
font-weight: 900;
color: #111827;
}
2 months ago
2 months ago
.q-tags {
margin-top: 6px;
display: flex;
gap: 8px;
flex-wrap: wrap;
}
2 months ago
2 months ago
.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);
2 months ago
}
2 months ago
.chip.muted {
background: #f3f4f6;
color: #374151;
2 months ago
}
2 months ago
/* 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;
2 months ago
}
2 months ago
2 months ago
.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;
2 months ago
}
2 months ago
2 months ago
/* ============ RESPUESTAS ============ */
.answer {
margin-top: 14px;
2 months ago
}
2 months ago
2 months ago
.radio-group {
width: 100%;
2 months ago
}
2 months ago
.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;
2 months ago
}
2 months ago
.opt:hover {
border-color: rgba(0, 0, 0, 0.18);
background: #fafafa;
}
2 months ago
2 months ago
.opt :deep(.ant-radio) {
margin-top: 2px;
}
.optKey {
min-width: 22px;
font-weight: 900;
color: #111827;
}
2 months ago
2 months ago
.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;
}
2 months ago
2 months ago
/* Nota discreta */
.note {
margin-top: 12px;
font-size: 12px;
color: #6b7280;
line-height: 1.5;
}
2 months ago
2 months ago
/* Responsive */
@media (max-width: 640px) {
.top-right {
text-align: left;
}
.nav {
flex-direction: column;
2 months ago
}
2 months ago
.btn,
.primary {
width: 100%;
min-width: 0;
}
}
</style>
2 months ago
2 months ago
<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 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);
/* INFO EXAMEN */
const examenInfo = computed(() => {
if (!examenStore.examenActual) {
return { proceso: null, area: null, duracion: 60, intentos: 0, intentos_maximos: 3 };
2 months ago
}
2 months ago
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,
};
});
2 months ago
2 months ago
/* 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 ?? "",
}));
};
2 months ago
2 months ago
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;
2 months ago
2 months ago
try {
2 months ago
guardando.value = true;
2 months ago
2 months ago
const r = await guardarRespuestaDePregunta(p);
if (!r?.success) {
message.warning(r?.message || "Completa tu respuesta antes de continuar");
return;
2 months ago
}
2 months ago
p.estado = "respondida";
2 months ago
2 months ago
if (!esUltima.value) {
indiceActual.value++;
return;
2 months ago
}
2 months ago
// última: finalizar
await finalizarExamen();
2 months ago
} catch (e) {
2 months ago
console.error(e);
message.error("Error al guardar");
2 months ago
} finally {
2 months ago
guardando.value = false;
2 months ago
}
2 months ago
};
2 months ago
2 months ago
/* FINALIZAR */
2 months ago
const finalizarExamen = async () => {
2 months ago
const examenId = route.params.examenId;
2 months ago
Modal.confirm({
2 months ago
title: "Finalizar examen",
content: "¿Confirmas finalizar? Luego no podrás modificar respuestas.",
okText: "Finalizar",
cancelText: "Cancelar",
2 months ago
onOk: async () => {
try {
2 months ago
finalizando.value = true;
const r = await examenStore.finalizarExamen(examenId);
if (r?.success) {
message.success("Examen finalizado");
router.push({ name: "PanelResultados", params: { examenId } });
2 months ago
} else {
2 months ago
message.error(r?.message || "No se pudo finalizar");
2 months ago
}
2 months ago
} catch (e) {
console.error(e);
message.error("Error al finalizar");
2 months ago
} finally {
2 months ago
finalizando.value = false;
2 months ago
}
2 months ago
},
});
};
2 months ago
2 months ago
/* TIMER */
2 months ago
const finalizarExamenAutomaticamente = () => {
2 months ago
message.error("Tiempo agotado. El examen se finalizará.");
finalizarExamen();
};
2 months ago
const calcularTiempoRestante = () => {
if (examenStore.examenActual?.hora_inicio && examenInfo.value.duracion) {
2 months ago
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();
2 months ago
} else {
2 months ago
timerValue.value = Date.now() + examenInfo.value.duracion * 60 * 1000;
2 months ago
}
2 months ago
};
2 months ago
2 months ago
/* INIT */
2 months ago
const iniciarSesionExamen = async () => {
2 months ago
if (initOnce.value) return;
initOnce.value = true;
2 months ago
2 months ago
const examenId = route.params.examenId;
2 months ago
if (!examenId) {
2 months ago
message.error("No se encontró el examen");
router.push({ name: "DashboardPostulante" });
return;
2 months ago
}
2 months ago
try {
2 months ago
cargandoInicio.value = true;
2 months ago
2 months ago
const r = await examenStore.iniciarExamen(examenId);
2 months ago
if (!r?.success) {
2 months ago
message.error(r?.message || "No se pudo iniciar el examen");
router.push({ name: "DashboardPostulante" });
return;
2 months ago
}
2 months ago
if (Array.isArray(examenStore.preguntas) && examenStore.preguntas.length) {
preguntasLocal.value = transformarPreguntas(examenStore.preguntas);
indiceActual.value = 0;
2 months ago
} else {
2 months ago
message.error("No hay preguntas");
router.push({ name: "DashboardPostulante" });
return;
2 months ago
}
2 months ago
calcularTiempoRestante();
if (timerIntervalId) clearInterval(timerIntervalId);
timerIntervalId = setInterval(calcularTiempoRestante, 30000);
2 months ago
} finally {
2 months ago
cargandoInicio.value = false;
2 months ago
}
2 months ago
};
2 months ago
2 months ago
onMounted(iniciarSesionExamen);
2 months ago
2 months ago
onBeforeUnmount(() => {
2 months ago
if (timerIntervalId) clearInterval(timerIntervalId);
});
2 months ago
</script>
<style scoped>
2 months ago
/* ============ LAYOUT SERIO ============ */
.exam-page {
max-width: 920px;
2 months ago
margin: 0 auto;
2 months ago
padding: 16px;
background: #f6f7f9; /* sobrio */
min-height: 100vh;
2 months ago
}
2 months ago
/* ============ HEADER ============ */
.top-card {
border-radius: 14px;
background: #ffffff;
border: 1px solid rgba(0,0,0,0.06);
}
.topbar {
2 months ago
display: flex;
justify-content: space-between;
2 months ago
gap: 14px;
2 months ago
flex-wrap: wrap;
2 months ago
align-items: flex-start;
2 months ago
}
2 months ago
.top-left {
flex: 1;
min-width: 260px;
2 months ago
}
2 months ago
.title .proceso {
font-weight: 900;
color: #111827;
font-size: 16px;
2 months ago
}
2 months ago
.title .sub {
margin-top: 4px;
color: #4b5563;
font-size: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
2 months ago
}
2 months ago
.area {
font-weight: 800;
color: #111827;
2 months ago
}
2 months ago
.sep {
opacity: 0.5;
2 months ago
}
2 months ago
.meta {
margin-top: 8px;
font-size: 12px;
color: #6b7280;
2 months ago
display: flex;
2 months ago
gap: 8px;
2 months ago
flex-wrap: wrap;
}
2 months ago
.progress {
margin-top: 10px;
2 months ago
}
2 months ago
.progressText {
margin-top: 6px;
font-size: 12px;
color: #6b7280;
2 months ago
}
2 months ago
/* Timer sobrio */
.top-right {
min-width: 180px;
text-align: right;
2 months ago
}
2 months ago
.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; /* serio, sin rojo chillón */
2 months ago
}
2 months ago
/* ============ CARDS ============ */
.card {
margin-top: 12px;
border-radius: 16px;
background: #fff;
border: 1px solid rgba(0,0,0,0.06);
2 months ago
}
2 months ago
.question-card {
padding-bottom: 6px;
}
/* ============ PREGUNTA ============ */
.q-header {
2 months ago
display: flex;
2 months ago
justify-content: space-between;
2 months ago
align-items: center;
}
2 months ago
.q-number {
font-weight: 900;
color: #111827;
2 months ago
}
2 months ago
.q-tags {
margin-top: 6px;
display: flex;
gap: 8px;
flex-wrap: wrap;
2 months ago
}
2 months ago
.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);
2 months ago
}
2 months ago
.chip.muted {
background: #f3f4f6;
color: #374151;
}
/* Enunciado serio, legible */
.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;
2 months ago
font-size: 15px;
2 months ago
color: #111827;
overflow-wrap: anywhere;
2 months ago
}
2 months ago
.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;
2 months ago
}
2 months ago
/* ============ RESPUESTAS ============ */
.answer {
margin-top: 14px;
2 months ago
}
2 months ago
.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;
2 months ago
}
2 months ago
.opt:hover {
border-color: rgba(0,0,0,0.20);
background: #fafafa;
2 months ago
}
2 months ago
.opt :deep(.ant-radio) {
margin-top: 2px;
2 months ago
}
2 months ago
.optKey {
min-width: 22px;
font-weight: 900;
color: #111827;
2 months ago
}
2 months ago
.optText {
line-height: 1.6;
color: #111827;
overflow-wrap: anywhere;
}
/* ============ NAV (SOLO 2 BOTONES) ============ */
.nav {
margin-top: 16px;
2 months ago
display: flex;
justify-content: space-between;
2 months ago
gap: 10px;
2 months ago
}
2 months ago
.btn {
height: 40px;
border-radius: 10px;
font-weight: 800;
min-width: 140px;
2 months ago
}
2 months ago
.primary {
min-width: 200px;
2 months ago
}
2 months ago
/* Nota discreta */
.note {
margin-top: 12px;
font-size: 12px;
color: #6b7280;
line-height: 1.5;
2 months ago
}
/* Responsive */
2 months ago
@media (max-width: 640px) {
.top-right {
2 months ago
text-align: left;
}
2 months ago
.nav {
2 months ago
flex-direction: column;
}
2 months ago
.btn, .primary {
2 months ago
width: 100%;
2 months ago
min-width: 0;
2 months ago
}
2 months ago
}
2 months ago
</style>