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.

1729 lines
45 KiB
Vue

2 months ago
<!-- components/AdminAcademia/Cursos/PreguntasCursoView.vue -->
<template>
<div class="preguntas-curso-view">
<!-- Header con navegación -->
<div class="view-header">
<div class="header-left">
<a-button type="link" @click="goBack" class="back-btn">
<ArrowLeftOutlined /> Volver a Cursos
</a-button>
<div class="header-title">
<h2>{{ curso.nombre }}</h2>
<div class="curso-info">
<span class="curso-codigo">{{ curso.codigo }}</span>
<a-tag :color="curso.activo ? 'green' : 'red'">
{{ curso.activo ? 'Activo' : 'Inactivo' }}
</a-tag>
</div>
</div>
</div>
<div class="header-right">
<a-button type="primary" @click="showCreateModal">
<template #icon><PlusOutlined /></template>
Nueva Pregunta
</a-button>
</div>
</div>
<!-- Estadísticas del Curso -->
<div class="curso-stats">
<a-card class="stat-card">
<div class="stat-content">
<div class="stat-icon-wrapper" style="background: #e6f7ff;">
<QuestionCircleOutlined style="color: #1890ff;" />
</div>
<div class="stat-info">
<div class="stat-value">{{ estadisticas.total || 0 }}</div>
<div class="stat-label">Total Preguntas</div>
</div>
</div>
</a-card>
<a-card class="stat-card">
<div class="stat-content">
<div class="stat-icon-wrapper" style="background: #f6ffed;">
<CheckCircleOutlined style="color: #52c41a;" />
</div>
<div class="stat-info">
<div class="stat-value">{{ estadisticas.facil || 0 }}</div>
<div class="stat-label">Fáciles</div>
</div>
</div>
</a-card>
<a-card class="stat-card">
<div class="stat-content">
<div class="stat-icon-wrapper" style="background: #fff7e6;">
<WarningOutlined style="color: #fa8c16;" />
</div>
<div class="stat-info">
<div class="stat-value">{{ estadisticas.medio || 0 }}</div>
<div class="stat-label">Medias</div>
</div>
</div>
</a-card>
<a-card class="stat-card">
<div class="stat-content">
<div class="stat-icon-wrapper" style="background: #fff2f0;">
<CloseCircleOutlined style="color: #ff4d4f;" />
</div>
<div class="stat-info">
<div class="stat-value">{{ estadisticas.dificil || 0 }}</div>
<div class="stat-label">Difíciles</div>
</div>
</div>
</a-card>
</div>
<!-- Filtros -->
<div class="filters-section">
<div class="filters">
<a-input-search
v-model:value="searchText"
placeholder="Buscar en preguntas..."
@search="handleSearch"
style="width: 300px"
size="large"
/>
<a-select
v-model:value="dificultadFilter"
placeholder="Todas las dificultades"
style="width: 200px; margin-left: 16px"
size="large"
@change="handleFilterChange"
>
<a-select-option value="">Todas las dificultades</a-select-option>
<a-select-option value="facil">Fácil</a-select-option>
<a-select-option value="medio">Media</a-select-option>
<a-select-option value="dificil">Difícil</a-select-option>
</a-select>
<a-select
v-model:value="estadoFilter"
placeholder="Todos los estados"
style="width: 200px; margin-left: 16px"
size="large"
@change="handleFilterChange"
>
<a-select-option value="">Todos los estados</a-select-option>
<a-select-option value="true">Activo</a-select-option>
<a-select-option value="false">Inactivo</a-select-option>
</a-select>
</div>
<div class="filter-actions">
<a-button @click="clearFilters" size="large">
<template #icon><ReloadOutlined /></template>
Limpiar filtros
</a-button>
</div>
</div>
<!-- Tabla de Preguntas -->
<div class="preguntas-table-container">
<a-table
:data-source="preguntas"
:columns="columns"
:loading="preguntaStore.loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="preguntas-table"
>
<template #bodyCell="{ column, record }">
<!-- Dificultad -->
<template v-if="column.key === 'nivel_dificultad'">
<a-tag :color="getDificultadColor(record.nivel_dificultad)">
{{ formatDificultad(record.nivel_dificultad) }}
</a-tag>
</template>
<!-- Estado -->
<template v-if="column.key === 'activo'">
<a-switch
:checked="record.activo"
@change="togglePreguntaStatus(record)"
:checked-children="'Activo'"
:un-checked-children="'Inactivo'"
/>
</template>
<!-- Vista Previa del Enunciado -->
<template v-if="column.key === 'enunciado'">
<div class="enunciado-preview" v-html="getEnunciadoPreview(record.enunciado)" />
</template>
<!-- Fecha Creación -->
<template v-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<!-- Acciones -->
<template v-if="column.key === 'acciones'">
<a-space>
<a-button
type="link"
size="small"
@click="verPregunta(record)"
class="action-btn"
>
<EyeOutlined /> Ver
</a-button>
<a-button
type="link"
size="small"
@click="showEditModal(record)"
class="action-btn"
>
<EditOutlined /> Editar
</a-button>
<a-button
type="link"
size="small"
danger
@click="confirmDeletePregunta(record)"
class="action-btn"
>
<DeleteOutlined /> Eliminar
</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- Modal Crear/Editar Pregunta -->
<a-modal
v-model:open="modalPreguntaVisible"
:title="isEditingPregunta ? 'Editar Pregunta' : 'Nueva Pregunta'"
:confirm-loading="preguntaStore.loading"
@ok="handleModalPreguntaOk"
@cancel="handleModalPreguntaCancel"
width="900px"
class="pregunta-modal"
>
<a-form
ref="formPreguntaRef"
:model="formPreguntaState"
:rules="formPreguntaRules"
layout="vertical"
>
<!-- Sección del Enunciado -->
<a-card size="small" class="section-card">
<template #title>
<div class="section-title">
<span>Enunciado de la Pregunta</span>
</div>
</template>
<a-form-item
label="Texto del Enunciado *"
name="enunciado"
:validate-status="getFieldStatus('enunciado')"
:help="getFieldHelp('enunciado')"
>
<a-textarea
v-model:value="formPreguntaState.enunciado"
placeholder="Escribe el enunciado principal de la pregunta..."
:rows="4"
:maxlength="1000"
show-count
/>
</a-form-item>
<!-- Subida de imágenes para el enunciado -->
<a-form-item
label="Imágenes del Enunciado (Opcional)"
>
<div class="upload-section">
<a-upload
v-model:file-list="imagenesEnunciadoFiles"
list-type="picture-card"
:multiple="true"
:before-upload="beforeUpload"
accept="image/*"
@preview="handlePreview"
@remove="handleRemoveImagenEnunciado"
>
<div v-if="imagenesEnunciadoFiles.length < 5">
<PlusOutlined />
<div class="ant-upload-text">Agregar Imagen</div>
<div class="ant-upload-hint">Máx. 5 imágenes, 2MB c/u</div>
</div>
</a-upload>
<!-- Vista previa de imágenes existentes (solo en edición) -->
<div v-if="isEditingPregunta && formPreguntaState.imagenes_existentes?.length" class="existing-images">
<h4>Imágenes existentes:</h4>
<div class="image-grid">
<div v-for="(imagen, index) in formPreguntaState.imagenes_existentes"
:key="'existente-'+index"
class="image-item">
<img :src="getImageUrl(imagen)" :alt="'Imagen ' + (index+1)" />
<a-button
type="link"
danger
@click="eliminarImagenExistente('enunciado', index)"
class="remove-image-btn"
>
<DeleteOutlined />
</a-button>
</div>
</div>
</div>
</div>
</a-form-item>
<a-form-item
label="Enunciado Adicional (Opcional)"
>
<a-textarea
v-model:value="formPreguntaState.enunciado_adicional"
placeholder="Información adicional, contexto o detalles importantes..."
:rows="3"
/>
</a-form-item>
</a-card>
<!-- Sección de Opciones -->
<a-card size="small" class="section-card">
<template #title>
<div class="section-title">
<span>Opciones de Respuesta</span>
<span class="section-subtitle">(Mínimo 2 opciones, selecciona la correcta)</span>
</div>
</template>
<div class="opciones-container">
<div v-for="(opcion, index) in formPreguntaState.opciones"
:key="index"
class="opcion-item"
:class="{ 'correct-option': formPreguntaState.respuesta_correcta === opcion }">
<div class="opcion-header">
<div class="opcion-label">
<span class="opcion-letter">{{ String.fromCharCode(65 + index) }}</span>
<a-input
v-model:value="formPreguntaState.opciones[index]"
placeholder="Texto de la opción..."
@change="updateOpcion(index, $event.target.value)"
class="opcion-input"
/>
</div>
<div class="opcion-actions">
<a-radio
:checked="formPreguntaState.respuesta_correcta === opcion"
@change="() => setRespuestaCorrecta(opcion)"
>
Correcta
</a-radio>
<a-button
v-if="formPreguntaState.opciones.length > 2"
type="link"
danger
@click="removeOpcion(index)"
class="remove-opcion-btn"
>
<DeleteOutlined /> Eliminar
</a-button>
</div>
</div>
</div>
</div>
<a-button
type="dashed"
@click="addOpcion"
block
:disabled="formPreguntaState.opciones.length >= 5"
>
<PlusOutlined /> Agregar Opción ({{ formPreguntaState.opciones.length }}/5)
</a-button>
</a-card>
<!-- Sección de Explicación -->
<a-card size="small" class="section-card">
<template #title>
<div class="section-title">
<span>Explicación y Retroalimentación</span>
</div>
</template>
<a-form-item
label="Explicación de la Respuesta Correcta (Opcional)"
>
<a-textarea
v-model:value="formPreguntaState.explicacion"
placeholder="Explica por qué esta opción es la correcta..."
:rows="4"
/>
</a-form-item>
<!-- Subida de imágenes para la explicación -->
<a-form-item
label="Imágenes de la Explicación (Opcional)"
>
<div class="upload-section">
<a-upload
v-model:file-list="imagenesExplicacionFiles"
list-type="picture-card"
:multiple="true"
:before-upload="beforeUpload"
accept="image/*"
@preview="handlePreview"
@remove="handleRemoveImagenExplicacion"
>
<div v-if="imagenesExplicacionFiles.length < 3">
<PlusOutlined />
<div class="ant-upload-text">Agregar Imagen</div>
<div class="ant-upload-hint">Máx. 3 imágenes, 2MB c/u</div>
</div>
</a-upload>
<!-- Vista previa de imágenes existentes (solo en edición) -->
<div v-if="isEditingPregunta && formPreguntaState.imagenes_explicacion_existentes?.length" class="existing-images">
<h4>Imágenes existentes:</h4>
<div class="image-grid">
<div v-for="(imagen, index) in formPreguntaState.imagenes_explicacion_existentes"
:key="'exp-existente-'+index"
class="image-item">
<img :src="getImageUrl(imagen)" :alt="'Imagen ' + (index+1)" />
<a-button
type="link"
danger
@click="eliminarImagenExistente('explicacion', index)"
class="remove-image-btn"
>
<DeleteOutlined />
</a-button>
</div>
</div>
</div>
</div>
</a-form-item>
</a-card>
<!-- Configuración -->
<a-card size="small" class="section-card">
<template #title>
<div class="section-title">
<span>Configuración</span>
</div>
</template>
<div class="config-row">
<a-form-item
label="Nivel de Dificultad *"
name="nivel_dificultad"
:validate-status="getFieldStatus('nivel_dificultad')"
:help="getFieldHelp('nivel_dificultad')"
class="config-item"
>
<a-select
v-model:value="formPreguntaState.nivel_dificultad"
placeholder="Selecciona la dificultad"
>
<a-select-option value="facil">
<div class="dificultad-option">
<CheckCircleOutlined style="color: #52c41a;" />
<span>Fácil</span>
</div>
</a-select-option>
<a-select-option value="medio">
<div class="dificultad-option">
<WarningOutlined style="color: #fa8c16;" />
<span>Media</span>
</div>
</a-select-option>
<a-select-option value="dificil">
<div class="dificultad-option">
<CloseCircleOutlined style="color: #ff4d4f;" />
<span>Difícil</span>
</div>
</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="Estado"
class="config-item"
>
<a-switch
v-model:checked="formPreguntaState.activo"
:checked-children="'Activo'"
:un-checked-children="'Inactivo'"
/>
</a-form-item>
</div>
</a-card>
<!-- Errores -->
<div class="form-footer" v-if="preguntaStore.errors">
<a-alert
v-for="(errorList, field) in preguntaStore.errors"
:key="field"
type="error"
:message="field === 'message' ? errorList : `${field}: ${errorList[0]}`"
show-icon
class="error-alert"
/>
</div>
</a-form>
</a-modal>
<!-- Modal de Vista de Pregunta -->
<a-modal
v-model:open="verPreguntaModalVisible"
:title="'Vista de Pregunta'"
width="800px"
:footer="null"
@cancel="verPreguntaModalVisible = false"
>
<div class="pregunta-view" v-if="preguntaSeleccionada">
<!-- Enunciado -->
<div class="enunciado-completo" v-html="preguntaSeleccionada.enunciado" />
<!-- Imágenes del enunciado -->
<div v-if="preguntaSeleccionada.imagenes && preguntaSeleccionada.imagenes.length"
class="imagenes-preview">
<h4>Imágenes del Enunciado:</h4>
<div class="imagenes-grid">
2 months ago
<img
v-for="(imagen, index) in preguntaSeleccionada.imagenes"
:key="index"
:src="imagen"
:alt="'Imagen ' + (index+1)"
@click="verImagen(imagen)"
class="clickable-image"
/>
2 months ago
</div>
</div>
<!-- Enunciado Adicional -->
<div v-if="preguntaSeleccionada.enunciado_adicional" class="enunciado-adicional">
<h4>Información Adicional:</h4>
<div v-html="preguntaSeleccionada.enunciado_adicional" />
</div>
<!-- Opciones -->
<div class="opciones-view">
<h4>Opciones de Respuesta:</h4>
<div
v-for="(opcion, index) in (preguntaSeleccionada.opciones || [])"
:key="index"
class="opcion-view"
:class="{ correcta: opcion === preguntaSeleccionada.respuesta_correcta }"
>
<div class="opcion-letter">{{ String.fromCharCode(65 + index) }}.</div>
<div class="opcion-text">{{ opcion }}</div>
<div class="opcion-correct" v-if="opcion === preguntaSeleccionada.respuesta_correcta">
<CheckCircleOutlined style="color: #52c41a; margin-left: 8px;" />
</div>
</div>
</div>
<!-- Explicación -->
<div v-if="preguntaSeleccionada.explicacion" class="explicacion-view">
<h4>Explicación:</h4>
<div v-html="preguntaSeleccionada.explicacion" />
</div>
<!-- Imágenes de la explicación -->
<div v-if="preguntaSeleccionada.imagenes_explicacion && preguntaSeleccionada.imagenes_explicacion.length"
class="imagenes-preview">
<h4>Imágenes de la Explicación:</h4>
<div class="imagenes-grid">
<img
v-for="(imagen, index) in preguntaSeleccionada.imagenes_explicacion"
:key="index"
:src="getImageUrl(imagen)"
:alt="'Imagen ' + (index+1)"
@click="verImagen(getImageUrl(imagen))"
class="clickable-image"
/>
</div>
</div>
<!-- Información Adicional -->
<div class="info-adicional">
<a-divider />
<div class="info-row">
<div class="info-item">
<strong>Dificultad:</strong>
<a-tag :color="getDificultadColor(preguntaSeleccionada.nivel_dificultad)">
{{ formatDificultad(preguntaSeleccionada.nivel_dificultad) }}
</a-tag>
</div>
<div class="info-item">
<strong>Estado:</strong>
<a-tag :color="preguntaSeleccionada.activo ? 'green' : 'red'">
{{ preguntaSeleccionada.activo ? 'Activo' : 'Inactivo' }}
</a-tag>
</div>
<div class="info-item">
<strong>Creado:</strong> {{ formatDate(preguntaSeleccionada.created_at) }}
</div>
</div>
</div>
</div>
</a-modal>
<!-- Modal de Confirmación para Eliminar Pregunta -->
<a-modal
v-model:open="deletePreguntaModalVisible"
title="Eliminar Pregunta"
@ok="handleDeletePregunta"
@cancel="deletePreguntaModalVisible = false"
ok-text="Eliminar"
cancel-text="Cancelar"
ok-type="danger"
>
<div class="delete-confirm-content">
<a-alert
message="¿Estás seguro de eliminar esta pregunta?"
description="Esta acción no se puede deshacer."
type="warning"
show-icon
/>
<div class="pregunta-info" v-if="preguntaToDelete">
<p><strong>Pregunta:</strong></p>
<div class="enunciado-preview" v-html="getEnunciadoPreview(preguntaToDelete.enunciado)" />
<p><strong>Dificultad:</strong> {{ formatDificultad(preguntaToDelete.nivel_dificultad) }}</p>
</div>
</div>
</a-modal>
<!-- Modal de Vista Previa de Imagen -->
<a-modal :open="previewVisible" :title="previewTitle" :footer="null" @cancel="previewVisible = false">
<img alt="Vista previa" style="width: 100%" :src="previewImage" />
</a-modal>
<!-- Modal para ver imagen en grande -->
<a-modal v-model:open="modalImagenVisible" :footer="null" @cancel="modalImagenVisible = false">
<img :src="imagenGrande" style="width: 100%" />
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { usePreguntaStore } from '../../../store/pregunta.store'
import { useCursoStore } from '../../../store/curso.store'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
EyeOutlined,
QuestionCircleOutlined,
CheckCircleOutlined,
WarningOutlined,
CloseCircleOutlined,
ArrowLeftOutlined,
ReloadOutlined
} from '@ant-design/icons-vue'
// Router y Store
const route = useRoute()
const router = useRouter()
const preguntaStore = usePreguntaStore()
const cursoStore = useCursoStore()
// Estado
const modalPreguntaVisible = ref(false)
const verPreguntaModalVisible = ref(false)
const deletePreguntaModalVisible = ref(false)
const isEditingPregunta = ref(false)
const searchText = ref('')
const dificultadFilter = ref('')
const estadoFilter = ref('')
const preguntaSeleccionada = ref(null)
const preguntaToDelete = ref(null)
const formPreguntaRef = ref()
const modalImagenVisible = ref(false)
const imagenGrande = ref('')
// Estado para imágenes
const imagenesEnunciadoFiles = ref([])
const imagenesExplicacionFiles = ref([])
const previewVisible = ref(false)
const previewImage = ref('')
const previewTitle = ref('')
// Datos del curso
const curso = reactive({
id: null,
nombre: '',
codigo: '',
activo: true,
preguntas_count: 0
})
const estadisticas = reactive({
total: 0,
facil: 0,
medio: 0,
dificil: 0
})
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '30', '50'],
showTotal: (total, range) => `${range[0]}-${range[1]} de ${total} preguntas`
})
// Formulario de Pregunta
const formPreguntaState = reactive({
id: null,
curso_id: null,
enunciado: '',
enunciado_adicional: '',
opciones: ['', ''],
respuesta_correcta: '',
explicacion: '',
nivel_dificultad: 'medio',
activo: true,
imagenes_existentes: [],
imagenes_explicacion_existentes: []
})
// Funciones de validación manual
const validarFormulario = () => {
const errors = []
// Validar opciones
const opcionesValidas = formPreguntaState.opciones.filter(op => op && op.trim() !== '')
if (opcionesValidas.length < 2) {
errors.push('Debe completar al menos 2 opciones')
}
// Verificar duplicados
const opcionesSet = new Set(opcionesValidas.map(op => op.trim().toLowerCase()))
if (opcionesSet.size !== opcionesValidas.length) {
errors.push('No puede haber opciones duplicadas')
}
// Validar respuesta correcta
if (!formPreguntaState.respuesta_correcta || formPreguntaState.respuesta_correcta.trim() === '') {
errors.push('Debe seleccionar una respuesta correcta')
} else if (!opcionesValidas.includes(formPreguntaState.respuesta_correcta)) {
errors.push('La respuesta correcta debe estar entre las opciones')
}
return errors
}
// Reglas de validación (solo campos básicos, opciones se validan manualmente)
const formPreguntaRules = {
enunciado: [
{ required: true, message: 'El enunciado es requerido', trigger: 'blur' },
{ min: 5, message: 'El enunciado debe tener al menos 5 caracteres', trigger: 'blur' }
],
nivel_dificultad: [
{ required: true, message: 'La dificultad es requerida', trigger: 'change' }
]
}
// Columnas de la tabla de preguntas
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80
},
{
title: 'Enunciado',
dataIndex: 'enunciado',
key: 'enunciado',
ellipsis: true
},
{
title: 'Dificultad',
dataIndex: 'nivel_dificultad',
key: 'nivel_dificultad',
width: 120
},
{
title: 'Estado',
dataIndex: 'activo',
key: 'activo',
width: 100
},
{
title: 'Creado',
dataIndex: 'created_at',
key: 'created_at',
width: 150
},
{
title: 'Acciones',
key: 'acciones',
width: 200,
align: 'center'
}
]
// Computed
const preguntas = computed(() => preguntaStore.preguntas)
// Métodos para manejar imágenes
const beforeUpload = (file) => {
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
message.error('Solo se pueden subir imágenes!')
return false
}
if (!isLt2M) {
message.error('La imagen debe ser menor a 2MB!')
return false
}
return false // Devolver false para manejar la subida manualmente
}
const handleRemoveImagenEnunciado = (file) => {
const index = imagenesEnunciadoFiles.value.findIndex(f => f.uid === file.uid)
if (index !== -1) {
imagenesEnunciadoFiles.value.splice(index, 1)
}
}
const handleRemoveImagenExplicacion = (file) => {
const index = imagenesExplicacionFiles.value.findIndex(f => f.uid === file.uid)
if (index !== -1) {
imagenesExplicacionFiles.value.splice(index, 1)
}
}
const handlePreview = async (file) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj)
}
previewImage.value = file.url || file.preview
previewVisible.value = true
previewTitle.value = file.name || file.url?.substring(file.url?.lastIndexOf('/') + 1) || 'Imagen'
}
const getBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = error => reject(error)
})
}
const getImageUrl = (path) => {
if (!path) return ''
if (path.startsWith('http')) return path
return `/storage/${path}`
}
const eliminarImagenExistente = (tipo, index) => {
if (tipo === 'enunciado') {
formPreguntaState.imagenes_existentes.splice(index, 1)
message.success('Imagen eliminada (se aplicará al guardar)')
} else if (tipo === 'explicacion') {
formPreguntaState.imagenes_explicacion_existentes.splice(index, 1)
message.success('Imagen eliminada (se aplicará al guardar)')
}
}
// Métodos para ver imágenes en grande
const verImagen = (url) => {
imagenGrande.value = url
modalImagenVisible.value = true
}
// Métodos principales
const goBack = () => {
router.push({ name: 'AcademiaCursos' })
}
const fetchPreguntas = async () => {
const params = {
page: pagination.current,
per_page: pagination.pageSize
}
if (searchText.value) params.search = searchText.value
if (dificultadFilter.value) params.nivel_dificultad = dificultadFilter.value
if (estadoFilter.value !== '') params.activo = estadoFilter.value === 'true'
const data = await preguntaStore.fetchPreguntasByCurso(curso.id, params)
if (data) {
// Actualizar estadísticas
estadisticas.total = data.estadisticas?.total || 0
estadisticas.facil = data.estadisticas?.facil || 0
estadisticas.medio = data.estadisticas?.medio || 0
estadisticas.dificil = data.estadisticas?.dificil || 0
// Actualizar paginación
if (data.preguntas) {
pagination.total = data.preguntas.total || 0
pagination.current = data.preguntas.current_page || 1
pagination.pageSize = data.preguntas.per_page || 10
}
}
}
const loadCursoInfo = async () => {
const cursoId = parseInt(route.params.id)
curso.id = cursoId
const cursoEncontrado = cursoStore.cursos.find(c => c.id === cursoId)
if (cursoEncontrado) {
Object.assign(curso, cursoEncontrado)
} else {
curso.nombre = `Curso ${cursoId}`
}
}
const showCreateModal = () => {
isEditingPregunta.value = false
resetPreguntaForm()
formPreguntaState.curso_id = curso.id
modalPreguntaVisible.value = true
preguntaStore.errors = null
}
const showEditModal = (pregunta) => {
isEditingPregunta.value = true
resetPreguntaForm()
// Llenar formulario con datos de la pregunta
formPreguntaState.id = pregunta.id
formPreguntaState.curso_id = curso.id
formPreguntaState.enunciado = pregunta.enunciado
formPreguntaState.enunciado_adicional = pregunta.enunciado_adicional || ''
formPreguntaState.opciones = pregunta.opciones && Array.isArray(pregunta.opciones) ? pregunta.opciones : ['']
formPreguntaState.respuesta_correcta = pregunta.respuesta_correcta || ''
formPreguntaState.explicacion = pregunta.explicacion || ''
formPreguntaState.nivel_dificultad = pregunta.nivel_dificultad
formPreguntaState.activo = pregunta.activo
// Cargar imágenes existentes
formPreguntaState.imagenes_existentes = pregunta.imagenes || []
formPreguntaState.imagenes_explicacion_existentes = pregunta.imagenes_explicacion || []
// Limpiar archivos nuevos
imagenesEnunciadoFiles.value = []
imagenesExplicacionFiles.value = []
modalPreguntaVisible.value = true
preguntaStore.errors = null
}
const verPregunta = (pregunta) => {
preguntaSeleccionada.value = pregunta
verPreguntaModalVisible.value = true
}
const confirmDeletePregunta = (pregunta) => {
preguntaToDelete.value = pregunta
deletePreguntaModalVisible.value = true
}
const handleDeletePregunta = async () => {
try {
await preguntaStore.eliminarPregunta(preguntaToDelete.value.id)
message.success('Pregunta eliminada correctamente')
deletePreguntaModalVisible.value = false
preguntaToDelete.value = null
await fetchPreguntas()
} catch (error) {
message.error('Error al eliminar la pregunta')
}
}
const handleModalPreguntaOk = async () => {
try {
// Validar campos básicos con Ant Design
await formPreguntaRef.value.validateFields()
// Luego validar opciones y respuesta correcta manualmente
const erroresValidacion = validarFormulario()
if (erroresValidacion.length > 0) {
erroresValidacion.forEach(error => {
message.error(error)
})
return
}
// Si pasa todas las validaciones, proceder
await submitPreguntaForm()
} catch (error) {
console.log('Validación fallida:', error)
}
}
const handleModalPreguntaCancel = () => {
modalPreguntaVisible.value = false
resetPreguntaForm()
preguntaStore.errors = null
}
const submitPreguntaForm = async () => {
try {
// Validar antes de enviar
const erroresValidacion = validarFormulario()
if (erroresValidacion.length > 0) {
erroresValidacion.forEach(error => {
message.error(error)
})
return
}
// Crear FormData para enviar archivos
const formData = new FormData()
// Agregar campos básicos
formData.append('curso_id', formPreguntaState.curso_id)
formData.append('enunciado', formPreguntaState.enunciado)
formData.append('nivel_dificultad', formPreguntaState.nivel_dificultad)
formData.append('activo', formPreguntaState.activo ? 1 : 0)
if (formPreguntaState.enunciado_adicional) {
formData.append('enunciado_adicional', formPreguntaState.enunciado_adicional)
}
if (formPreguntaState.explicacion) {
formData.append('explicacion', formPreguntaState.explicacion)
}
2 months ago
// Agregar opciones como array (no JSON stringificado)
2 months ago
const opcionesValidas = formPreguntaState.opciones.filter(op => op && op.trim() !== '')
2 months ago
opcionesValidas.forEach((opcion, index) => {
formData.append(`opciones[${index}]`, opcion)
})
2 months ago
if (formPreguntaState.respuesta_correcta) {
formData.append('respuesta_correcta', formPreguntaState.respuesta_correcta)
}
// Agregar nuevas imágenes del enunciado
imagenesEnunciadoFiles.value.forEach(file => {
if (file.originFileObj) {
formData.append('imagenes[]', file.originFileObj)
}
})
// Agregar nuevas imágenes de la explicación
imagenesExplicacionFiles.value.forEach(file => {
if (file.originFileObj) {
formData.append('imagenes_explicacion[]', file.originFileObj)
}
})
if (isEditingPregunta.value) {
2 months ago
// Para edición, también enviar imágenes existentes
if (formPreguntaState.imagenes_existentes?.length) {
formData.append('imagenes_existentes', JSON.stringify(formPreguntaState.imagenes_existentes))
}
if (formPreguntaState.imagenes_explicacion_existentes?.length) {
formData.append('imagenes_explicacion_existentes', JSON.stringify(formPreguntaState.imagenes_explicacion_existentes))
}
// IMPORTANTE: Enviar curso_id también en actualización
formData.append('curso_id', formPreguntaState.curso_id)
2 months ago
await preguntaStore.actualizarPregunta(formPreguntaState.id, formData)
message.success('Pregunta actualizada correctamente')
} else {
await preguntaStore.crearPregunta(formData)
message.success('Pregunta creada correctamente')
}
modalPreguntaVisible.value = false
resetPreguntaForm()
preguntaStore.errors = null
await fetchPreguntas()
} catch (error) {
console.error('Error al guardar pregunta:', error)
2 months ago
// Mostrar errores específicos del backend si existen
if (error.response && error.response.data.errors) {
const errors = error.response.data.errors
Object.values(errors).forEach(errorList => {
errorList.forEach(err => message.error(err))
})
} else {
message.error('Error al guardar la pregunta')
}
2 months ago
}
}
const togglePreguntaStatus = async (pregunta) => {
try {
const payload = {
curso_id: curso.id,
enunciado: pregunta.enunciado,
enunciado_adicional: pregunta.enunciado_adicional,
opciones: pregunta.opciones,
respuesta_correcta: pregunta.respuesta_correcta,
explicacion: pregunta.explicacion,
nivel_dificultad: pregunta.nivel_dificultad,
activo: !pregunta.activo
}
await preguntaStore.actualizarPregunta(pregunta.id, payload)
message.success(`Pregunta ${pregunta.activo ? 'desactivada' : 'activada'} correctamente`)
await fetchPreguntas()
} catch (error) {
message.error('Error al cambiar el estado de la pregunta')
}
}
const resetPreguntaForm = () => {
formPreguntaState.id = null
formPreguntaState.curso_id = null
formPreguntaState.enunciado = ''
formPreguntaState.enunciado_adicional = ''
formPreguntaState.opciones = ['', '']
formPreguntaState.respuesta_correcta = ''
formPreguntaState.explicacion = ''
formPreguntaState.nivel_dificultad = 'medio'
formPreguntaState.activo = true
formPreguntaState.imagenes_existentes = []
formPreguntaState.imagenes_explicacion_existentes = []
imagenesEnunciadoFiles.value = []
imagenesExplicacionFiles.value = []
if (formPreguntaRef.value) {
formPreguntaRef.value.resetFields()
}
}
const addOpcion = () => {
if (formPreguntaState.opciones.length < 5) {
formPreguntaState.opciones.push('')
}
}
const removeOpcion = (index) => {
if (formPreguntaState.opciones.length > 2) {
const opcionEliminada = formPreguntaState.opciones[index]
formPreguntaState.opciones.splice(index, 1)
// Si eliminamos la opción correcta, limpiar respuesta_correcta
if (opcionEliminada === formPreguntaState.respuesta_correcta) {
formPreguntaState.respuesta_correcta = ''
}
}
}
const updateOpcion = (index, value) => {
formPreguntaState.opciones[index] = value
}
const setRespuestaCorrecta = (opcion) => {
formPreguntaState.respuesta_correcta = opcion
}
const handleSearch = () => {
pagination.current = 1
fetchPreguntas()
}
const handleFilterChange = () => {
pagination.current = 1
fetchPreguntas()
}
const clearFilters = () => {
searchText.value = ''
dificultadFilter.value = ''
estadoFilter.value = ''
pagination.current = 1
fetchPreguntas()
}
const handleTableChange = (paginationConfig) => {
pagination.current = paginationConfig.current
pagination.pageSize = paginationConfig.pageSize
fetchPreguntas()
}
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const formatDificultad = (dificultad) => {
const map = {
'facil': 'Fácil',
'medio': 'Media',
'dificil': 'Difícil'
}
return map[dificultad] || dificultad
}
const getDificultadColor = (dificultad) => {
const map = {
'facil': 'green',
'medio': 'orange',
'dificil': 'red'
}
return map[dificultad] || 'default'
}
const getEnunciadoPreview = (enunciado) => {
if (!enunciado) return ''
// Remover HTML tags y limitar a 100 caracteres
const text = enunciado.replace(/<[^>]*>/g, '')
return text.length > 100 ? text.substring(0, 100) + '...' : text
}
const getFieldStatus = (fieldName) => {
if (preguntaStore.errors && preguntaStore.errors[fieldName]) {
return 'error'
}
return ''
}
const getFieldHelp = (fieldName) => {
if (preguntaStore.errors && preguntaStore.errors[fieldName]) {
return preguntaStore.errors[fieldName][0]
}
return ''
}
// Lifecycle
onMounted(async () => {
await loadCursoInfo()
await fetchPreguntas()
})
</script>
<style scoped>
.preguntas-curso-view {
padding: 0;
}
.view-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.back-btn {
padding: 0;
height: auto;
}
.header-title h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f1f1f;
}
.curso-info {
display: flex;
align-items: center;
gap: 12px;
margin-top: 4px;
}
.curso-codigo {
font-size: 14px;
color: #666;
background: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
}
.curso-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon-wrapper {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #1f1f1f;
line-height: 1.3;
}
.stat-label {
font-size: 14px;
color: #666;
margin-top: 2px;
}
.filters-section {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
flex-wrap: wrap;
gap: 16px;
}
.filters {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.filter-actions {
display: flex;
align-items: center;
}
.preguntas-table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
overflow: hidden;
}
.preguntas-table {
border-radius: 12px;
}
.preguntas-table :deep(.ant-table) {
border-radius: 12px;
}
.preguntas-table :deep(.ant-table-thead > tr > th) {
background: #fafafa;
font-weight: 600;
color: #1f1f1f;
border-bottom: 1px solid #f0f0f0;
}
.preguntas-table :deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid #f0f0f0;
}
.preguntas-table :deep(.ant-table-tbody > tr:hover > td) {
background: #fafafa;
}
.enunciado-preview {
max-height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
}
.action-btn {
padding: 4px 8px;
height: auto;
}
.action-btn :deep(.anticon) {
font-size: 14px;
}
.pregunta-modal :deep(.ant-modal-body) {
padding: 24px;
}
/* Estilos adicionales */
.section-card {
margin-bottom: 16px;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-subtitle {
font-size: 12px;
color: #999;
font-weight: normal;
}
.upload-section {
margin-top: 8px;
}
.existing-images {
margin-top: 16px;
padding: 12px;
background: #fafafa;
border-radius: 8px;
border: 1px dashed #d9d9d9;
}
.existing-images h4 {
margin: 0 0 12px 0;
color: #666;
font-size: 14px;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
}
.image-item {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e8e8e8;
}
.image-item img {
width: 100%;
height: 100px;
object-fit: cover;
}
.remove-image-btn {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.5);
color: white;
border: none;
}
.remove-image-btn:hover {
background: rgba(255, 0, 0, 0.7);
}
.opcion-item {
padding: 12px;
margin-bottom: 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
}
.opcion-item.correct-option {
border-color: #52c41a;
background: #f6ffed;
}
.opcion-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.opcion-label {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.opcion-letter {
font-weight: bold;
color: #1890ff;
min-width: 24px;
}
.opcion-input {
flex: 1;
}
.opcion-actions {
display: flex;
align-items: center;
gap: 12px;
}
.config-row {
display: flex;
gap: 24px;
}
.config-item {
flex: 1;
}
.dificultad-option {
display: flex;
align-items: center;
gap: 8px;
}
.form-footer {
margin-top: 16px;
}
.error-alert {
margin-bottom: 8px;
}
.error-alert:last-child {
margin-bottom: 0;
}
/* Vista de Pregunta */
.pregunta-view {
max-height: 600px;
overflow-y: auto;
padding-right: 8px;
}
.pregunta-view::-webkit-scrollbar {
width: 6px;
}
.pregunta-view::-webkit-scrollbar-track {
background: #f1f1f1;
}
.pregunta-view::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
.enunciado-completo {
font-size: 16px;
line-height: 1.6;
margin-bottom: 20px;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.imagenes-preview {
margin: 16px 0;
}
.imagenes-preview h4 {
margin: 16px 0 8px 0;
color: #666;
}
.imagenes-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.clickable-image {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
border: 1px solid #f0f0f0;
cursor: pointer;
transition: transform 0.2s;
}
.clickable-image:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.enunciado-adicional {
margin-bottom: 20px;
padding: 16px;
background: #f0f0f0;
border-radius: 8px;
}
.enunciado-adicional h4 {
margin-top: 0;
color: #666;
}
.opciones-view {
margin-bottom: 20px;
}
.opcion-view {
display: flex;
align-items: center;
padding: 12px 16px;
margin-bottom: 8px;
background: #fafafa;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.opcion-view.correcta {
background: #f6ffed;
border-color: #b7eb8f;
}
.opcion-letter {
font-weight: bold;
margin-right: 12px;
min-width: 24px;
}
.opcion-text {
flex: 1;
}
.explicacion-view {
padding: 16px;
background: #e6f7ff;
border-radius: 8px;
margin-bottom: 20px;
}
.explicacion-view h4 {
margin-top: 0;
color: #1890ff;
}
.info-adicional {
margin-top: 20px;
}
.info-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
}
.delete-confirm-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.pregunta-info {
background: #fafafa;
padding: 12px;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
/* Ajustes para el upload de imágenes */
:deep(.ant-upload-select-picture-card) {
width: 100px;
height: 100px;
margin-right: 8px;
margin-bottom: 8px;
}
:deep(.ant-upload-list-picture-card-container) {
width: 100px;
height: 100px;
}
/* Responsive */
@media (max-width: 768px) {
.view-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.header-right {
align-self: flex-start;
}
.filters-section {
flex-direction: column;
align-items: stretch;
}
.filters {
flex-direction: column;
width: 100%;
}
.filters .ant-input-search,
.filters .ant-select {
width: 100%;
margin-left: 0;
}
.curso-stats {
grid-template-columns: 1fr;
}
.config-row {
flex-direction: column;
}
.opcion-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.opcion-actions {
align-self: flex-end;
}
.imagenes-grid {
justify-content: center;
}
.clickable-image {
max-width: 150px;
max-height: 150px;
}
}
</style>