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.

1885 lines
52 KiB
Vue

2 months ago
<!-- components/AdminAcademia/Cursos/PreguntasCursoView.vue -->
<template>
<div class="preguntas-curso-view">
2 months ago
<!-- Header con navegación (sticky) -->
2 months ago
<div class="view-header">
<div class="header-left">
<a-button type="link" @click="goBack" class="back-btn">
<ArrowLeftOutlined /> Volver a Cursos
</a-button>
2 months ago
2 months ago
<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>
2 months ago
2 months ago
<div class="header-right">
2 months ago
<a-button type="primary" @click="showCreateModal" class="primary-cta">
2 months ago
<template #icon><PlusOutlined /></template>
Nueva Pregunta
</a-button>
</div>
</div>
2 months ago
<!-- Estadísticas -->
2 months ago
<div class="curso-stats">
2 months ago
<a-card class="stat-card" :bordered="false">
2 months ago
<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>
2 months ago
<a-card class="stat-card" :bordered="false">
2 months ago
<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>
2 months ago
<a-card class="stat-card" :bordered="false">
2 months ago
<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>
2 months ago
<a-card class="stat-card" :bordered="false">
2 months ago
<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>
2 months ago
<!-- Filtros (en card) -->
<a-card class="filters-card" :bordered="false">
<div class="filters-section">
<div class="filters">
<a-input-search
v-model:value="searchText"
placeholder="Buscar en preguntas..."
@search="handleSearch"
style="width: 320px"
size="large"
allow-clear
/>
<a-select
v-model:value="dificultadFilter"
placeholder="Todas las dificultades"
style="width: 220px"
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: 220px"
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>
2 months ago
</div>
2 months ago
</a-card>
2 months ago
2 months ago
<!-- Tabla -->
2 months ago
<div class="preguntas-table-container">
2 months ago
<a-table
2 months ago
:data-source="preguntas"
:columns="columns"
:loading="preguntaStore.loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="preguntas-table"
2 months ago
size="middle"
:scroll="{ x: 980 }"
2 months ago
>
<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>
2 months ago
<!-- Vista previa del Enunciado -->
2 months ago
<template v-if="column.key === 'enunciado'">
2 months ago
<a-tooltip :title="stripHtml(record.enunciado)">
<div class="enunciado-preview" v-html="getEnunciadoPreview(record.enunciado)" />
</a-tooltip>
2 months ago
</template>
2 months ago
<!-- <template v-if="column.key === 'enunciado'">
<div class="enunciado-md-preview">
<MarkdownLatex :content="record.enunciado" />
</div>
</template> -->
2 months ago
<!-- Fecha Creación -->
<template v-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<!-- Acciones -->
<template v-if="column.key === 'acciones'">
2 months ago
<div class="actions">
<a-tooltip title="Ver">
<a-button type="text" size="small" @click="verPregunta(record)" class="icon-action">
<EyeOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="Editar">
<a-button type="text" size="small" @click="showEditModal(record)" class="icon-action">
<EditOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="Eliminar">
<a-button
type="text"
size="small"
danger
@click="confirmDeletePregunta(record)"
class="icon-action"
>
<DeleteOutlined />
</a-button>
</a-tooltip>
</div>
2 months ago
</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"
2 months ago
width="1200px"
2 months ago
class="pregunta-modal"
2 months ago
destroy-on-close
2 months ago
>
2 months ago
<a-form ref="formPreguntaRef" :model="formPreguntaState" :rules="formPreguntaRules" layout="vertical">
<!-- Enunciado -->
<a-card size="small" class="section-card" :bordered="false">
2 months ago
<template #title>
<div class="section-title">
<span>Enunciado de la Pregunta</span>
</div>
</template>
2 months ago
<a-form-item
label="Texto del Enunciado *"
2 months ago
name="enunciado"
:validate-status="getFieldStatus('enunciado')"
:help="getFieldHelp('enunciado')"
>
2 months ago
<div class="editor-container">
<div class="editor-column">
<div class="editor-header">
<span>Editor</span>
<a-tag color="blue">Markdown & LaTeX</a-tag>
</div>
2 months ago
2 months ago
<a-textarea
v-model:value="formPreguntaState.enunciado"
placeholder="Escribe el enunciado principal de la pregunta..."
:rows="6"
:maxlength="2000"
show-count
@input="handleEnunciadoInput"
class="markdown-editor"
/>
2 months ago
2 months ago
<div class="editor-tips">
2 months ago
<small><strong>Tips:</strong> **negrita**, *cursiva*, $$fórmulas$$, `código`</small>
2 months ago
</div>
</div>
2 months ago
2 months ago
<div class="preview-column">
<div class="editor-header">
<span>Vista Previa</span>
<a-tag color="green">Actualización en tiempo real</a-tag>
</div>
2 months ago
2 months ago
<div class="preview-content">
<div v-if="formPreguntaState.enunciado" class="markdown-preview">
<MarkdownLatex :content="formPreguntaState.enunciado" />
</div>
<div v-else class="empty-preview">
<EyeOutlined style="font-size: 24px; color: #ccc;" />
<p>El contenido aparecerá aquí mientras escribes...</p>
</div>
</div>
</div>
</div>
2 months ago
</a-form-item>
2 months ago
<!-- Imágenes del enunciado -->
<a-form-item label="Imágenes del Enunciado (Opcional)">
2 months ago
<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>
2 months ago
<div
v-if="isEditingPregunta && formPreguntaState.imagenes_existentes?.length"
class="existing-images"
>
2 months ago
<h4>Imágenes existentes:</h4>
<div class="image-grid">
2 months ago
<div
v-for="(imagen, index) in formPreguntaState.imagenes_existentes"
:key="'existente-'+index"
class="image-item"
>
2 months ago
<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>
2 months ago
<!-- Enunciado adicional -->
<a-form-item label="Enunciado Adicional (Opcional)">
<div class="editor-container editor-small">
2 months ago
<div class="editor-column">
<div class="editor-header">
<span>Editor</span>
<a-tag color="blue">Markdown & LaTeX</a-tag>
</div>
2 months ago
2 months ago
<a-textarea
v-model:value="formPreguntaState.enunciado_adicional"
placeholder="Información adicional, contexto o detalles importantes..."
:rows="4"
@input="handleAdicionalInput"
class="markdown-editor"
/>
</div>
2 months ago
2 months ago
<div class="preview-column">
<div class="editor-header">
<span>Vista Previa</span>
</div>
2 months ago
2 months ago
<div class="preview-content">
<div v-if="formPreguntaState.enunciado_adicional" class="markdown-preview">
<MarkdownLatex :content="formPreguntaState.enunciado_adicional" />
</div>
<div v-else class="empty-preview">
<EyeOutlined style="font-size: 24px; color: #ccc;" />
<p>Contenido adicional aparecerá aquí...</p>
</div>
</div>
</div>
</div>
2 months ago
</a-form-item>
</a-card>
2 months ago
<!-- Opciones (CON LaTeX/Markdown + preview) -->
<a-card size="small" class="section-card" :bordered="false">
2 months ago
<template #title>
<div class="section-title">
<span>Opciones de Respuesta</span>
2 months ago
<span class="section-subtitle">(Mínimo 2, marca la correcta)</span>
2 months ago
</div>
</template>
2 months ago
<div class="opciones-grid">
<div
v-for="(opcion, index) in formPreguntaState.opciones"
:key="index"
class="opcion-card"
:class="{ 'is-correct': formPreguntaState.respuesta_correcta === opcion }"
>
<div class="opcion-top">
<div class="opcion-badge">
2 months ago
<span class="opcion-letter">{{ String.fromCharCode(65 + index) }}</span>
2 months ago
<a-tag color="blue" class="mini-tag">Markdown & LaTeX</a-tag>
2 months ago
</div>
2 months ago
<div class="opcion-controls">
2 months ago
<a-radio
:checked="formPreguntaState.respuesta_correcta === opcion"
@change="() => setRespuestaCorrecta(opcion)"
>
Correcta
</a-radio>
2 months ago
2 months ago
<a-button
v-if="formPreguntaState.opciones.length > 2"
2 months ago
type="text"
2 months ago
danger
@click="removeOpcion(index)"
2 months ago
class="icon-btn"
2 months ago
>
2 months ago
<DeleteOutlined />
2 months ago
</a-button>
</div>
</div>
2 months ago
<div class="opcion-editor-wrap">
<div class="opcion-editor">
<a-textarea
v-model:value="formPreguntaState.opciones[index]"
placeholder="Escribe la opción... (puedes usar $$latex$$, **negrita**, listas, etc.)"
:rows="4"
class="markdown-editor"
@change="updateOpcion(index, $event.target.value)"
/>
<div class="editor-tips compact">
<small><strong>Ej:</strong> $$\\int_0^1 x^2 dx$$ · **texto** · `código`</small>
</div>
</div>
<div class="opcion-preview">
<div class="preview-header">
<span>Vista previa</span>
<a-tag color="green" class="mini-tag">Live</a-tag>
</div>
<div class="preview-content option-preview-content">
<div v-if="formPreguntaState.opciones[index]" class="markdown-preview">
<MarkdownLatex :content="formPreguntaState.opciones[index]" />
</div>
<div v-else class="empty-preview">
<EyeOutlined style="font-size: 20px; color: #ccc;" />
<p>La opción se verá aquí...</p>
</div>
</div>
</div>
</div>
2 months ago
</div>
</div>
<a-button
type="dashed"
@click="addOpcion"
block
:disabled="formPreguntaState.opciones.length >= 5"
2 months ago
class="add-opcion-btn"
2 months ago
>
<PlusOutlined /> Agregar Opción ({{ formPreguntaState.opciones.length }}/5)
</a-button>
</a-card>
2 months ago
<!-- Explicación -->
<a-card size="small" class="section-card" :bordered="false">
2 months ago
<template #title>
<div class="section-title">
<span>Explicación y Retroalimentación</span>
</div>
</template>
2 months ago
<a-form-item label="Explicación de la Respuesta Correcta (Opcional)">
2 months ago
<div class="editor-container">
<div class="editor-column">
<div class="editor-header">
<span>Editor</span>
<a-tag color="blue">Markdown & LaTeX</a-tag>
</div>
2 months ago
2 months ago
<a-textarea
v-model:value="formPreguntaState.explicacion"
placeholder="Explica por qué esta opción es la correcta..."
:rows="6"
@input="handleExplicacionInput"
class="markdown-editor"
/>
2 months ago
2 months ago
<div class="editor-tips">
2 months ago
<small><strong>Tips:</strong> Usa $$fórmulas$$, **importante**, listas con *</small>
2 months ago
</div>
</div>
2 months ago
2 months ago
<div class="preview-column">
<div class="editor-header">
<span>Vista Previa</span>
</div>
2 months ago
2 months ago
<div class="preview-content">
<div v-if="formPreguntaState.explicacion" class="markdown-preview">
<MarkdownLatex :content="formPreguntaState.explicacion" />
</div>
<div v-else class="empty-preview">
<EyeOutlined style="font-size: 24px; color: #ccc;" />
<p>La explicación aparecerá aquí mientras escribes...</p>
</div>
</div>
</div>
</div>
2 months ago
</a-form-item>
2 months ago
<!-- Imágenes de explicación -->
<a-form-item label="Imágenes de la Explicación (Opcional)">
2 months ago
<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>
2 months ago
<div
v-if="isEditingPregunta && formPreguntaState.imagenes_explicacion_existentes?.length"
class="existing-images"
>
2 months ago
<h4>Imágenes existentes:</h4>
<div class="image-grid">
2 months ago
<div
v-for="(imagen, index) in formPreguntaState.imagenes_explicacion_existentes"
:key="'exp-existente-'+index"
class="image-item"
>
2 months ago
<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 -->
2 months ago
<a-card size="small" class="section-card" :bordered="false">
2 months ago
<template #title>
<div class="section-title">
<span>Configuración</span>
</div>
</template>
2 months ago
2 months ago
<div class="config-row">
2 months ago
<a-form-item
label="Nivel de Dificultad *"
2 months ago
name="nivel_dificultad"
:validate-status="getFieldStatus('nivel_dificultad')"
:help="getFieldHelp('nivel_dificultad')"
class="config-item"
>
2 months ago
<a-select v-model:value="formPreguntaState.nivel_dificultad" placeholder="Selecciona la dificultad">
2 months ago
<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>
2 months ago
<a-form-item label="Estado" class="config-item">
2 months ago
<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>
2 months ago
<!-- Modal Vista de Pregunta -->
2 months ago
<a-modal
v-model:open="verPreguntaModalVisible"
:title="'Vista de Pregunta'"
width="800px"
:footer="null"
@cancel="verPreguntaModalVisible = false"
2 months ago
destroy-on-close
2 months ago
>
<div class="pregunta-view" v-if="preguntaSeleccionada">
2 months ago
<div class="enunciado-principal">
<MarkdownLatex :content="preguntaSeleccionada.enunciado" />
2 months ago
2 months ago
2 months ago
<div v-if="preguntaSeleccionada.imagenes && preguntaSeleccionada.imagenes.length" class="imagenes-enunciado">
2 months ago
<div class="imagenes-grid">
2 months ago
<img
v-for="(imagen, index) in preguntaSeleccionada.imagenes"
2 months ago
:key="index"
2 months ago
:src="imagen"
2 months ago
:alt="'Imagen ' + (index+1)"
@click="verImagen(imagen)"
class="clickable-image centered-image"
/>
2 months ago
</div>
</div>
2 months ago
2 months ago
<div v-if="preguntaSeleccionada.enunciado_adicional" >
2 months ago
<MarkdownLatex :content="preguntaSeleccionada.enunciado_adicional" />
2 months ago
</div>
2 months ago
</div>
2 months ago
<div class="opciones-view">
<h4>Opciones de Respuesta:</h4>
2 months ago
<div
v-for="(opcion, index) in (preguntaSeleccionada.opciones || [])"
2 months ago
:key="index"
class="opcion-view"
:class="{ correcta: opcion === preguntaSeleccionada.respuesta_correcta }"
>
<div class="opcion-letter">{{ String.fromCharCode(65 + index) }}.</div>
2 months ago
<div class="opcion-text">
<MarkdownLatex :content="opcion" />
</div>
2 months ago
<div class="opcion-correct" v-if="opcion === preguntaSeleccionada.respuesta_correcta">
<CheckCircleOutlined style="color: #52c41a; margin-left: 8px;" />
</div>
</div>
</div>
<div v-if="preguntaSeleccionada.explicacion" class="explicacion-view">
<h4>Explicación:</h4>
2 months ago
<MarkdownLatex :content="preguntaSeleccionada.explicacion" />
2 months ago
</div>
2 months ago
<div
v-if="preguntaSeleccionada.imagenes_explicacion && preguntaSeleccionada.imagenes_explicacion.length"
class="imagenes-explicacion"
>
2 months ago
<div class="imagenes-grid">
2 months ago
<img
v-for="(imagen, index) in preguntaSeleccionada.imagenes_explicacion"
2 months ago
:key="index"
:src="getImageUrl(imagen)"
:alt="'Imagen ' + (index+1)"
@click="verImagen(getImageUrl(imagen))"
2 months ago
class="clickable-image centered-image"
2 months ago
/>
</div>
</div>
<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>
2 months ago
<!-- Modal Confirmación Eliminar -->
2 months ago
<a-modal
v-model:open="deletePreguntaModalVisible"
title="Eliminar Pregunta"
@ok="handleDeletePregunta"
@cancel="deletePreguntaModalVisible = false"
ok-text="Eliminar"
cancel-text="Cancelar"
ok-type="danger"
2 months ago
destroy-on-close
2 months ago
>
<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>
2 months ago
<!-- Preview Upload -->
2 months ago
<a-modal :open="previewVisible" :title="previewTitle" :footer="null" @cancel="previewVisible = false">
<img alt="Vista previa" style="width: 100%" :src="previewImage" />
</a-modal>
2 months ago
<!-- Imagen grande -->
2 months ago
<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'
2 months ago
import MarkdownLatex from '../cursos/MarkdownLatex.vue'
2 months ago
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`
})
2 months ago
// Formulario
2 months ago
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: []
})
2 months ago
// Validación manual
2 months ago
const validarFormulario = () => {
const errors = []
const opcionesValidas = formPreguntaState.opciones.filter(op => op && op.trim() !== '')
2 months ago
if (opcionesValidas.length < 2) errors.push('Debe completar al menos 2 opciones')
2 months ago
const opcionesSet = new Set(opcionesValidas.map(op => op.trim().toLowerCase()))
2 months ago
if (opcionesSet.size !== opcionesValidas.length) errors.push('No puede haber opciones duplicadas')
2 months ago
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')
}
2 months ago
2 months ago
return errors
}
2 months ago
// Reglas (solo básicos)
2 months ago
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' }
],
2 months ago
nivel_dificultad: [{ required: true, message: 'La dificultad es requerida', trigger: 'change' }]
2 months ago
}
2 months ago
// Columnas
2 months ago
const columns = [
2 months ago
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: 'Enunciado', dataIndex: 'enunciado', key: 'enunciado', ellipsis: true, minWidth: 360 },
{ title: 'Dificultad', dataIndex: 'nivel_dificultad', key: 'nivel_dificultad', width: 130 },
{ title: 'Estado', dataIndex: 'activo', key: 'activo', width: 110 },
{ title: 'Creado', dataIndex: 'created_at', key: 'created_at', width: 150 },
{ title: 'Acciones', key: 'acciones', width: 160, align: 'center', fixed: 'right' }
2 months ago
]
// Computed
const preguntas = computed(() => preguntaStore.preguntas)
2 months ago
// Utils
const stripHtml = (html) => (html ? String(html).replace(/<[^>]*>/g, '') : '')
// Imágenes
2 months ago
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
}
2 months ago
return false
2 months ago
}
const handleRemoveImagenEnunciado = (file) => {
const index = imagenesEnunciadoFiles.value.findIndex(f => f.uid === file.uid)
2 months ago
if (index !== -1) imagenesEnunciadoFiles.value.splice(index, 1)
2 months ago
}
const handleRemoveImagenExplicacion = (file) => {
const index = imagenesExplicacionFiles.value.findIndex(f => f.uid === file.uid)
2 months ago
if (index !== -1) imagenesExplicacionFiles.value.splice(index, 1)
2 months ago
}
const handlePreview = async (file) => {
2 months ago
if (!file.url && !file.preview) file.preview = await getBase64(file.originFileObj)
2 months ago
previewImage.value = file.url || file.preview
previewVisible.value = true
previewTitle.value = file.name || file.url?.substring(file.url?.lastIndexOf('/') + 1) || 'Imagen'
}
2 months ago
const getBase64 = (file) =>
new Promise((resolve, reject) => {
2 months ago
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') {
2 months ago
formPreguntaState.imagenes_existentes = formPreguntaState.imagenes_existentes.filter((_, i) => i !== index)
2 months ago
message.success('Imagen eliminada (se aplicará al guardar)')
} else if (tipo === 'explicacion') {
2 months ago
formPreguntaState.imagenes_explicacion_existentes =
formPreguntaState.imagenes_explicacion_existentes.filter((_, i) => i !== index)
2 months ago
message.success('Imagen eliminada (se aplicará al guardar)')
}
}
const verImagen = (url) => {
imagenGrande.value = url
modalImagenVisible.value = true
}
2 months ago
// Handlers live preview
const handleEnunciadoInput = () => {}
const handleAdicionalInput = () => {}
const handleExplicacionInput = () => {}
2 months ago
2 months ago
// Navegación
2 months ago
const goBack = () => {
router.push({ name: 'AcademiaCursos' })
}
const fetchPreguntas = async () => {
2 months ago
const params = { page: pagination.current, per_page: pagination.pageSize }
2 months ago
if (searchText.value) params.search = searchText.value
if (dificultadFilter.value) params.nivel_dificultad = dificultadFilter.value
if (estadoFilter.value !== '') params.activo = estadoFilter.value === 'true'
2 months ago
2 months ago
const data = await preguntaStore.fetchPreguntasByCurso(curso.id, params)
if (data) {
estadisticas.total = data.estadisticas?.total || 0
estadisticas.facil = data.estadisticas?.facil || 0
estadisticas.medio = data.estadisticas?.medio || 0
estadisticas.dificil = data.estadisticas?.dificil || 0
2 months ago
2 months ago
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
2 months ago
2 months ago
const cursoEncontrado = cursoStore.cursos.find(c => c.id === cursoId)
2 months ago
if (cursoEncontrado) Object.assign(curso, cursoEncontrado)
else curso.nombre = `Curso ${cursoId}`
2 months ago
}
2 months ago
// Modal create
2 months ago
const showCreateModal = () => {
isEditingPregunta.value = false
resetPreguntaForm()
formPreguntaState.curso_id = curso.id
modalPreguntaVisible.value = true
preguntaStore.errors = null
}
2 months ago
// Modal edit
2 months ago
const showEditModal = (pregunta) => {
isEditingPregunta.value = true
resetPreguntaForm()
2 months ago
2 months ago
formPreguntaState.id = pregunta.id
formPreguntaState.curso_id = curso.id
formPreguntaState.enunciado = pregunta.enunciado
formPreguntaState.enunciado_adicional = pregunta.enunciado_adicional || ''
2 months ago
// Garantizar mínimo 2 opciones
const ops = (pregunta.opciones && Array.isArray(pregunta.opciones)) ? pregunta.opciones : ['', '']
formPreguntaState.opciones = ops.length >= 2 ? ops : [...ops, '']
2 months ago
formPreguntaState.respuesta_correcta = pregunta.respuesta_correcta || ''
formPreguntaState.explicacion = pregunta.explicacion || ''
formPreguntaState.nivel_dificultad = pregunta.nivel_dificultad
formPreguntaState.activo = pregunta.activo
2 months ago
2 months ago
formPreguntaState.imagenes_existentes = pregunta.imagenes || []
formPreguntaState.imagenes_explicacion_existentes = pregunta.imagenes_explicacion || []
2 months ago
2 months ago
imagenesEnunciadoFiles.value = []
imagenesExplicacionFiles.value = []
2 months ago
2 months ago
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 {
await formPreguntaRef.value.validateFields()
2 months ago
2 months ago
const erroresValidacion = validarFormulario()
if (erroresValidacion.length > 0) {
2 months ago
erroresValidacion.forEach(err => message.error(err))
2 months ago
return
}
2 months ago
2 months ago
await submitPreguntaForm()
} catch (error) {
console.log('Validación fallida:', error)
}
}
const handleModalPreguntaCancel = () => {
modalPreguntaVisible.value = false
resetPreguntaForm()
preguntaStore.errors = null
}
const submitPreguntaForm = async () => {
try {
const erroresValidacion = validarFormulario()
if (erroresValidacion.length > 0) {
2 months ago
erroresValidacion.forEach(err => message.error(err))
2 months ago
return
}
const formData = new FormData()
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)
2 months ago
2 months ago
if (formPreguntaState.enunciado_adicional) formData.append('enunciado_adicional', formPreguntaState.enunciado_adicional)
if (formPreguntaState.explicacion) formData.append('explicacion', formPreguntaState.explicacion)
2 months ago
2 months ago
const opcionesValidas = formPreguntaState.opciones.filter(op => op && op.trim() !== '')
2 months ago
formData.append('opciones', JSON.stringify(opcionesValidas))
formData.append('respuesta_correcta', formPreguntaState.respuesta_correcta)
2 months ago
imagenesEnunciadoFiles.value.forEach(file => file.originFileObj && formData.append('imagenes[]', file.originFileObj))
imagenesExplicacionFiles.value.forEach(file => file.originFileObj && formData.append('imagenes_explicacion[]', file.originFileObj))
2 months ago
2 months ago
if (isEditingPregunta.value) {
2 months ago
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))
}
2 months ago
2 months ago
await preguntaStore.actualizarPregunta(formPreguntaState.id, formData)
message.success('Pregunta actualizada correctamente')
} else {
await preguntaStore.crearPregunta(formData)
message.success('Pregunta creada correctamente')
}
2 months ago
2 months ago
modalPreguntaVisible.value = false
resetPreguntaForm()
preguntaStore.errors = null
await fetchPreguntas()
} catch (error) {
console.error('Error al guardar pregunta:', error)
2 months ago
if (error.response && error.response.data.errors) {
2 months ago
Object.values(error.response.data.errors).forEach(list => list.forEach(err => message.error(err)))
2 months ago
} 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
}
2 months ago
2 months ago
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 = []
2 months ago
2 months ago
imagenesEnunciadoFiles.value = []
imagenesExplicacionFiles.value = []
2 months ago
if (formPreguntaRef.value) formPreguntaRef.value.resetFields()
2 months ago
}
const addOpcion = () => {
2 months ago
if (formPreguntaState.opciones.length < 5) formPreguntaState.opciones.push('')
2 months ago
}
const removeOpcion = (index) => {
2 months ago
if (formPreguntaState.opciones.length <= 2) return
const opcionEliminada = formPreguntaState.opciones[index]
formPreguntaState.opciones.splice(index, 1)
if (opcionEliminada === formPreguntaState.respuesta_correcta) {
formPreguntaState.respuesta_correcta = ''
2 months ago
}
}
2 months ago
/**
* Importante: si el usuario edita el texto de la opción marcada como correcta,
* actualizamos también respuesta_correcta para que no se desincronice.
*/
2 months ago
const updateOpcion = (index, value) => {
2 months ago
const oldValue = formPreguntaState.opciones[index]
2 months ago
formPreguntaState.opciones[index] = value
2 months ago
if (formPreguntaState.respuesta_correcta === oldValue) {
formPreguntaState.respuesta_correcta = value
}
2 months ago
}
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)
2 months ago
return date.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: 'numeric' })
2 months ago
}
const formatDificultad = (dificultad) => {
2 months ago
const map = { facil: 'Fácil', medio: 'Media', dificil: 'Difícil' }
2 months ago
return map[dificultad] || dificultad
}
const getDificultadColor = (dificultad) => {
2 months ago
const map = { facil: 'green', medio: 'orange', dificil: 'red' }
2 months ago
return map[dificultad] || 'default'
}
const getEnunciadoPreview = (enunciado) => {
if (!enunciado) return ''
2 months ago
const text = stripHtml(enunciado)
return text.length > 140 ? text.substring(0, 140) + '...' : text
2 months ago
}
const getFieldStatus = (fieldName) => {
2 months ago
if (preguntaStore.errors && preguntaStore.errors[fieldName]) return 'error'
2 months ago
return ''
}
const getFieldHelp = (fieldName) => {
2 months ago
if (preguntaStore.errors && preguntaStore.errors[fieldName]) return preguntaStore.errors[fieldName][0]
2 months ago
return ''
}
// Lifecycle
onMounted(async () => {
await loadCursoInfo()
await fetchPreguntas()
})
</script>
<style scoped>
2 months ago
/* Layout general */
2 months ago
.preguntas-curso-view {
padding: 0;
}
2 months ago
/* Header sticky */
2 months ago
.view-header {
2 months ago
position: sticky;
top: 0;
z-index: 10;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
2 months ago
display: flex;
justify-content: space-between;
align-items: center;
2 months ago
padding: 12px 8px;
margin-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
2 months ago
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
2 months ago
min-width: 0;
2 months ago
}
.back-btn {
padding: 0;
height: auto;
}
2 months ago
.header-title {
min-width: 0;
}
2 months ago
.header-title h2 {
margin: 0;
2 months ago
font-size: 22px;
font-weight: 650;
2 months ago
color: #1f1f1f;
2 months ago
line-height: 1.2;
2 months ago
}
.curso-info {
display: flex;
align-items: center;
2 months ago
gap: 10px;
margin-top: 6px;
2 months ago
}
.curso-codigo {
2 months ago
font-size: 13px;
2 months ago
color: #666;
background: #f5f5f5;
2 months ago
padding: 2px 10px;
border-radius: 999px;
border: 1px solid #ededed;
2 months ago
}
2 months ago
.primary-cta {
border-radius: 10px;
box-shadow: 0 8px 18px rgba(24,144,255,0.18);
}
/* Stats */
2 months ago
.curso-stats {
display: grid;
2 months ago
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 14px;
2 months ago
}
.stat-card {
2 months ago
border-radius: 14px;
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.06);
2 months ago
border: 1px solid #f0f0f0;
}
.stat-content {
display: flex;
align-items: center;
2 months ago
gap: 14px;
2 months ago
}
.stat-icon-wrapper {
2 months ago
width: 44px;
height: 44px;
border-radius: 14px;
2 months ago
display: flex;
align-items: center;
justify-content: center;
2 months ago
font-size: 18px;
2 months ago
}
.stat-value {
2 months ago
font-size: 22px;
font-weight: 700;
2 months ago
color: #1f1f1f;
2 months ago
line-height: 1.2;
2 months ago
}
.stat-label {
2 months ago
font-size: 13px;
2 months ago
color: #666;
margin-top: 2px;
}
2 months ago
/* Filtros */
.filters-card {
border-radius: 14px;
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
margin-bottom: 14px;
}
2 months ago
.filters-section {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
2 months ago
gap: 14px;
2 months ago
}
.filters {
display: flex;
align-items: center;
flex-wrap: wrap;
2 months ago
gap: 12px;
2 months ago
}
.filter-actions {
display: flex;
align-items: center;
}
2 months ago
/* Tabla */
2 months ago
.preguntas-table-container {
background: white;
2 months ago
border-radius: 14px;
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.06);
2 months ago
border: 1px solid #f0f0f0;
overflow: hidden;
}
.preguntas-table :deep(.ant-table) {
2 months ago
border-radius: 14px;
2 months ago
}
.preguntas-table :deep(.ant-table-thead > tr > th) {
background: #fafafa;
2 months ago
font-weight: 650;
2 months ago
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) {
2 months ago
background: #fcfcfc;
2 months ago
}
.enunciado-preview {
display: -webkit-box;
2 months ago
2 months ago
-webkit-box-orient: vertical;
2 months ago
overflow: hidden;
line-height: 1.35;
color: #333;
2 months ago
}
2 months ago
.actions {
display: inline-flex;
gap: 6px;
justify-content: center;
2 months ago
}
2 months ago
.icon-action {
border-radius: 10px;
}
.icon-action :deep(.anticon) {
font-size: 16px;
2 months ago
}
2 months ago
/* Modal */
2 months ago
.pregunta-modal :deep(.ant-modal-body) {
2 months ago
padding: 18px;
2 months ago
}
2 months ago
/* Editor + preview (reutilizable) */
2 months ago
.editor-container {
display: flex;
2 months ago
gap: 14px;
margin-bottom: 10px;
height: 320px;
}
.editor-container.editor-small {
height: 260px;
2 months ago
}
.editor-column,
.preview-column {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
2 months ago
min-width: 0;
2 months ago
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.editor-header span {
2 months ago
font-weight: 600;
2 months ago
color: #333;
}
.markdown-editor {
flex: 1;
resize: none;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
padding: 12px;
border: 1px solid #d9d9d9;
2 months ago
border-radius: 10px;
2 months ago
background-color: #fafafa;
}
.markdown-editor:focus {
border-color: #1890ff;
background-color: #fff;
}
.editor-tips {
margin-top: 8px;
2 months ago
padding: 10px;
2 months ago
background-color: #f6ffed;
2 months ago
border-radius: 10px;
2 months ago
border: 1px solid #b7eb8f;
font-size: 12px;
color: #666;
}
2 months ago
.editor-tips.compact {
padding: 8px;
border-radius: 10px;
}
2 months ago
.preview-content {
flex: 1;
overflow-y: auto;
padding: 12px;
border: 1px solid #d9d9d9;
2 months ago
border-radius: 10px;
2 months ago
background-color: #fff;
min-height: 200px;
}
.empty-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
text-align: center;
}
.empty-preview p {
margin-top: 8px;
font-size: 14px;
}
.markdown-preview {
font-size: 14px;
2 months ago
line-height: 1.65;
2 months ago
}
2 months ago
/* Cards de secciones */
2 months ago
.section-card {
2 months ago
margin-bottom: 14px;
border-radius: 14px;
2 months ago
border: 1px solid #f0f0f0;
2 months ago
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.04);
2 months ago
}
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.section-subtitle {
font-size: 12px;
color: #999;
font-weight: normal;
}
2 months ago
/* Upload */
2 months ago
.upload-section {
margin-top: 8px;
}
.existing-images {
2 months ago
margin-top: 14px;
2 months ago
padding: 12px;
background: #fafafa;
2 months ago
border-radius: 12px;
2 months ago
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;
2 months ago
border-radius: 12px;
2 months ago
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;
2 months ago
background: rgba(0, 0, 0, 0.45);
2 months ago
color: white;
border: none;
}
.remove-image-btn:hover {
2 months ago
background: rgba(255, 0, 0, 0.65);
2 months ago
}
2 months ago
/* Opciones (nuevo diseño + latex preview) */
.opciones-grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.opcion-card {
2 months ago
border: 1px solid #f0f0f0;
2 months ago
background: #fff;
border-radius: 14px;
padding: 12px;
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.04);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.opcion-card:hover {
transform: translateY(-1px);
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.06);
2 months ago
}
2 months ago
.opcion-card.is-correct {
2 months ago
border-color: #52c41a;
2 months ago
box-shadow: 0 14px 26px rgba(82, 196, 26, 0.10);
2 months ago
}
2 months ago
.opcion-top {
2 months ago
display: flex;
justify-content: space-between;
2 months ago
align-items: center;
gap: 12px;
margin-bottom: 10px;
2 months ago
}
2 months ago
.opcion-badge {
2 months ago
display: flex;
align-items: center;
2 months ago
gap: 10px;
2 months ago
}
.opcion-letter {
2 months ago
width: 34px;
height: 34px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
2 months ago
color: #1890ff;
2 months ago
background: #e6f7ff;
border: 1px solid #bae7ff;
2 months ago
}
2 months ago
.mini-tag {
margin: 0;
font-size: 12px;
border-radius: 999px;
2 months ago
}
2 months ago
.opcion-controls {
2 months ago
display: flex;
align-items: center;
2 months ago
gap: 10px;
}
.icon-btn {
padding: 0 8px;
border-radius: 10px;
}
.opcion-editor-wrap {
display: grid;
grid-template-columns: 1fr 1fr;
2 months ago
gap: 12px;
}
2 months ago
.preview-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #f0f0f0;
}
.option-preview-content {
min-height: 150px;
}
.add-opcion-btn {
margin-top: 10px;
border-radius: 12px;
}
/* Config */
2 months ago
.config-row {
display: flex;
2 months ago
gap: 18px;
2 months ago
}
.config-item {
flex: 1;
}
.dificultad-option {
display: flex;
align-items: center;
gap: 8px;
}
2 months ago
/* Footer errores */
2 months ago
.form-footer {
2 months ago
margin-top: 14px;
2 months ago
}
.error-alert {
margin-bottom: 8px;
}
.error-alert:last-child {
margin-bottom: 0;
}
2 months ago
/* Vista de pregunta */
2 months ago
.pregunta-view {
max-height: 600px;
overflow-y: auto;
padding-right: 8px;
}
2 months ago
.enunciado-principal {
2 months ago
font-size: 16px;
2 months ago
line-height: 1.7;
margin-bottom: 18px;
padding: 14px;
2 months ago
background: #fafafa;
2 months ago
border-radius: 14px;
border: 1px solid #f0f0f0;
2 months ago
}
.imagenes-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.clickable-image {
max-width: 200px;
max-height: 200px;
2 months ago
border-radius: 12px;
2 months ago
border: 1px solid #f0f0f0;
cursor: pointer;
transition: transform 0.2s;
}
.clickable-image:hover {
2 months ago
transform: scale(1.04);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.12);
2 months ago
}
.enunciado-adicional {
2 months ago
margin-bottom: 18px;
padding: 14px;
background: #f5f5f5;
border-radius: 14px;
border: 1px solid #ededed;
2 months ago
}
.opciones-view {
2 months ago
margin-bottom: 18px;
2 months ago
}
.opcion-view {
display: flex;
2 months ago
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
2 months ago
margin-bottom: 8px;
2 months ago
background: #fff;
border-radius: 14px;
2 months ago
border: 1px solid #f0f0f0;
}
.opcion-view.correcta {
background: #f6ffed;
border-color: #b7eb8f;
}
.opcion-text {
flex: 1;
2 months ago
min-width: 0;
2 months ago
}
.explicacion-view {
2 months ago
padding: 14px;
2 months ago
background: #e6f7ff;
2 months ago
border-radius: 14px;
margin-bottom: 18px;
border: 1px solid #bae7ff;
2 months ago
}
.info-row {
display: flex;
flex-wrap: wrap;
2 months ago
gap: 18px;
2 months ago
}
.info-item {
display: flex;
align-items: center;
gap: 8px;
}
.delete-confirm-content {
display: flex;
flex-direction: column;
2 months ago
gap: 14px;
2 months ago
}
.pregunta-info {
background: #fafafa;
padding: 12px;
2 months ago
border-radius: 14px;
2 months ago
border: 1px solid #f0f0f0;
}
2 months ago
/* Ajustes upload */
2 months ago
: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 {
2 months ago
position: relative;
2 months ago
flex-direction: column;
align-items: flex-start;
2 months ago
gap: 12px;
2 months ago
}
2 months ago
2 months ago
.filters-section {
flex-direction: column;
align-items: stretch;
}
2 months ago
2 months ago
.filters {
flex-direction: column;
width: 100%;
}
2 months ago
2 months ago
.filters .ant-input-search,
.filters .ant-select {
2 months ago
width: 100% !important;
2 months ago
}
2 months ago
2 months ago
.curso-stats {
grid-template-columns: 1fr;
}
2 months ago
2 months ago
.config-row {
flex-direction: column;
}
2 months ago
2 months ago
.editor-container,
.editor-container.editor-small {
2 months ago
flex-direction: column;
height: auto;
}
2 months ago
.opcion-editor-wrap {
grid-template-columns: 1fr;
2 months ago
}
2 months ago
.clickable-image {
max-width: 150px;
max-height: 150px;
2 months ago
}
2 months ago
}
2 months ago
</style>