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.

849 lines
20 KiB
Vue

<template>
<div class="exam-page">
<!-- HEADER PROFESIONAL -->
<a-card class="top-card" :bordered="false">
<div class="topbar">
<div class="top-left">
<div class="title">
<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>
<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="statusPill">
<span class="dot" :class="{ ok: porcentajeCompletado === 100 }"></span>
<span>{{ porcentajeCompletado === 100 ? "Listo para finalizar" : "En progreso" }}</span>
</div>
<div class="timerBox">
<div class="timerLabel">Tiempo restante</div>
<a-statistic-countdown
class="timer"
:value="timerValue"
@finish="finalizarExamenAutomaticamente"
format="HH:mm:ss"
/>
</div>
</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>
<div class="custom-divider"></div>
<!-- ENUNCIADO (SIN MARCOS: SOLO TEXTO + LÍNEAS) -->
<div class="qBody">
<div class="qStatement">
<MarkdownLatex :content="preguntaActual.enunciado" />
</div>
<div v-if="imagenesPregunta.length" class="imgWrap">
<a-image-preview-group>
<div class="imgGrid">
<a-image
v-for="(src, i) in imagenesPregunta"
:key="i"
:src="src"
:alt="`Imagen ${i + 1}`"
class="img"
/>
</div>
</a-image-preview-group>
</div>
<div v-if="preguntaActual.extra && preguntaActual.extra !== preguntaActual.enunciado" class="qExtra">
<MarkdownLatex :content="preguntaActual.extra" />
</div>
<!-- solo línea -->
<div class="lineDivider"></div>
</div>
<!-- RESPUESTAS (SIN MARCOS: SOLO LÍNEAS) -->
<div class="answer">
<div v-if="tieneOpciones(preguntaActual)">
<a-radio-group
v-model:value="preguntaActual.respuestaSeleccionada"
:disabled="preguntaActual.estado === 'respondida' || guardando || finalizando"
class="radio-group"
>
<div class="optList">
<a-radio
v-for="op in preguntaActual.opcionesOrdenadas"
:key="op.key"
:value="op.key.toString()"
class="optRow"
>
<span class="optKey">{{ getLetraOpcion(op.key) }})</span>
<span class="optTextInline">
<MarkdownLatex :content="op.texto" />
</span>
</a-radio>
</div>
</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 -->
<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 -->
<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>
<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";
import MarkdownLatex from "../../views/administrador/cursos/MarkdownLatex.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 };
}
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" };
if (p.estado === "respondida") return { success: true };
let respuesta = null;
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;
}
} else {
if (p.respuestaTexto && p.respuestaTexto.trim()) {
respuesta = p.respuestaTexto.trim();
}
}
return await examenStore.responderPregunta(p.id, respuesta);
};
/* IMÁGENES */
const normalizarImagenes = (imagenes) => {
if (!imagenes) return [];
if (Array.isArray(imagenes)) return imagenes.filter(Boolean).map(String);
if (typeof imagenes === "string") {
const s = imagenes.trim();
if (!s) return [];
if ((s.startsWith("[") && s.endsWith("]")) || (s.startsWith("{") && s.endsWith("}"))) {
try {
const parsed = JSON.parse(s);
if (Array.isArray(parsed)) return parsed.filter(Boolean).map(String);
if (parsed?.imagenes && Array.isArray(parsed.imagenes)) return parsed.imagenes.filter(Boolean).map(String);
} catch (_) {}
}
return [s];
}
return [];
};
const imagenesPregunta = computed(() => normalizarImagenes(preguntaActual.value?.imagenes));
/* ACCIÓN SIGUIENTE */
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;
}
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);
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);
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;
}
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;
}
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: 980px;
margin: 0 auto;
padding: 18px;
background: #f5f7fb;
min-height: 100vh;
}
/* HEADER */
.top-card {
border-radius: 16px;
background: #ffffff;
border: 1px solid rgba(17, 24, 39, 0.08);
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.06);
overflow: hidden;
}
.top-card::before {
content: "";
display: block;
height: 6px;
background: linear-gradient(90deg, #111827, #374151);
}
.topbar {
display: flex;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
align-items: flex-start;
padding: 14px 16px 16px;
}
.top-left {
flex: 1;
min-width: 280px;
}
.sub {
margin-top: 6px;
color: #475569;
font-size: 12px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.area {
font-weight: 800;
color: #0f172a;
}
.sep {
opacity: 0.55;
}
.meta {
margin-top: 10px;
font-size: 12px;
color: #64748b;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.progress {
margin-top: 12px;
}
.progressText {
margin-top: 6px;
font-size: 12px;
color: #64748b;
}
/* top-right */
.top-right {
min-width: 220px;
text-align: right;
display: grid;
gap: 10px;
justify-items: end;
}
.statusPill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
border: 1px solid rgba(17, 24, 39, 0.10);
background: #f8fafc;
color: #334155;
font-size: 12px;
font-weight: 800;
}
.statusPill .dot {
width: 10px;
height: 10px;
border-radius: 99px;
background: #f59e0b;
}
.statusPill .dot.ok {
background: #22c55e;
}
.timerBox {
width: 100%;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(17, 24, 39, 0.10);
background: #ffffff;
}
.timerLabel {
font-size: 12px;
color: #64748b;
font-weight: 800;
margin-bottom: 4px;
}
.timer :deep(.ant-statistic-content) {
font-size: 28px;
font-weight: 1000;
color: #0f172a;
letter-spacing: 0.6px;
}
/* CARDS */
.card {
margin-top: 14px;
border-radius: 18px;
background: #fff;
border: 1px solid rgba(17, 24, 39, 0.08);
box-shadow: 0 8px 20px rgba(17, 24, 39, 0.05);
}
.question-card {
padding-bottom: 10px;
}
/* PREGUNTA */
.q-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px 6px;
}
.q-number {
font-weight: 1000;
color: #0f172a;
font-size: 15px;
}
.q-tags {
margin-top: 8px;
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: 900;
border: 1px solid rgba(17, 24, 39, 0.08);
}
.chip.muted {
background: #f1f5f9;
color: #334155;
}
/* divisor bajo el header de pregunta */
.custom-divider {
margin: 0 16px 8px;
height: 1px;
background: rgba(15, 23, 42, 0.10);
}
/* ===== ENUNCIADO SIN MARCO (solo texto + aire) ===== */
.qBody {
margin: 0 16px 10px;
}
.qStatement,
.qExtra {
padding: 8px 0;
line-height: 1.75;
color: #0f172a;
overflow-wrap: anywhere;
}
/* Separador tipo examen (solo línea) */
.lineDivider {
margin: 12px 0 6px;
height: 1px;
background: rgba(15, 23, 42, 0.10);
}
/* KaTeX: aire arriba/abajo sin “flechas” */
.qBody :deep(.katex-display) {
margin: 14px 0;
padding: 8px 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.qBody :deep(.katex-display::-webkit-scrollbar) {
height: 0px;
}
/* IMÁGENES */
.imgWrap { margin-top: 10px; }
.imgGrid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 520px));
gap: 10px;
justify-content: center;
}
.img :deep(img) {
width: 100%;
height: 260px;
object-fit: contain;
border-radius: 12px;
border: 1px solid rgba(17, 24, 39, 0.10);
background: #fff;
}
/* ===== OPCIONES SIN MARCOS: solo líneas ===== */
.answer {
margin: 0 16px;
padding-bottom: 6px;
}
.radio-group { width: 100%; }
.optList {
display: flex;
flex-direction: column;
}
/* cada opción = una fila con solo línea abajo */
.optRow {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 2px;
margin: 0;
border-bottom: 1px solid rgba(15, 23, 42, 0.10);
}
/* radio alineado */
.optRow :deep(.ant-radio) {
align-self: flex-start;
margin-top: 2px;
}
/* A) y texto en una sola fila */
.optKey {
flex: 0 0 auto;
min-width: 28px;
font-weight: 1000;
color: #0f172a;
line-height: 1.4;
}
.optTextInline {
flex: 1 1 auto;
min-width: 0;
color: #0f172a;
line-height: 1.6;
overflow-wrap: anywhere;
}
/* seleccionado (sin caja) */
.optRow:has(:deep(.ant-radio-checked)) {
background: rgba(37, 99, 235, 0.06);
}
/* hover suave */
.optRow:hover {
background: rgba(15, 23, 42, 0.03);
}
/* Textarea */
:deep(.ant-input) {
border-radius: 12px;
}
:deep(.ant-input:focus),
:deep(.ant-input-focused) {
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
/* NAV */
.nav {
margin: 16px 16px 0;
display: flex;
justify-content: space-between;
gap: 10px;
padding-bottom: 6px;
}
.btn {
height: 42px;
border-radius: 12px;
font-weight: 900;
min-width: 150px;
}
.primary {
min-width: 230px;
}
/* Nota */
.note {
margin: 12px 16px 16px;
font-size: 12px;
color: #64748b;
line-height: 1.6;
padding: 10px 12px;
border-radius: 12px;
background: #f8fafc;
border: 1px dashed rgba(17, 24, 39, 0.14);
}
/* Responsive */
@media (max-width: 640px) {
.exam-page {
padding: 10px;
}
.top-right {
text-align: left;
justify-items: start;
min-width: 100%;
}
.qBody,
.answer {
margin: 0 12px;
}
.nav {
flex-direction: column;
margin: 14px 12px 0;
}
.btn,
.primary {
width: 100%;
min-width: 0;
}
.img :deep(img) {
height: 220px;
}
.optRow {
padding: 12px 0;
}
}
</style>