mongodb
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
@@ -2,21 +2,59 @@
|
||||
<div>
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Artículos Notificados</h1>
|
||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:space-x-4">
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Artículos Notificados</h1>
|
||||
<p v-if="currentUser" class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span v-if="isAdmin">Todos los artículos</span>
|
||||
<span v-else>Tus artículos</span>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:space-x-4 flex-wrap">
|
||||
<select
|
||||
v-model="selectedPlatform"
|
||||
@change="loadArticles"
|
||||
class="input text-sm sm:text-base"
|
||||
style="width: 100%; min-width: 180px;"
|
||||
style="width: 100%; min-width: 150px;"
|
||||
>
|
||||
<option value="">Todas las plataformas</option>
|
||||
<option value="wallapop">Wallapop</option>
|
||||
<option value="vinted">Vinted</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="isAdmin"
|
||||
v-model="selectedUsername"
|
||||
@change="loadArticles"
|
||||
class="input text-sm sm:text-base"
|
||||
style="width: 100%; min-width: 150px;"
|
||||
>
|
||||
<option value="">Todos los usuarios</option>
|
||||
<option v-for="username in availableUsernames" :key="username" :value="username">
|
||||
{{ username }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="selectedWorker"
|
||||
@change="loadArticles"
|
||||
class="input text-sm sm:text-base"
|
||||
style="width: 100%; min-width: 150px;"
|
||||
>
|
||||
<option value="">Todos los workers</option>
|
||||
<option v-for="worker in availableWorkers" :key="worker" :value="worker">
|
||||
{{ worker }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="loadArticles" class="btn btn-primary whitespace-nowrap">
|
||||
Actualizar
|
||||
</button>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
@click="handleClearAllArticles"
|
||||
class="btn btn-danger whitespace-nowrap"
|
||||
title="Borrar todos los artículos (solo admin)"
|
||||
>
|
||||
🗑️ Borrar Todos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +63,7 @@
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Buscar artículos en Redis por título, descripción, precio, localidad..."
|
||||
placeholder="Buscar artículos por título, descripción, precio, localidad..."
|
||||
class="input pr-10"
|
||||
@input="searchQuery = $event.target.value"
|
||||
/>
|
||||
@@ -49,7 +87,7 @@
|
||||
|
||||
<div v-else-if="searching" class="card text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Buscando artículos en Redis...</p>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Buscando artículos...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredArticles.length === 0 && searchQuery && !searching" class="card text-center py-12">
|
||||
@@ -58,95 +96,11 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
<ArticleCard
|
||||
v-for="article in filteredArticles"
|
||||
:key="`${article.platform}-${article.id}`"
|
||||
class="card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<!-- Imagen del artículo -->
|
||||
<div class="flex-shrink-0 self-center sm:self-start">
|
||||
<div v-if="article.images && article.images.length > 0" class="w-24 h-24 sm:w-32 sm:h-32 relative">
|
||||
<img
|
||||
:src="article.images[0]"
|
||||
:alt="article.title || 'Sin título'"
|
||||
class="w-24 h-24 sm:w-32 sm:h-32 object-cover rounded-lg"
|
||||
@error="($event) => handleImageError($event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="w-24 h-24 sm:w-32 sm:h-32 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
||||
<span class="text-gray-400 dark:text-gray-500 text-xs">Sin imagen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información del artículo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold rounded flex-shrink-0"
|
||||
:class="
|
||||
article.platform === 'wallapop'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
"
|
||||
>
|
||||
{{ article.platform?.toUpperCase() || 'N/A' }}
|
||||
</span>
|
||||
<span class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ formatDate(article.notifiedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 line-clamp-2" :title="article.title">
|
||||
{{ article.title || 'Sin título' }}
|
||||
</h3>
|
||||
|
||||
<div v-if="article.price !== null && article.price !== undefined" class="mb-2">
|
||||
<span class="text-xl font-bold text-primary-600">
|
||||
{{ article.price }} {{ article.currency || '€' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1 text-xs sm:text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
<div v-if="article.location" class="flex flex-wrap items-center">
|
||||
<span class="font-medium">📍 Localidad:</span>
|
||||
<span class="ml-2">{{ article.location }}</span>
|
||||
</div>
|
||||
<div v-if="article.allows_shipping !== null" class="flex flex-wrap items-center">
|
||||
<span class="font-medium">🚚 Envío:</span>
|
||||
<span class="ml-2">{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}</span>
|
||||
</div>
|
||||
<div v-if="article.modified_at" class="flex flex-wrap items-center">
|
||||
<span class="font-medium">🕒 Modificado:</span>
|
||||
<span class="ml-2 break-all">{{ article.modified_at }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="article.description" class="text-xs sm:text-sm text-gray-700 dark:text-gray-300 mb-2 overflow-hidden line-clamp-2">
|
||||
{{ article.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-3">
|
||||
<a
|
||||
v-if="article.url"
|
||||
:href="article.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 text-xs sm:text-sm font-medium break-all"
|
||||
>
|
||||
🔗 Ver anuncio
|
||||
</a>
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 break-all">
|
||||
ID: {{ article.id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
:article="article"
|
||||
/>
|
||||
|
||||
<div v-if="!searchQuery" class="flex justify-center space-x-2 mt-6">
|
||||
<button
|
||||
@@ -161,7 +115,7 @@
|
||||
|
||||
<p class="text-center text-xs sm:text-sm text-gray-500 mt-4 px-2">
|
||||
<span v-if="searchQuery">
|
||||
Mostrando {{ filteredArticles.length }} resultados de búsqueda en Redis
|
||||
Mostrando {{ filteredArticles.length }} resultados de búsqueda
|
||||
<span class="block sm:inline sm:ml-2 text-xs text-primary-600">(de {{ total }} artículos totales)</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
@@ -176,6 +130,11 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import ArticleCard from '../components/ArticleCard.vue';
|
||||
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
|
||||
const allArticles = ref([]);
|
||||
const searchResults = ref([]);
|
||||
@@ -185,12 +144,45 @@ const total = ref(0);
|
||||
const offset = ref(0);
|
||||
const limit = 50;
|
||||
const selectedPlatform = ref('');
|
||||
const selectedUsername = ref('');
|
||||
const selectedWorker = ref('');
|
||||
const searchQuery = ref('');
|
||||
const autoPollInterval = ref(null);
|
||||
const searchTimeout = ref(null);
|
||||
const POLL_INTERVAL = 30000; // 30 segundos
|
||||
const SEARCH_DEBOUNCE = 500; // 500ms de debounce para búsqueda
|
||||
|
||||
// Obtener listas de usuarios y workers únicos de los artículos
|
||||
const availableUsernames = computed(() => {
|
||||
const usernames = new Set();
|
||||
allArticles.value.forEach(article => {
|
||||
if (article.username) {
|
||||
usernames.add(article.username);
|
||||
}
|
||||
});
|
||||
searchResults.value.forEach(article => {
|
||||
if (article.username) {
|
||||
usernames.add(article.username);
|
||||
}
|
||||
});
|
||||
return Array.from(usernames).sort();
|
||||
});
|
||||
|
||||
const availableWorkers = computed(() => {
|
||||
const workers = new Set();
|
||||
allArticles.value.forEach(article => {
|
||||
if (article.worker_name) {
|
||||
workers.add(article.worker_name);
|
||||
}
|
||||
});
|
||||
searchResults.value.forEach(article => {
|
||||
if (article.worker_name) {
|
||||
workers.add(article.worker_name);
|
||||
}
|
||||
});
|
||||
return Array.from(workers).sort();
|
||||
});
|
||||
|
||||
// Artículos que se muestran (búsqueda o lista normal)
|
||||
const filteredArticles = computed(() => {
|
||||
if (searchQuery.value.trim()) {
|
||||
@@ -199,9 +191,14 @@ const filteredArticles = computed(() => {
|
||||
return allArticles.value;
|
||||
});
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return 'N/A';
|
||||
return new Date(timestamp).toLocaleString('es-ES');
|
||||
|
||||
function checkUserRole() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticles(reset = true, silent = false) {
|
||||
@@ -215,9 +212,19 @@ async function loadArticles(reset = true, silent = false) {
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getArticles(limit, offset.value);
|
||||
// Construir query params para filtros
|
||||
const params = { limit, offset: offset.value };
|
||||
if (selectedPlatform.value) params.platform = selectedPlatform.value;
|
||||
// Solo permitir filtrar por username si es admin
|
||||
if (selectedUsername.value && isAdmin.value) {
|
||||
params.username = selectedUsername.value;
|
||||
}
|
||||
if (selectedWorker.value) params.worker_name = selectedWorker.value;
|
||||
|
||||
const data = await api.getArticles(limit, offset.value, params);
|
||||
|
||||
let filtered = data.articles;
|
||||
// El filtro de plataforma se aplica en el backend ahora, pero mantenemos compatibilidad
|
||||
if (selectedPlatform.value) {
|
||||
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
||||
}
|
||||
@@ -240,17 +247,82 @@ async function loadArticles(reset = true, silent = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
checkUserRole();
|
||||
if (currentUser.value) {
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
loadArticles(false);
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'articles_updated') {
|
||||
if (data.type === 'articles_updated' || data.type === 'articles_cleared' || data.type === 'cache_cleared') {
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearAllArticles() {
|
||||
if (!isAdmin.value) {
|
||||
alert('Solo los administradores pueden borrar todos los artículos');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = confirm(
|
||||
'⚠️ ¿Estás seguro de que quieres borrar TODOS los artículos?\n\n' +
|
||||
'Esta acción eliminará permanentemente todos los artículos de la base de datos.\n' +
|
||||
'Esta acción NO se puede deshacer.\n\n' +
|
||||
'¿Continuar?'
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirmación adicional
|
||||
const doubleConfirmed = confirm(
|
||||
'⚠️ ÚLTIMA CONFIRMACIÓN ⚠️\n\n' +
|
||||
'Estás a punto de borrar TODOS los artículos de la base de datos.\n' +
|
||||
'Esta acción es IRREVERSIBLE.\n\n' +
|
||||
'¿Estás absolutamente seguro?'
|
||||
);
|
||||
|
||||
if (!doubleConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const result = await api.clearAllArticles();
|
||||
alert(`✅ ${result.message || `Se borraron ${result.count || 0} artículos`}`);
|
||||
|
||||
// Limpiar la vista
|
||||
allArticles.value = [];
|
||||
searchResults.value = [];
|
||||
total.value = 0;
|
||||
offset.value = 0;
|
||||
|
||||
// Recargar artículos (ahora estará vacío)
|
||||
await loadArticles();
|
||||
} catch (error) {
|
||||
console.error('Error borrando artículos:', error);
|
||||
|
||||
if (error.response?.status === 403) {
|
||||
alert('❌ Error: No tienes permisos de administrador para realizar esta acción');
|
||||
} else if (error.response?.status === 401) {
|
||||
alert('❌ Error: Debes estar autenticado para realizar esta acción');
|
||||
} else {
|
||||
alert('❌ Error al borrar artículos: ' + (error.response?.data?.error || error.message || 'Error desconocido'));
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchArticles(query) {
|
||||
if (!query.trim()) {
|
||||
searchResults.value = [];
|
||||
@@ -265,10 +337,16 @@ async function searchArticles(query) {
|
||||
|
||||
let filtered = data.articles || [];
|
||||
|
||||
// Aplicar filtro de plataforma si está seleccionado
|
||||
// Aplicar filtros si están seleccionados
|
||||
if (selectedPlatform.value) {
|
||||
filtered = filtered.filter(a => a.platform === selectedPlatform.value);
|
||||
}
|
||||
if (selectedUsername.value) {
|
||||
filtered = filtered.filter(a => a.username === selectedUsername.value);
|
||||
}
|
||||
if (selectedWorker.value) {
|
||||
filtered = filtered.filter(a => a.worker_name === selectedWorker.value);
|
||||
}
|
||||
|
||||
searchResults.value = filtered;
|
||||
} catch (error) {
|
||||
@@ -298,15 +376,13 @@ watch(searchQuery, (newQuery) => {
|
||||
}, SEARCH_DEBOUNCE);
|
||||
});
|
||||
|
||||
function handleImageError(event) {
|
||||
// Si la imagen falla al cargar, reemplazar con placeholder
|
||||
event.target.onerror = null; // Prevenir bucle infinito
|
||||
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkUserRole();
|
||||
loadArticles();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
|
||||
// Iniciar autopoll para actualizar automáticamente
|
||||
autoPollInterval.value = setInterval(() => {
|
||||
@@ -316,6 +392,8 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
|
||||
// Limpiar el intervalo cuando el componente se desmonte
|
||||
if (autoPollInterval.value) {
|
||||
|
||||
@@ -1,87 +1,161 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100 mb-4 sm:mb-6">Dashboard</h1>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Dashboard</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span v-if="isAdmin">Resumen general del sistema (estadísticas de todos los usuarios)</span>
|
||||
<span v-else>Tu resumen personal</span>
|
||||
<span v-if="currentUser" class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estadísticas -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6 mb-6 sm:mb-8">
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-primary-100 rounded-lg p-3">
|
||||
<Cog6ToothIcon class="w-6 h-6 text-primary-600" />
|
||||
<!-- Statistics Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Workers Card -->
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-primary-500 to-primary-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<Cog6ToothIcon class="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Workers Activos</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ stats.activeWorkers }}<span class="text-sm font-normal text-gray-500 dark:text-gray-400">/{{ stats.totalWorkers }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-3 sm:ml-4">
|
||||
<p class="text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400">Workers Activos</p>
|
||||
<p class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.activeWorkers }}/{{ stats.totalWorkers }}</p>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Estado del sistema</span>
|
||||
<span :class="stats.activeWorkers > 0 ? 'badge badge-success' : 'badge badge-danger'">
|
||||
{{ stats.activeWorkers > 0 ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-green-100 rounded-lg p-3">
|
||||
<HeartIcon class="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Favoritos</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalFavorites }}</p>
|
||||
<!-- Favorites Card -->
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-green-500 to-green-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<HeartIcon class="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Favoritos</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalFavorites }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<router-link to="/favorites" class="text-xs text-primary-600 dark:text-primary-400 hover:underline font-medium flex items-center">
|
||||
Ver todos →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-blue-100 rounded-lg p-3">
|
||||
<DocumentTextIcon class="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Artículos Notificados</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalNotified }}</p>
|
||||
<!-- Articles Card -->
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<DocumentTextIcon class="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Artículos Notificados</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ stats.totalNotified }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<router-link to="/articles" class="text-xs text-primary-600 dark:text-primary-400 hover:underline font-medium flex items-center">
|
||||
Ver todos →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 bg-purple-100 rounded-lg p-3">
|
||||
<ChartBarIcon class="w-6 h-6 text-purple-600" />
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Plataformas</p>
|
||||
<p class="text-sm font-bold text-gray-900 dark:text-gray-100">
|
||||
W: {{ stats.platforms?.wallapop || 0 }} | V: {{ stats.platforms?.vinted || 0 }}
|
||||
</p>
|
||||
<!-- Platforms Card -->
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl flex items-center justify-center shadow-lg">
|
||||
<ChartBarIcon class="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">Plataformas</p>
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span class="text-blue-600 dark:text-blue-400">W:</span> {{ stats.platforms?.wallapop || 0 }}
|
||||
</span>
|
||||
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span class="text-green-600 dark:text-green-400">V:</span> {{ stats.platforms?.vinted || 0 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Total de plataformas activas</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gráfico de plataformas -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<!-- Charts and Quick Actions -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Platform Distribution -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Distribución por Plataforma</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Distribución por Plataforma</h3>
|
||||
<p class="card-subtitle">Artículos notificados por plataforma</p>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<!-- Wallapop -->
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Wallapop</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ stats.platforms?.wallapop || 0 }}</span>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="w-3 h-3 bg-primary-600 rounded-full"></span>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Wallapop</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ stats.platforms?.wallapop || 0 }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
({{ getPercentage(stats.platforms?.wallapop || 0, stats.totalNotified) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
class="bg-primary-600 h-2 rounded-full"
|
||||
class="bg-gradient-to-r from-primary-500 to-primary-600 h-3 rounded-full transition-all duration-500 shadow-sm"
|
||||
:style="{
|
||||
width: `${getPercentage(stats.platforms?.wallapop || 0, stats.totalNotified)}%`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vinted -->
|
||||
<div>
|
||||
<div class="flex justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Vinted</span>
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ stats.platforms?.vinted || 0 }}</span>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="w-3 h-3 bg-green-600 rounded-full"></span>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">Vinted</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ stats.platforms?.vinted || 0 }}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
({{ getPercentage(stats.platforms?.vinted || 0, stats.totalNotified) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
class="bg-green-600 h-2 rounded-full"
|
||||
class="bg-gradient-to-r from-green-500 to-green-600 h-3 rounded-full transition-all duration-500 shadow-sm"
|
||||
:style="{
|
||||
width: `${getPercentage(stats.platforms?.vinted || 0, stats.totalNotified)}%`,
|
||||
}"
|
||||
@@ -91,29 +165,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Accesos Rápidos</h2>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Accesos Rápidos</h3>
|
||||
<p class="card-subtitle">Navegación rápida a secciones principales</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<router-link
|
||||
to="/articles"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
||||
class="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl hover:from-gray-100 hover:to-gray-200 dark:hover:from-gray-600 dark:hover:to-gray-700 transition-all duration-200 group border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Ver todos los artículos</span>
|
||||
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center group-hover:bg-blue-200 dark:group-hover:bg-blue-900/70 transition-colors">
|
||||
<DocumentTextIcon class="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Artículos</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Ver todos los artículos notificados</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/favorites"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
class="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl hover:from-gray-100 hover:to-gray-200 dark:hover:from-gray-600 dark:hover:to-gray-700 transition-all duration-200 group border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Ver favoritos</span>
|
||||
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-green-100 dark:bg-green-900/50 rounded-lg flex items-center justify-center group-hover:bg-green-200 dark:group-hover:bg-green-900/70 transition-colors">
|
||||
<HeartIcon class="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Favoritos</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Gestionar artículos favoritos</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/workers"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
class="flex items-center justify-between p-4 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-xl hover:from-gray-100 hover:to-gray-200 dark:hover:from-gray-600 dark:hover:to-gray-700 transition-all duration-200 group border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Gestionar workers</span>
|
||||
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500" />
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-primary-100 dark:bg-primary-900/50 rounded-lg flex items-center justify-center group-hover:bg-primary-200 dark:group-hover:bg-primary-900/70 transition-colors">
|
||||
<Cog6ToothIcon class="w-5 h-5 text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">Workers</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Configurar y gestionar workers</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRightIcon class="w-5 h-5 text-gray-400 dark:text-gray-500 group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors" />
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,6 +228,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
HeartIcon,
|
||||
@@ -140,6 +245,9 @@ const stats = ref({
|
||||
platforms: {},
|
||||
});
|
||||
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
|
||||
function getPercentage(value, total) {
|
||||
if (!total || total === 0) return 0;
|
||||
return Math.round((value / total) * 100);
|
||||
@@ -148,11 +256,28 @@ function getPercentage(value, total) {
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats.value = await api.getStats();
|
||||
// Verificar si el usuario es admin (se puede inferir de si ve todas las estadísticas)
|
||||
// O podemos añadir un endpoint para verificar el rol
|
||||
} catch (error) {
|
||||
console.error('Error cargando estadísticas:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function checkUserRole() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
// Por ahora, asumimos que si no hay usuario o el usuario no es admin, no es admin
|
||||
// En el futuro, se podría añadir un endpoint para verificar el rol
|
||||
// Por defecto, asumimos que el usuario normal no es admin
|
||||
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
checkUserRole();
|
||||
if (currentUser.value) {
|
||||
loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
|
||||
@@ -163,8 +288,11 @@ function handleWSMessage(event) {
|
||||
let interval = null;
|
||||
|
||||
onMounted(() => {
|
||||
checkUserRole();
|
||||
loadStats();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
interval = setInterval(loadStats, 10000); // Actualizar cada 10 segundos
|
||||
});
|
||||
|
||||
@@ -173,6 +301,8 @@ onUnmounted(() => {
|
||||
clearInterval(interval);
|
||||
}
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Favoritos</h1>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Favoritos</h1>
|
||||
<p v-if="currentUser" class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Tus favoritos
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 ml-1">({{ currentUser }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<button @click="loadFavorites" class="btn btn-primary self-start sm:self-auto">
|
||||
Actualizar
|
||||
</button>
|
||||
@@ -12,6 +18,14 @@
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Cargando favoritos...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!currentUser" class="card text-center py-12">
|
||||
<HeartIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<p class="text-gray-600 dark:text-gray-400 text-lg">Inicia sesión para ver tus favoritos</p>
|
||||
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||
Necesitas estar autenticado para ver y gestionar tus favoritos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="favorites.length === 0" class="card text-center py-12">
|
||||
<HeartIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<p class="text-gray-600 dark:text-gray-400 text-lg">No tienes favoritos aún</p>
|
||||
@@ -20,72 +34,14 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
<div v-else class="space-y-4">
|
||||
<ArticleCard
|
||||
v-for="favorite in favorites"
|
||||
:key="`${favorite.platform}-${favorite.id}`"
|
||||
class="card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-2">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold rounded"
|
||||
:class="
|
||||
favorite.platform === 'wallapop'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
"
|
||||
>
|
||||
{{ favorite.platform?.toUpperCase() || 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
{{ favorite.title || 'Sin título' }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{{ favorite.description?.substring(0, 100) }}...
|
||||
</p>
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<span class="text-xl font-bold text-primary-600">
|
||||
{{ favorite.price }} {{ favorite.currency }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ favorite.location }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:space-x-2 mt-4">
|
||||
<a
|
||||
:href="favorite.url"
|
||||
target="_blank"
|
||||
class="flex-1 btn btn-primary text-center text-sm sm:text-base"
|
||||
>
|
||||
Ver artículo
|
||||
</a>
|
||||
<button
|
||||
@click="removeFavorite(favorite.platform, favorite.id)"
|
||||
class="btn btn-danger text-sm sm:text-base"
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="favorite.images && favorite.images.length > 0" class="mt-4">
|
||||
<img
|
||||
:src="favorite.images[0]"
|
||||
:alt="favorite.title"
|
||||
class="w-full h-48 object-cover rounded-lg"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
||||
Añadido: {{ formatDate(favorite.addedAt) }}
|
||||
</p>
|
||||
</div>
|
||||
:article="favorite"
|
||||
:show-remove-button="true"
|
||||
@remove="removeFavorite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -93,26 +49,34 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import { HeartIcon } from '@heroicons/vue/24/outline';
|
||||
import ArticleCard from '../components/ArticleCard.vue';
|
||||
|
||||
const favorites = ref([]);
|
||||
const loading = ref(true);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
return new Date(dateString).toLocaleString('es-ES');
|
||||
}
|
||||
|
||||
function handleImageError(event) {
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
|
||||
async function loadFavorites() {
|
||||
// Solo cargar si hay usuario autenticado
|
||||
if (!currentUser.value) {
|
||||
favorites.value = [];
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
favorites.value = await api.getFavorites();
|
||||
} catch (error) {
|
||||
console.error('Error cargando favoritos:', error);
|
||||
// Si hay error de autenticación, limpiar favoritos
|
||||
if (error.response?.status === 401) {
|
||||
favorites.value = [];
|
||||
currentUser.value = null;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -132,20 +96,40 @@ async function removeFavorite(platform, id) {
|
||||
}
|
||||
}
|
||||
|
||||
function checkUserRole() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'favorites_updated') {
|
||||
favorites.value = data.data;
|
||||
// Solo actualizar si es para el usuario actual
|
||||
if (data.username === currentUser.value) {
|
||||
favorites.value = data.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
checkUserRole();
|
||||
if (currentUser.value) {
|
||||
loadFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkUserRole();
|
||||
loadFavorites();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -57,7 +57,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-2 sm:p-6">
|
||||
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
|
||||
<DocumentMagnifyingGlassIcon class="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold">Acceso Denegado</p>
|
||||
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||
Solo los administradores pueden ver los logs del sistema
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="card p-2 sm:p-6">
|
||||
<div
|
||||
ref="logsContainer"
|
||||
class="bg-gray-900 text-green-400 font-mono text-xs sm:text-sm p-3 sm:p-4 rounded-lg overflow-x-auto max-h-[400px] sm:max-h-[600px] overflow-y-auto"
|
||||
@@ -87,6 +95,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import { DocumentMagnifyingGlassIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const logs = ref([]);
|
||||
const loading = ref(true);
|
||||
@@ -96,6 +106,9 @@ const refreshIntervalSeconds = ref(5);
|
||||
const followLatestLog = ref(true);
|
||||
const logsContainer = ref(null);
|
||||
const lastLineNumber = ref(-1); // Número de la última línea leída
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
let refreshInterval = null;
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
@@ -113,7 +126,21 @@ function getLogColor(log) {
|
||||
return 'text-green-400';
|
||||
}
|
||||
|
||||
function checkUserRole() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
|
||||
}
|
||||
|
||||
async function loadLogs(forceReload = false, shouldScroll = null) {
|
||||
// Verificar que el usuario es admin antes de cargar logs
|
||||
if (!isAdmin.value) {
|
||||
accessDenied.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
accessDenied.value = false;
|
||||
|
||||
// Si shouldScroll es null, usar la configuración de followLatestLog
|
||||
const shouldAutoScroll = shouldScroll !== null ? shouldScroll : followLatestLog.value;
|
||||
|
||||
@@ -176,6 +203,10 @@ async function loadLogs(forceReload = false, shouldScroll = null) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando logs:', error);
|
||||
// Si hay error 403, es porque no es admin
|
||||
if (error.response?.status === 403) {
|
||||
accessDenied.value = true;
|
||||
}
|
||||
loading.value = false;
|
||||
} finally {
|
||||
if (isInitialLoad) {
|
||||
@@ -214,11 +245,12 @@ function handleWSMessage(event) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkUserRole();
|
||||
loadLogs(true, true); // Primera carga forzada siempre hace scroll
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
|
||||
// Inicializar auto-refresh si está activado
|
||||
if (autoRefresh.value) {
|
||||
if (autoRefresh.value && isAdmin.value) {
|
||||
updateRefreshInterval();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
>
|
||||
🔑 Cambiar Mi Contraseña
|
||||
</button>
|
||||
<button @click="showAddModal = true" class="btn btn-primary text-xs sm:text-sm whitespace-nowrap">
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
@click="showAddModal = true"
|
||||
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
+ Crear Usuario
|
||||
</button>
|
||||
</div>
|
||||
@@ -39,6 +43,12 @@
|
||||
>
|
||||
Tú
|
||||
</span>
|
||||
<span
|
||||
v-if="user.role === 'admin'"
|
||||
class="px-2 py-1 text-xs font-semibold rounded bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -58,6 +68,14 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="user.username === currentUser"
|
||||
@click="showTelegramModal = true"
|
||||
class="btn btn-secondary text-xs sm:text-sm"
|
||||
title="Configurar Telegram"
|
||||
>
|
||||
📱 Telegram
|
||||
</button>
|
||||
<button
|
||||
v-if="user.username === currentUser"
|
||||
@click="showChangePasswordModal = true"
|
||||
@@ -67,7 +85,7 @@
|
||||
🔑 Cambiar Contraseña
|
||||
</button>
|
||||
<button
|
||||
v-if="user.username !== currentUser"
|
||||
v-if="user.username !== currentUser && isAdmin"
|
||||
@click="confirmDeleteUser(user.username)"
|
||||
class="btn btn-danger text-xs sm:text-sm"
|
||||
title="Eliminar usuario"
|
||||
@@ -275,6 +293,82 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para configuración de Telegram -->
|
||||
<div
|
||||
v-if="showTelegramModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-2 sm:p-4"
|
||||
@click.self="closeTelegramModal"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 sm:p-6 max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">Configuración de Telegram</h2>
|
||||
<button
|
||||
@click="closeTelegramModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Configura tu bot de Telegram y canal para recibir notificaciones de tus workers.
|
||||
</p>
|
||||
<form @submit.prevent="saveTelegramConfig" class="space-y-4">
|
||||
<div v-if="telegramError" class="bg-red-100 dark:bg-red-900/30 border border-red-400 dark:border-red-700 text-red-700 dark:text-red-300 px-4 py-3 rounded">
|
||||
{{ telegramError }}
|
||||
</div>
|
||||
<div v-if="telegramSuccess" class="bg-green-100 dark:bg-green-900/30 border border-green-400 dark:border-green-700 text-green-700 dark:text-green-300 px-4 py-3 rounded">
|
||||
{{ telegramSuccess }}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Token del Bot <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
v-model="telegramForm.token"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="Ej: 123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Obtén tu token desde @BotFather en Telegram
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Canal o Grupo <span class="text-red-500">*</span></label>
|
||||
<input
|
||||
v-model="telegramForm.channel"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="Ej: @micanal o -1001234567890"
|
||||
required
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Usa @nombrecanal para canales públicos o el ID numérico para grupos/canales privados
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="telegramForm.enable_polling"
|
||||
type="checkbox"
|
||||
id="enable_polling"
|
||||
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label for="enable_polling" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Habilitar polling del bot (para comandos /favs, /threads, etc.)
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 sm:space-x-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="button" @click="closeTelegramModal" class="btn btn-secondary text-sm sm:text-base">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary text-sm sm:text-base" :disabled="loadingAction">
|
||||
{{ loadingAction ? 'Guardando...' : 'Guardar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de confirmación para eliminar -->
|
||||
<div
|
||||
v-if="userToDelete"
|
||||
@@ -323,7 +417,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
|
||||
@@ -332,10 +426,13 @@ const loading = ref(true);
|
||||
const loadingAction = ref(false);
|
||||
const showAddModal = ref(false);
|
||||
const showChangePasswordModal = ref(false);
|
||||
const showTelegramModal = ref(false);
|
||||
const userToDelete = ref(null);
|
||||
const addError = ref('');
|
||||
const passwordError = ref('');
|
||||
const passwordSuccess = ref('');
|
||||
const telegramError = ref('');
|
||||
const telegramSuccess = ref('');
|
||||
|
||||
const userForm = ref({
|
||||
username: '',
|
||||
@@ -349,10 +446,19 @@ const passwordForm = ref({
|
||||
newPasswordConfirm: '',
|
||||
});
|
||||
|
||||
const telegramForm = ref({
|
||||
token: '',
|
||||
channel: '',
|
||||
enable_polling: false
|
||||
});
|
||||
|
||||
const isAuthenticated = computed(() => authService.hasCredentials());
|
||||
const currentUser = computed(() => {
|
||||
return authService.getUsername() || '';
|
||||
});
|
||||
const isAdmin = computed(() => {
|
||||
return authService.isAdmin();
|
||||
});
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
@@ -524,6 +630,61 @@ function closeChangePasswordModal() {
|
||||
};
|
||||
}
|
||||
|
||||
function closeTelegramModal() {
|
||||
showTelegramModal.value = false;
|
||||
telegramError.value = '';
|
||||
telegramSuccess.value = '';
|
||||
telegramForm.value = {
|
||||
token: '',
|
||||
channel: '',
|
||||
enable_polling: false
|
||||
};
|
||||
}
|
||||
|
||||
async function loadTelegramConfig() {
|
||||
try {
|
||||
const config = await api.getTelegramConfig();
|
||||
if (config) {
|
||||
telegramForm.value = {
|
||||
token: config.token || '',
|
||||
channel: config.channel || '',
|
||||
enable_polling: config.enable_polling || false
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando configuración de Telegram:', error);
|
||||
telegramError.value = 'Error cargando la configuración de Telegram';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTelegramConfig() {
|
||||
telegramError.value = '';
|
||||
telegramSuccess.value = '';
|
||||
|
||||
if (!telegramForm.value.token || !telegramForm.value.channel) {
|
||||
telegramError.value = 'Token y canal son requeridos';
|
||||
return;
|
||||
}
|
||||
|
||||
loadingAction.value = true;
|
||||
try {
|
||||
await api.setTelegramConfig(telegramForm.value);
|
||||
telegramSuccess.value = 'Configuración de Telegram guardada correctamente';
|
||||
setTimeout(() => {
|
||||
closeTelegramModal();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error guardando configuración de Telegram:', error);
|
||||
if (error.response?.data?.error) {
|
||||
telegramError.value = error.response.data.error;
|
||||
} else {
|
||||
telegramError.value = 'Error al guardar la configuración de Telegram';
|
||||
}
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthLogout() {
|
||||
// Cuando el usuario se desconecta globalmente, limpiar datos
|
||||
users.value = [];
|
||||
@@ -544,6 +705,13 @@ onMounted(() => {
|
||||
});
|
||||
});
|
||||
|
||||
// Cargar configuración de Telegram cuando se abre el modal
|
||||
watch(showTelegramModal, (newVal) => {
|
||||
if (newVal) {
|
||||
loadTelegramConfig();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-logout', handleAuthLogout);
|
||||
window.removeEventListener('auth-login', loadUsers);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Gestión de Workers</h1>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Gestión de Workers</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Tus workers personalizados
|
||||
<span v-if="currentUser" class="font-medium text-gray-700 dark:text-gray-300">(Usuario: {{ currentUser }})</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button @click="showGeneralModal = true" class="btn btn-secondary text-xs sm:text-sm">
|
||||
⚙️ Configuración General
|
||||
</button>
|
||||
<button @click="handleClearCache" class="btn btn-secondary text-xs sm:text-sm">
|
||||
🗑️ Limpiar Caché
|
||||
</button>
|
||||
<button @click="showAddModal = true" class="btn btn-primary text-xs sm:text-sm whitespace-nowrap">
|
||||
+ Añadir Worker
|
||||
</button>
|
||||
@@ -116,7 +119,7 @@
|
||||
🗑️ Eliminar
|
||||
</button>
|
||||
<button
|
||||
@click="disableWorker(worker.name)"
|
||||
@click="disableWorker(worker)"
|
||||
class="btn btn-secondary text-xs sm:text-sm flex-1 sm:flex-none"
|
||||
>
|
||||
⏸️ Desactivar
|
||||
@@ -157,7 +160,7 @@
|
||||
✏️ Editar
|
||||
</button>
|
||||
<button
|
||||
@click="enableWorker(worker.name)"
|
||||
@click="enableWorker(worker)"
|
||||
class="btn btn-primary text-xs sm:text-sm flex-1 sm:flex-none"
|
||||
>
|
||||
▶️ Activar
|
||||
@@ -175,7 +178,10 @@
|
||||
</div>
|
||||
|
||||
<div v-if="activeWorkers.length === 0 && disabledWorkers.length === 0" class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400">No hay workers configurados</p>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-2">No tienes workers configurados</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
Los workers son personales para cada usuario. Cada usuario gestiona sus propias búsquedas.
|
||||
</p>
|
||||
<button @click="showAddModal = true" class="btn btn-primary mt-4">
|
||||
+ Crear primer worker
|
||||
</button>
|
||||
@@ -385,7 +391,10 @@
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 sm:p-6 max-w-2xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Configuración General</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">Estas configuraciones se aplican a todos los workers</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Estas configuraciones se aplican a todos tus workers.
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">Los filtros globales se combinan con los filtros específicos de cada worker.</span>
|
||||
</p>
|
||||
<form @submit.prevent="saveGeneralConfig" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras del título (global)</label>
|
||||
@@ -422,22 +431,32 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
|
||||
const workers = ref({ items: [], disabled: [], general: {} });
|
||||
const loading = ref(true);
|
||||
const showAddModal = ref(false);
|
||||
const showGeneralModal = ref(false);
|
||||
const editingWorker = ref(null);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
|
||||
const activeWorkers = computed(() => {
|
||||
return workers.value.items?.filter(
|
||||
w => !workers.value.disabled?.includes(w.name)
|
||||
w => {
|
||||
const workerId = w.id || w.worker_id;
|
||||
const workerName = w.name;
|
||||
return !workers.value.disabled?.includes(workerId) && !workers.value.disabled?.includes(workerName);
|
||||
}
|
||||
) || [];
|
||||
});
|
||||
|
||||
const disabledWorkers = computed(() => {
|
||||
return workers.value.items?.filter(
|
||||
w => workers.value.disabled?.includes(w.name)
|
||||
w => {
|
||||
const workerId = w.id || w.worker_id;
|
||||
const workerName = w.name;
|
||||
return workers.value.disabled?.includes(workerId) || workers.value.disabled?.includes(workerName);
|
||||
}
|
||||
) || [];
|
||||
});
|
||||
|
||||
@@ -613,6 +632,7 @@ async function saveWorker() {
|
||||
};
|
||||
|
||||
const workerData = {
|
||||
id: editingWorker.value ? (workers.value.items[editingWorker.value.index]?.id || workers.value.items[editingWorker.value.index]?.worker_id || crypto.randomUUID()) : crypto.randomUUID(),
|
||||
name: workerForm.value.name,
|
||||
platform: workerForm.value.platform,
|
||||
search_query: workerForm.value.search_query,
|
||||
@@ -631,11 +651,15 @@ async function saveWorker() {
|
||||
};
|
||||
|
||||
if (editingWorker.value) {
|
||||
// Editar worker existente
|
||||
// Editar worker existente - mantener el ID existente
|
||||
const index = editingWorker.value.index;
|
||||
const existingId = workers.value.items[index]?.id || workers.value.items[index]?.worker_id;
|
||||
if (existingId) {
|
||||
workerData.id = existingId;
|
||||
}
|
||||
updatedWorkers.items[index] = workerData;
|
||||
} else {
|
||||
// Añadir nuevo worker
|
||||
// Añadir nuevo worker con ID único
|
||||
updatedWorkers.items.push(workerData);
|
||||
}
|
||||
|
||||
@@ -669,8 +693,10 @@ async function saveGeneralConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
async function disableWorker(name) {
|
||||
if (!confirm(`¿Desactivar el worker "${name}"?`)) {
|
||||
async function disableWorker(worker) {
|
||||
const workerId = worker.id || worker.worker_id;
|
||||
const workerName = worker.name;
|
||||
if (!confirm(`¿Desactivar el worker "${workerName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -681,8 +707,14 @@ async function disableWorker(name) {
|
||||
disabled: [...(workers.value.disabled || [])]
|
||||
};
|
||||
|
||||
if (!updatedWorkers.disabled.includes(name)) {
|
||||
updatedWorkers.disabled.push(name);
|
||||
// Usar ID si existe, sino usar nombre (para compatibilidad)
|
||||
const identifier = workerId || workerName;
|
||||
|
||||
// Eliminar cualquier referencia antigua (por nombre o ID)
|
||||
updatedWorkers.disabled = updatedWorkers.disabled.filter(d => d !== workerId && d !== workerName && d !== worker.worker_id);
|
||||
|
||||
if (!updatedWorkers.disabled.includes(identifier)) {
|
||||
updatedWorkers.disabled.push(identifier);
|
||||
}
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
@@ -693,12 +725,15 @@ async function disableWorker(name) {
|
||||
}
|
||||
}
|
||||
|
||||
async function enableWorker(name) {
|
||||
async function enableWorker(worker) {
|
||||
const workerId = worker.id || worker.worker_id;
|
||||
const workerName = worker.name;
|
||||
|
||||
try {
|
||||
const updatedWorkers = {
|
||||
...workers.value,
|
||||
items: workers.value.items || [],
|
||||
disabled: [...(workers.value.disabled || [])].filter(n => n !== name)
|
||||
disabled: [...(workers.value.disabled || [])].filter(d => d !== workerId && d !== workerName && d !== worker.worker_id)
|
||||
};
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
@@ -730,41 +765,40 @@ async function deleteWorker(name) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearCache() {
|
||||
if (!confirm('¿Estás seguro de que quieres limpiar toda la caché de Redis?\n\nEsto eliminará todos los artículos notificados de todas las instancias. Esta acción no se puede deshacer.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api.clearCache();
|
||||
const message = result.count > 0
|
||||
? `✓ Caché limpiada exitosamente: ${result.count} artículos eliminados`
|
||||
: 'La caché ya estaba vacía';
|
||||
alert(message);
|
||||
} catch (error) {
|
||||
console.error('Error limpiando caché:', error);
|
||||
alert('Error al limpiar la caché: ' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'workers_updated') {
|
||||
workers.value = data.data;
|
||||
// Actualizar formulario general
|
||||
generalForm.value = {
|
||||
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
|
||||
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
|
||||
};
|
||||
// Solo actualizar si es para el usuario actual (o si no especifica usuario)
|
||||
if (!data.username || data.username === currentUser.value) {
|
||||
workers.value = data.data;
|
||||
// Actualizar formulario general
|
||||
generalForm.value = {
|
||||
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
|
||||
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Escuchar cambios de autenticación
|
||||
function handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
// Recargar workers si cambia el usuario
|
||||
if (currentUser.value) {
|
||||
loadWorkers();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWorkers();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user