Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-20 03:21:50 +01:00
parent 19932854ca
commit 81bf0675ed
32 changed files with 3081 additions and 932 deletions

View File

@@ -1,30 +1,103 @@
<template>
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
<nav class="bg-white dark:bg-gray-800 shadow-lg dark:shadow-gray-900 border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<div class="flex-shrink-0 flex items-center">
<h1 class="text-xl sm:text-2xl font-bold text-primary-600 dark:text-primary-400">🛎 Wallabicher</h1>
</div>
<div class="hidden md:ml-6 md:flex md:space-x-8">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900 dark:text-gray-200 hover:text-primary-600 dark:hover:text-primary-400 border-b-2 border-transparent hover:border-primary-600 dark:hover:border-primary-400 transition-colors"
active-class="border-primary-600 dark:border-primary-400 text-primary-600 dark:text-primary-400"
>
<component :is="item.icon" class="w-5 h-5 mr-2" />
{{ item.name }}
</router-link>
<div class="min-h-screen bg-gray-100 dark:bg-gray-900" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
<!-- Sidebar -->
<aside
class="fixed top-0 left-0 z-40 h-screen transition-all duration-300 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shadow-lg"
:class="sidebarCollapsed ? 'w-20' : 'w-64'"
>
<!-- Logo -->
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
<router-link v-if="!sidebarCollapsed" to="/" class="flex items-center space-x-3 flex-1 min-w-0 group">
<div class="flex-shrink-0 w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
<img
src="/logo.jpg"
alt="Wallabicher Logo"
class="w-full h-full object-cover"
/>
</div>
<div class="flex-1 min-w-0">
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100 truncate">Wallabicher</h1>
<p class="text-xs text-gray-500 dark:text-gray-400">Admin Panel</p>
</div>
</router-link>
<router-link v-else to="/" class="flex items-center justify-center w-full group">
<div class="w-10 h-10 rounded-lg overflow-hidden ring-2 ring-gray-200 dark:ring-gray-700 group-hover:ring-primary-500 transition-all">
<img
src="/logo.jpg"
alt="Wallabicher Logo"
class="w-full h-full object-cover"
/>
</div>
</router-link>
<button
@click="sidebarCollapsed = !sidebarCollapsed"
class="flex-shrink-0 p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ml-2"
:title="sidebarCollapsed ? 'Expandir sidebar' : 'Colapsar sidebar'"
>
<Bars3Icon v-if="sidebarCollapsed" class="w-5 h-5" />
<XMarkIcon v-else class="w-5 h-5" />
</button>
</div>
<!-- Navigation -->
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="flex items-center px-3 py-3 text-sm font-medium rounded-lg transition-all duration-200 group"
:class="
$route.path === item.path
? 'bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-400 shadow-sm'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
"
>
<component :is="item.icon" class="flex-shrink-0 w-5 h-5" :class="sidebarCollapsed ? 'mx-auto' : 'mr-3'" />
<span v-if="!sidebarCollapsed" class="flex-1">{{ item.name }}</span>
<span
v-if="!sidebarCollapsed && $route.path === item.path"
class="ml-2 w-2 h-2 bg-primary-600 rounded-full"
></span>
</router-link>
</nav>
<!-- Sidebar Footer -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-2 mb-3" :class="sidebarCollapsed ? 'justify-center' : ''">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-gradient-to-br from-green-400 to-green-500 rounded-full flex items-center justify-center">
<div class="w-3 h-3 bg-white rounded-full" :class="wsConnected ? 'opacity-100' : 'opacity-0'"></div>
</div>
</div>
<div class="flex items-center space-x-3">
<div v-if="!sidebarCollapsed" class="flex-1 min-w-0">
<p class="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Estado del sistema</p>
</div>
</div>
</div>
</aside>
<!-- Main Content -->
<div class="transition-all duration-300" :class="sidebarCollapsed ? 'ml-20' : 'ml-64'">
<!-- Header -->
<header class="sticky top-0 z-30 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm">
<div class="flex items-center justify-between h-16 px-6">
<!-- Breadcrumbs -->
<div class="flex items-center space-x-2">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ getCurrentPageTitle() }}
</h2>
</div>
<!-- Header Actions -->
<div class="flex items-center space-x-2">
<!-- Push Notifications -->
<button
v-if="!pushEnabled"
@click="enablePushNotifications"
class="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
class="relative p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Activar notificaciones push"
>
<BellIcon class="w-5 h-5" />
@@ -32,135 +105,77 @@
<button
v-else
@click="disablePushNotifications"
class="p-2 rounded-md text-green-600 dark:text-green-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
class="relative p-2 rounded-lg text-green-600 dark:text-green-400 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Desactivar notificaciones push"
>
<BellSlashIcon class="w-5 h-5" />
<span class="absolute top-1 right-1 w-2 h-2 bg-green-500 rounded-full"></span>
</button>
<!-- Dark Mode Toggle -->
<button
@click="toggleDarkMode"
class="p-2 rounded-md text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Alternar modo oscuro"
>
<SunIcon v-if="isDark" class="w-5 h-5" />
<MoonIcon v-else class="w-5 h-5" />
</button>
<!-- Login/Logout -->
<button
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
:class="[
'p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500',
isAuthenticated
? 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
: 'text-primary-600 dark:text-primary-400 hover:bg-primary-50 dark:hover:bg-primary-900/20'
]"
:title="isAuthenticated ? 'Desconectar / Cerrar sesión' : 'Iniciar sesión'"
class="p-2 rounded-lg text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
:title="isAuthenticated ? 'Desconectar' : 'Iniciar sesión'"
>
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5" />
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5" />
</button>
<div class="hidden sm:flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
:class="wsConnected ? 'bg-green-500' : 'bg-red-500'"
></div>
<span class="text-sm text-gray-600 dark:text-gray-300">
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
</span>
</div>
<!-- Mobile menu button -->
<button
@click="mobileMenuOpen = !mobileMenuOpen"
class="md:hidden inline-flex items-center justify-center p-2 rounded-md text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500"
aria-expanded="false"
>
<Bars3Icon v-if="!mobileMenuOpen" class="block h-6 w-6" />
<XMarkIcon v-else class="block h-6 w-6" />
</button>
</div>
</div>
</div>
</header>
<!-- Mobile menu -->
<div v-if="mobileMenuOpen" class="md:hidden border-t border-gray-200 dark:border-gray-700">
<div class="pt-2 pb-3 space-y-1 px-4">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
@click="mobileMenuOpen = false"
class="flex items-center px-3 py-2 text-base font-medium text-gray-900 dark:text-gray-200 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors"
:class="$route.path === item.path ? 'text-primary-600 dark:text-primary-400 bg-gray-50 dark:bg-gray-700' : ''"
>
<component :is="item.icon" class="w-5 h-5 mr-3" />
{{ item.name }}
</router-link>
</div>
<div class="pt-4 pb-3 border-t border-gray-200 dark:border-gray-700 px-4 space-y-3">
<div class="flex items-center space-x-2">
<div
class="w-3 h-3 rounded-full"
:class="wsConnected ? 'bg-green-500' : 'bg-red-500'"
></div>
<span class="text-sm text-gray-600 dark:text-gray-300">
{{ wsConnected ? 'Conectado' : 'Desconectado' }}
</span>
</div>
<button
@click="isAuthenticated ? handleLogout() : showLoginModal = true"
:class="[
'w-full flex items-center px-3 py-2 text-base font-medium rounded-md transition-colors',
isAuthenticated
? 'text-gray-900 dark:text-gray-200 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-50 dark:hover:bg-gray-700'
: 'text-gray-900 dark:text-gray-200 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-700'
]"
>
<ArrowRightOnRectangleIcon v-if="isAuthenticated" class="w-5 h-5 mr-3" />
<ArrowLeftOnRectangleIcon v-else class="w-5 h-5 mr-3" />
{{ isAuthenticated ? 'Desconectar' : 'Iniciar Sesión' }}
</button>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
<router-view />
</main>
<!-- Page Content -->
<main class="p-6">
<router-view />
</main>
</div>
<!-- Toast notifications container -->
<div class="fixed top-4 right-4 z-50 space-y-2">
<div class="fixed top-20 right-6 z-50 space-y-3">
<div
v-for="toast in toasts"
:key="toast.id"
class="bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm border border-gray-200/50 dark:border-gray-700/50 rounded-lg shadow-md p-2 max-w-xs min-w-[200px] animate-slide-in"
class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl p-3 max-w-sm min-w-[320px] animate-slide-in backdrop-blur-sm"
>
<div class="flex items-start gap-2">
<div class="flex items-start gap-3">
<div class="flex-shrink-0">
<img
v-if="toast.images"
:src="toast.images[0]"
:alt="toast.title"
class="w-10 h-10 object-cover rounded"
class="w-12 h-12 object-cover rounded-lg"
@error="($event) => $event.target.style.display = 'none'"
/>
<div v-else class="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded flex items-center justify-center">
<span class="text-gray-400 text-xs">📦</span>
<div v-else class="w-12 h-12 bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-700 dark:to-gray-600 rounded-lg flex items-center justify-center">
<span class="text-gray-400 text-lg">📦</span>
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-1">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1 mb-0.5">
<div class="flex items-center gap-2 mb-1">
<span
class="px-1.5 py-0.5 text-[10px] font-semibold rounded"
:class="toast.platform === 'wallapop' ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200' : 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
class="px-2 py-0.5 text-[10px] font-bold rounded-md uppercase tracking-wide"
:class="toast.platform === 'wallapop' ? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300' : 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300'"
>
{{ toast.platform?.toUpperCase() }}
</span>
</div>
<h4 class="font-medium text-gray-900 dark:text-gray-100 text-xs mb-0.5 line-clamp-1 leading-tight">
<h4 class="font-semibold text-gray-900 dark:text-gray-100 text-sm mb-1 line-clamp-1 leading-tight">
{{ toast.title || 'Nuevo artículo' }}
</h4>
<p v-if="toast.price" class="text-sm font-bold text-primary-600 dark:text-primary-400 mb-1">
<p v-if="toast.price" class="text-base font-bold text-primary-600 dark:text-primary-400 mb-2">
{{ toast.price }} {{ toast.currency || '' }}
</p>
<a
@@ -168,14 +183,14 @@
:href="toast.url"
target="_blank"
rel="noopener noreferrer"
class="text-[10px] text-primary-600 dark:text-primary-400 hover:underline inline-flex items-center gap-0.5"
class="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:underline inline-flex items-center gap-1 font-medium"
>
Ver
Ver artículo
</a>
</div>
<button
@click="removeToast(toast.id)"
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs leading-none p-0.5"
class="flex-shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-sm leading-none p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title="Cerrar"
>
@@ -189,92 +204,111 @@
<!-- Modal de Login Global -->
<div
v-if="showLoginModal"
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
class="fixed inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-4"
@click.self="closeLoginModal"
>
<div class="card max-w-md w-full">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">🔐 Iniciar Sesión</h2>
<button
@click="closeLoginModal"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="Cerrar"
>
</button>
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl max-w-md w-full border border-gray-200 dark:border-gray-700 overflow-hidden">
<!-- Modal Header -->
<div class="bg-gradient-to-r from-primary-600 to-primary-700 px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-white rounded-lg overflow-hidden ring-2 ring-white/30">
<img
src="/logo.jpg"
alt="Wallabicher Logo"
class="w-full h-full object-cover"
/>
</div>
<div>
<h2 class="text-xl font-bold text-white">Iniciar Sesión</h2>
<p class="text-xs text-white/80">Wallabicher Admin Panel</p>
</div>
</div>
<button
@click="closeLoginModal"
class="text-white/80 hover:text-white p-1 rounded-lg hover:bg-white/20 transition-colors"
title="Cerrar"
>
<XMarkIcon class="w-6 h-6" />
</button>
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Ingresa tus credenciales para acceder a las funciones de administrador.
</p>
<form @submit.prevent="handleGlobalLogin" class="space-y-4">
<div v-if="globalLoginError" 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">
{{ globalLoginError }}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Usuario
</label>
<input
v-model="globalLoginForm.username"
type="text"
class="input"
placeholder="admin"
required
autocomplete="username"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Contraseña
</label>
<input
v-model="globalLoginForm.password"
type="password"
class="input"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<div class="flex items-center">
<input
v-model="globalLoginForm.remember"
type="checkbox"
id="remember-global"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="remember-global" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Recordar credenciales
</label>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeLoginModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="globalLoginLoading"
>
{{ globalLoginLoading ? 'Iniciando...' : 'Iniciar Sesión' }}
</button>
</div>
</form>
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-500 dark:text-gray-400">
💡 Por defecto: usuario <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">admin</code> / contraseña <code class="bg-gray-100 dark:bg-gray-800 px-1 rounded">admin</code>
<!-- Modal Body -->
<div class="px-6 py-6">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
Ingresa tus credenciales para acceder al panel de administración.
</p>
<form @submit.prevent="handleGlobalLogin" class="space-y-5">
<div v-if="globalLoginError" class="bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 text-red-700 dark:text-red-300 px-4 py-3 rounded-r-lg">
<div class="flex items-center">
<span class="text-red-500 mr-2"></span>
<span class="text-sm font-medium">{{ globalLoginError }}</span>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Usuario
</label>
<input
v-model="globalLoginForm.username"
type="text"
class="input"
placeholder="admin"
required
autocomplete="username"
/>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
Contraseña
</label>
<input
v-model="globalLoginForm.password"
type="password"
class="input"
placeholder="••••••••"
required
autocomplete="current-password"
/>
</div>
<div class="flex items-center">
<input
v-model="globalLoginForm.remember"
type="checkbox"
id="remember-global"
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label for="remember-global" class="ml-2 block text-sm text-gray-700 dark:text-gray-300">
Recordar credenciales
</label>
</div>
<div class="flex flex-col-reverse sm:flex-row justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="closeLoginModal"
class="btn btn-secondary text-sm sm:text-base"
>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary text-sm sm:text-base"
:disabled="globalLoginLoading"
>
<span v-if="!globalLoginLoading">Iniciar Sesión</span>
<span v-else class="flex items-center">
<span class="animate-spin mr-2"></span>
Iniciando...
</span>
</button>
</div>
</form>
</div>
</div>
</div>
@@ -304,24 +338,26 @@ import authService from './services/auth';
import { useRouter } from 'vue-router';
import api from './services/api';
const navItems = [
{ path: '/', name: 'Dashboard', icon: HomeIcon },
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon },
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon },
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon },
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon },
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon },
const allNavItems = [
{ path: '/', name: 'Dashboard', icon: HomeIcon, adminOnly: false },
{ path: '/articles', name: 'Artículos', icon: DocumentTextIcon, adminOnly: false },
{ path: '/favorites', name: 'Favoritos', icon: HeartIcon, adminOnly: false },
{ path: '/workers', name: 'Workers', icon: Cog6ToothIcon, adminOnly: false },
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: false },
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true },
];
const router = useRouter();
const wsConnected = ref(false);
const mobileMenuOpen = ref(false);
const sidebarCollapsed = ref(false);
const darkMode = ref(false);
const toasts = ref([]);
const pushEnabled = ref(false);
const showLoginModal = ref(false);
const globalLoginError = ref('');
const globalLoginLoading = ref(false);
const currentUser = ref(authService.getUsername() || null);
const isAdmin = ref(false);
const globalLoginForm = ref({
username: '',
password: '',
@@ -333,6 +369,22 @@ let toastIdCounter = 0;
const isDark = computed(() => darkMode.value);
const isAuthenticated = computed(() => authService.hasCredentials());
// Filtrar navItems según el rol del usuario
const navItems = computed(() => {
return allNavItems.filter(item => {
// Si requiere admin y el usuario no es admin, excluir
if (item.adminOnly && !isAdmin.value) {
return false;
}
return true;
});
});
function checkUserRole() {
currentUser.value = authService.getUsername() || null;
isAdmin.value = currentUser.value === 'admin'; // Simplificación temporal
}
function addToast(article) {
const id = ++toastIdCounter;
toasts.value.push({
@@ -465,6 +517,15 @@ function handleAuthRequired(event) {
}
}
function getCurrentPageTitle() {
const currentItem = navItems.value.find(item => item.path === router.currentRoute.value.path);
return currentItem ? currentItem.name : 'Dashboard';
}
function handleAuthChange() {
checkUserRole();
}
async function handleLogout() {
// Llamar al endpoint de logout e invalidar token
await authService.logout();
@@ -481,6 +542,7 @@ async function handleLogout() {
onMounted(async () => {
initDarkMode();
checkUserRole();
connectWebSocket();
await checkPushStatus();
@@ -496,15 +558,20 @@ onMounted(async () => {
if (!isValid) {
// Si el token expiró, limpiar sesión
authService.clearSession();
checkUserRole();
}
}
// Escuchar eventos de autenticación requerida
window.addEventListener('auth-required', handleAuthRequired);
window.addEventListener('auth-login', handleAuthChange);
window.addEventListener('auth-logout', handleAuthChange);
});
onUnmounted(() => {
window.removeEventListener('auth-required', handleAuthRequired);
window.removeEventListener('auth-login', handleAuthChange);
window.removeEventListener('auth-logout', handleAuthChange);
if (ws) {
ws.close();
}

View File

@@ -0,0 +1,225 @@
<template>
<div 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 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300'
: 'bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-300'
"
>
{{ article.platform?.toUpperCase() || 'N/A' }}
</span>
<span v-if="article.username" class="px-2 py-1 text-xs font-medium rounded bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300 flex-shrink-0" title="Usuario">
👤 {{ article.username }}
</span>
<span v-if="article.worker_name" class="px-2 py-1 text-xs font-medium rounded bg-orange-100 dark:bg-orange-900/50 text-orange-800 dark:text-orange-300 flex-shrink-0" title="Worker">
{{ article.worker_name }}
</span>
<span v-if="article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
{{ formatDate(article.notifiedAt) }}
</span>
<span v-if="article.addedAt && !article.notifiedAt" class="text-xs sm:text-sm text-gray-500 dark:text-gray-400">
Añadido: {{ formatDate(article.addedAt) }}
</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 dark:text-primary-400">
{{ 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 && article.allows_shipping !== undefined" 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 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 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>
<button
v-if="showRemoveButton"
@click="$emit('remove', article.platform, article.id)"
class="btn btn-danger text-xs sm:text-sm"
>
Eliminar
</button>
<button
v-if="!showRemoveButton && isAuthenticated && !isAdding"
@click="handleAddFavorite"
class="btn text-xs sm:text-sm flex items-center gap-1"
:class="favoriteStatus ? 'btn-secondary' : 'bg-pink-500 hover:bg-pink-600 text-white border-pink-600'"
:disabled="favoriteStatus"
:title="favoriteStatus ? 'Ya está en favoritos' : 'Añadir a favoritos'"
>
<HeartIconSolid v-if="favoriteStatus" class="w-4 h-4" />
<HeartIcon v-else class="w-4 h-4" />
{{ favoriteStatus ? 'En favoritos' : 'Añadir a favoritos' }}
</button>
<button
v-if="!showRemoveButton && isAuthenticated && isAdding"
disabled
class="btn btn-secondary text-xs sm:text-sm opacity-50 cursor-not-allowed"
>
<span class="inline-block animate-spin mr-1"></span>
Añadiendo...
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref, onMounted, onUnmounted } from 'vue';
import { HeartIcon } from '@heroicons/vue/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
import authService from '../services/auth';
import api from '../services/api';
const props = defineProps({
article: {
type: Object,
required: true,
},
showRemoveButton: {
type: Boolean,
default: false,
},
isFavorite: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['remove', 'added']);
const isAdding = ref(false);
const isAuthenticated = ref(false);
const favoriteStatus = ref(props.isFavorite);
// Verificar autenticación al montar y cuando cambie
function checkAuth() {
isAuthenticated.value = authService.hasCredentials();
}
function formatDate(timestamp) {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleString('es-ES');
}
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';
}
async function handleAddFavorite() {
if (!isAuthenticated.value || favoriteStatus.value || isAdding.value) {
return;
}
if (!props.article.platform || !props.article.id) {
alert('Error: El artículo no tiene platform o id válidos');
return;
}
isAdding.value = true;
try {
// El backend solo necesita platform e id
const favorite = {
platform: props.article.platform,
id: String(props.article.id), // Asegurar que sea string
};
await api.addFavorite(favorite);
favoriteStatus.value = true;
// Emitir evento para que el componente padre pueda actualizar si es necesario
emit('added', props.article.platform, props.article.id);
} catch (error) {
console.error('Error añadiendo a favoritos:', error);
// El interceptor de API ya maneja el error 401 mostrando el modal de login
if (error.response?.status === 404) {
alert('El artículo no se encontró en la base de datos. Asegúrate de que el artículo esté en la lista de notificados.');
} else if (error.response?.status === 400) {
alert('Error: ' + (error.response?.data?.error || 'Datos inválidos'));
} else if (error.response?.status !== 401) {
const errorMessage = error.response?.data?.error || error.message || 'Error desconocido';
alert('Error al añadir a favoritos: ' + errorMessage);
}
} finally {
isAdding.value = false;
}
}
function handleAuthChange() {
checkAuth();
}
onMounted(() => {
checkAuth();
// Escuchar cambios en la autenticación
window.addEventListener('auth-login', handleAuthChange);
window.addEventListener('auth-logout', handleAuthChange);
});
onUnmounted(() => {
window.removeEventListener('auth-login', handleAuthChange);
window.removeEventListener('auth-logout', handleAuthChange);
});
</script>

View File

@@ -74,10 +74,9 @@ export default {
},
// Artículos
async getArticles(limit = 100, offset = 0) {
const response = await api.get('/articles', {
params: { limit, offset },
});
async getArticles(limit = 100, offset = 0, additionalParams = {}) {
const params = { limit, offset, ...additionalParams };
const response = await api.get('/articles', { params });
return response.data;
},
@@ -105,6 +104,16 @@ export default {
},
// Telegram
async getTelegramConfig() {
const response = await api.get('/telegram/config');
return response.data;
},
async setTelegramConfig(config) {
const response = await api.put('/telegram/config', config);
return response.data;
},
async getTelegramThreads() {
const response = await api.get('/telegram/threads');
return response.data;
@@ -116,6 +125,12 @@ export default {
return response.data;
},
// Artículos - Borrar todos (solo admin)
async clearAllArticles() {
const response = await api.delete('/articles');
return response.data;
},
// Usuarios
async getUsers() {
const response = await api.get('/users');

View File

@@ -2,11 +2,13 @@
const AUTH_STORAGE_KEY = 'wallabicher_token';
const USERNAME_STORAGE_KEY = 'wallabicher_username';
const ROLE_STORAGE_KEY = 'wallabicher_role';
class AuthService {
constructor() {
this.token = this.loadToken();
this.username = this.loadUsername();
this.role = this.loadRole();
}
// Cargar token desde localStorage
@@ -29,13 +31,15 @@ class AuthService {
}
}
// Guardar token y username en localStorage
saveSession(token, username) {
// Guardar token, username y role en localStorage
saveSession(token, username, role = 'user') {
try {
this.token = token;
this.username = username;
this.role = role;
localStorage.setItem(AUTH_STORAGE_KEY, token);
localStorage.setItem(USERNAME_STORAGE_KEY, username);
localStorage.setItem(ROLE_STORAGE_KEY, role);
return true;
} catch (error) {
console.error('Error guardando sesión:', error);
@@ -43,13 +47,25 @@ class AuthService {
}
}
// Eliminar token y username
// Cargar role desde localStorage
loadRole() {
try {
return localStorage.getItem(ROLE_STORAGE_KEY) || 'user';
} catch (error) {
console.error('Error cargando role:', error);
return 'user';
}
}
// Eliminar token, username y role
clearSession() {
try {
this.token = '';
this.username = '';
this.role = 'user';
localStorage.removeItem(AUTH_STORAGE_KEY);
localStorage.removeItem(USERNAME_STORAGE_KEY);
localStorage.removeItem(ROLE_STORAGE_KEY);
return true;
} catch (error) {
console.error('Error eliminando sesión:', error);
@@ -67,6 +83,16 @@ class AuthService {
return this.username;
}
// Obtener role actual
getRole() {
return this.role;
}
// Verificar si es admin
isAdmin() {
return this.role === 'admin';
}
// Verificar si hay sesión activa (token guardado)
hasCredentials() {
return !!this.token;
@@ -98,8 +124,9 @@ class AuthService {
}
if (data.success && data.token) {
this.saveSession(data.token, data.username);
return { success: true, token: data.token, username: data.username };
const role = data.role || 'user';
this.saveSession(data.token, data.username, role);
return { success: true, token: data.token, username: data.username, role };
}
throw new Error('Respuesta inválida del servidor');
@@ -155,6 +182,11 @@ class AuthService {
if (response.ok) {
const data = await response.json();
if (data.success && data.authenticated) {
// Actualizar role si está disponible
if (data.role) {
this.role = data.role;
localStorage.setItem(ROLE_STORAGE_KEY, data.role);
}
return true;
}
}

View File

@@ -3,43 +3,175 @@
@tailwind utilities;
@layer base {
* {
@apply border-gray-200 dark:border-gray-700;
}
body {
@apply bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
@apply bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* Scrollbar styling */
::-webkit-scrollbar {
@apply w-2 h-2;
}
::-webkit-scrollbar-track {
@apply bg-gray-100 dark:bg-gray-800;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-300 dark:bg-gray-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-400 dark:bg-gray-500;
}
}
@layer components {
/* Metronic-style Cards */
.card {
@apply bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-lg p-6 border border-gray-200 dark:border-gray-700;
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm dark:shadow-lg p-6 border border-gray-200 dark:border-gray-700 transition-shadow duration-200;
}
.card:hover {
@apply shadow-md dark:shadow-xl;
}
.card-header {
@apply border-b border-gray-200 dark:border-gray-700 pb-4 mb-4;
}
.card-title {
@apply text-lg font-bold text-gray-900 dark:text-gray-100;
}
.card-subtitle {
@apply text-sm text-gray-500 dark:text-gray-400 mt-1;
}
/* Buttons */
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
@apply px-4 py-2.5 rounded-lg font-semibold text-sm transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-600;
@apply bg-primary-600 dark:bg-primary-500 text-white hover:bg-primary-700 dark:hover:bg-primary-600 focus:ring-primary-500 shadow-sm hover:shadow-md;
}
.btn-secondary {
@apply bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600;
@apply bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 focus:ring-gray-500;
}
.btn-danger {
@apply bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-800;
@apply bg-red-600 dark:bg-red-700 text-white hover:bg-red-700 dark:hover:bg-red-800 focus:ring-red-500 shadow-sm hover:shadow-md;
}
.btn-success {
@apply bg-green-600 dark:bg-green-700 text-white hover:bg-green-700 dark:hover:bg-green-800 focus:ring-green-500 shadow-sm hover:shadow-md;
}
.btn-sm {
@apply px-3 py-1.5 text-xs;
}
.btn-lg {
@apply px-6 py-3 text-base;
}
/* Inputs */
.input {
@apply w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400;
@apply w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 transition-all duration-200;
}
.input:focus {
@apply shadow-sm;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-md text-xs font-semibold;
}
.badge-primary {
@apply bg-primary-100 dark:bg-primary-900/50 text-primary-700 dark:text-primary-300;
}
.badge-success {
@apply bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300;
}
.badge-danger {
@apply bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300;
}
.badge-warning {
@apply bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300;
}
.badge-info {
@apply bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300;
}
/* Stats Cards */
.stat-card {
@apply card relative overflow-hidden;
}
.stat-card::before {
content: '';
@apply absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-primary-500/10 to-primary-600/10 rounded-full -mr-10 -mt-10;
}
/* Tables */
.table {
@apply w-full border-collapse;
}
.table thead {
@apply bg-gray-50 dark:bg-gray-800;
}
.table th {
@apply px-6 py-3 text-left text-xs font-bold text-gray-700 dark:text-gray-300 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700;
}
.table td {
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 border-b border-gray-200 dark:border-gray-700;
}
.table tbody tr:hover {
@apply bg-gray-50 dark:bg-gray-800/50;
}
/* Line clamp utility */
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Sidebar transitions */
.sidebar-collapsed {
transition: margin-left 0.3s ease;
}
}
@keyframes slide-in {
@@ -53,7 +185,20 @@
}
}
.animate-slide-in {
animation: slide-in 0.25s ease-out;
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
.animate-fade-in {
animation: fade-in 0.2s ease-out;
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();
}
});

View File

@@ -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 @@
>
</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);

View File

@@ -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>