add landing and subscription plans
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
8
web/dashboard/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
npm-debug.log*
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
|
||||
44
web/dashboard/CONFIGURACION_BACKEND.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Configuración del Backend
|
||||
|
||||
Este documento explica cómo configurar el frontend para conectarse a diferentes backends.
|
||||
|
||||
## Configuración mediante Variables de Entorno
|
||||
|
||||
El frontend usa la variable de entorno `VITE_API_BASE_URL` para determinar la URL del backend.
|
||||
|
||||
### Para desarrollo local (por defecto)
|
||||
|
||||
Si no defines `VITE_API_BASE_URL`, el frontend usará `/api` que será manejado por el proxy de Vite configurado en `vite.config.js` (redirige a `http://localhost:3001`).
|
||||
|
||||
### Para backend en producción
|
||||
|
||||
Crea un archivo `.env.local` en la raíz del directorio `frontend` con el siguiente contenido:
|
||||
|
||||
```bash
|
||||
VITE_API_BASE_URL=https://wb.pribyte.cloud/api
|
||||
```
|
||||
|
||||
**Nota:** Los archivos `.env.local` están en `.gitignore` y no se subirán al repositorio.
|
||||
|
||||
## Cómo usar
|
||||
|
||||
1. **Desarrollo local:**
|
||||
- No necesitas hacer nada, funciona por defecto con el proxy de Vite
|
||||
|
||||
2. **Backend en producción:**
|
||||
- Crea el archivo `.env.local` con la URL del backend
|
||||
- Reinicia el servidor de desarrollo (`npm run dev`) para que cargue las nuevas variables
|
||||
|
||||
## Ejemplo de archivo `.env.local`
|
||||
|
||||
```bash
|
||||
# Backend en producción
|
||||
VITE_API_BASE_URL=https://wb.pribyte.cloud/api
|
||||
```
|
||||
|
||||
## Notas importantes
|
||||
|
||||
- Las variables de entorno que empiezan con `VITE_` son expuestas al código del cliente
|
||||
- Después de modificar `.env.local`, necesitas reiniciar el servidor de desarrollo
|
||||
- El WebSocket también se configurará automáticamente basándose en la URL del backend
|
||||
|
||||
29
web/dashboard/Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar archivos de dependencias
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Instalar dependencias
|
||||
RUN npm ci
|
||||
|
||||
# Copiar código fuente
|
||||
COPY . .
|
||||
|
||||
# Construir aplicación
|
||||
RUN npm run build
|
||||
|
||||
# Stage de producción - servir con nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copiar archivos construidos
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copiar configuración de nginx (se puede sobrescribir con volumen)
|
||||
COPY nginx-dashboard.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
34
web/dashboard/index.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wallabicher Dashboard</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
|
||||
<!-- Apple Touch Icon (iOS) -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Android Chrome Icons -->
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/android-chrome-512x512.png" />
|
||||
|
||||
<!-- Manifest for PWA -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Theme colors -->
|
||||
<meta name="theme-color" content="#0284c7" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Wallabicher" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
24
web/dashboard/nginx-dashboard.conf
Normal file
@@ -0,0 +1,24 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
|
||||
|
||||
# SPA routing - todas las rutas del dashboard
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
49
web/dashboard/nginx.conf
Normal file
@@ -0,0 +1,49 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
|
||||
# SPA routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket proxy
|
||||
location /ws {
|
||||
proxy_pass http://backend:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
2622
web/dashboard/package-lock.json
generated
Normal file
26
web/dashboard/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "wallabicher-dashboard",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^5.0.1",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"axios": "^1.6.0",
|
||||
"chart.js": "^4.4.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-chartjs": "^5.2.0",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.4.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
7
web/dashboard/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
BIN
web/dashboard/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
web/dashboard/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
web/dashboard/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
web/dashboard/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
web/dashboard/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
web/dashboard/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
web/dashboard/public/logo.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
24
web/dashboard/public/manifest.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "Wallabicher Dashboard",
|
||||
"short_name": "Wallabicher",
|
||||
"description": "Dashboard para monitorear artículos de Wallapop y Vinted",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#0284c7",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"gcm_sender_id": "103953800507"
|
||||
}
|
||||
|
||||
100
web/dashboard/public/sw.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// Service Worker para notificaciones push
|
||||
const CACHE_NAME = 'wallabicher-v1';
|
||||
|
||||
// Instalar Service Worker
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('Service Worker instalado');
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activar Service Worker
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker activado');
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// Manejar mensajes push
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('Push recibido:', event);
|
||||
|
||||
let notificationData = {
|
||||
title: 'Wallabicher',
|
||||
body: 'Tienes nuevas notificaciones',
|
||||
icon: '/android-chrome-192x192.png',
|
||||
badge: '/android-chrome-192x192.png',
|
||||
tag: 'wallabicher-notification',
|
||||
requireInteraction: false,
|
||||
data: {}
|
||||
};
|
||||
|
||||
// Si hay datos en el push, usarlos
|
||||
if (event.data) {
|
||||
try {
|
||||
const data = event.data.json();
|
||||
if (data.title) notificationData.title = data.title;
|
||||
if (data.body) notificationData.body = data.body;
|
||||
if (data.icon) notificationData.icon = data.icon;
|
||||
if (data.image) notificationData.image = data.image;
|
||||
if (data.url) notificationData.data.url = data.url;
|
||||
if (data.platform) notificationData.data.platform = data.platform;
|
||||
if (data.price) notificationData.data.price = data.price;
|
||||
if (data.currency) notificationData.data.currency = data.currency;
|
||||
|
||||
// Usar tag único para agrupar notificaciones por artículo
|
||||
if (data.id) notificationData.tag = `article-${data.id}`;
|
||||
} catch (e) {
|
||||
// Si no es JSON, usar como texto
|
||||
notificationData.body = event.data.text();
|
||||
}
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(notificationData.title, {
|
||||
body: notificationData.body,
|
||||
icon: notificationData.icon,
|
||||
badge: notificationData.badge,
|
||||
image: notificationData.image,
|
||||
tag: notificationData.tag,
|
||||
requireInteraction: notificationData.requireInteraction,
|
||||
data: notificationData.data,
|
||||
actions: notificationData.data.url ? [
|
||||
{
|
||||
action: 'open',
|
||||
title: 'Ver artículo'
|
||||
},
|
||||
{
|
||||
action: 'close',
|
||||
title: 'Cerrar'
|
||||
}
|
||||
] : []
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Manejar clics en notificaciones
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('Notificación clickeada:', event);
|
||||
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'close') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Si hay una URL, abrirla
|
||||
if (event.notification.data.url) {
|
||||
event.waitUntil(
|
||||
clients.openWindow(event.notification.data.url)
|
||||
);
|
||||
} else {
|
||||
// Si no hay URL, abrir la app
|
||||
event.waitUntil(
|
||||
clients.openWindow('/')
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Manejar notificaciones cerradas
|
||||
self.addEventListener('notificationclose', (event) => {
|
||||
console.log('Notificación cerrada:', event);
|
||||
});
|
||||
437
web/dashboard/src/App.vue
Normal file
@@ -0,0 +1,437 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 dark:bg-gray-900" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||||
<!-- Sidebar - Solo mostrar si no estamos en login -->
|
||||
<template v-if="$route.path !== '/login'">
|
||||
<!-- 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="/dashboard" 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="/dashboard" 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 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>
|
||||
</template>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="transition-all duration-300" :class="$route.path === '/login' ? '' : (sidebarCollapsed ? 'ml-20' : 'ml-64')">
|
||||
<!-- Header - Solo mostrar si no estamos en login -->
|
||||
<header v-if="$route.path !== '/login'" 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="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" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="disablePushNotifications"
|
||||
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-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>
|
||||
|
||||
<!-- Logout -->
|
||||
<button
|
||||
@click="handleLogout()"
|
||||
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="Desconectar"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page Content -->
|
||||
<main :class="$route.path === '/login' ? '' : 'p-6 pb-20'">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Footer Fixed - Solo mostrar si no estamos en login -->
|
||||
<footer v-if="$route.path !== '/login'" class="fixed bottom-0 left-0 right-0 z-30 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 py-3 shadow-sm" :class="sidebarCollapsed ? 'ml-20' : 'ml-64'">
|
||||
<div class="px-6">
|
||||
<p class="text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
© {{ new Date().getFullYear() }} Wallabicher. Todos los derechos reservados.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Toast notifications container -->
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue';
|
||||
import {
|
||||
HomeIcon,
|
||||
DocumentTextIcon,
|
||||
HeartIcon,
|
||||
Cog6ToothIcon,
|
||||
CogIcon,
|
||||
UserGroupIcon,
|
||||
DocumentMagnifyingGlassIcon,
|
||||
ShieldExclamationIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
SunIcon,
|
||||
MoonIcon,
|
||||
BellIcon,
|
||||
BellSlashIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import pushNotificationService from './services/pushNotifications';
|
||||
import authService from './services/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from './services/api';
|
||||
import ToastContainer from './components/ToastContainer.vue';
|
||||
|
||||
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: '/subscription', name: 'Suscripción', icon: CreditCardIcon, adminOnly: false },
|
||||
{ path: '/settings', name: 'Configuración', icon: CogIcon, adminOnly: false },
|
||||
{ path: '/users', name: 'Usuarios', icon: UserGroupIcon, adminOnly: true },
|
||||
{ path: '/logs', name: 'Logs', icon: DocumentMagnifyingGlassIcon, adminOnly: true },
|
||||
{ path: '/rate-limiter', name: 'Rate Limiter', icon: ShieldExclamationIcon, adminOnly: true },
|
||||
{ path: '/sessions', name: 'Sesiones', icon: ClockIcon, adminOnly: true },
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
const wsConnected = ref(false);
|
||||
const sidebarCollapsed = ref(false);
|
||||
const darkMode = ref(false);
|
||||
const pushEnabled = ref(false);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
let ws = null;
|
||||
|
||||
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 toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value;
|
||||
if (darkMode.value) {
|
||||
document.documentElement.classList.add('dark');
|
||||
localStorage.setItem('darkMode', 'true');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
localStorage.setItem('darkMode', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function initDarkMode() {
|
||||
const saved = localStorage.getItem('darkMode');
|
||||
if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
darkMode.value = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
darkMode.value = false;
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
|
||||
async function enablePushNotifications() {
|
||||
try {
|
||||
const success = await pushNotificationService.init();
|
||||
if (success) {
|
||||
pushEnabled.value = true;
|
||||
// Mostrar notificación simple usando la API de notificaciones del navegador
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('Notificaciones activadas', {
|
||||
body: 'Ahora recibirás notificaciones push cuando haya nuevos artículos',
|
||||
icon: '/android-chrome-192x192.png',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
alert('No se pudieron activar las notificaciones push. Verifica que los permisos estén habilitados.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error activando notificaciones push:', error);
|
||||
alert('Error activando notificaciones push: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePushNotifications() {
|
||||
try {
|
||||
await pushNotificationService.unsubscribe();
|
||||
pushEnabled.value = false;
|
||||
// Mostrar notificación simple
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('Notificaciones desactivadas', {
|
||||
body: 'Ya no recibirás notificaciones push',
|
||||
icon: '/android-chrome-192x192.png',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error desactivando notificaciones push:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPushStatus() {
|
||||
const hasSubscription = await pushNotificationService.checkSubscription();
|
||||
pushEnabled.value = hasSubscription;
|
||||
}
|
||||
|
||||
function getCurrentPageTitle() {
|
||||
const currentItem = navItems.value.find(item => item.path === router.currentRoute.value.path);
|
||||
return currentItem ? currentItem.name : 'Dashboard';
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Reconectar websocket cuando cambie la autenticación (login)
|
||||
if (authService.hasCredentials()) {
|
||||
connectWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
// Cerrar conexión WebSocket antes de hacer logout
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
// Llamar al endpoint de logout e invalidar token
|
||||
await authService.logout();
|
||||
|
||||
// Redirigir a login después del logout
|
||||
router.push('/login');
|
||||
|
||||
// Disparar evento para que los componentes se actualicen
|
||||
window.dispatchEvent(new CustomEvent('auth-logout'));
|
||||
|
||||
// Mostrar mensaje informativo
|
||||
console.log('Sesión cerrada correctamente');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
initDarkMode();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
await checkPushStatus();
|
||||
|
||||
// Escuchar eventos de autenticación
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
// Si hay credenciales, validar y conectar websocket
|
||||
if (authService.hasCredentials()) {
|
||||
// Validar si el token sigue siendo válido
|
||||
const isValid = await authService.validateSession();
|
||||
if (!isValid) {
|
||||
// Si el token expiró, limpiar sesión y redirigir a login
|
||||
authService.clearSession();
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
if (router.currentRoute.value.path !== '/login') {
|
||||
router.push('/login');
|
||||
}
|
||||
} else {
|
||||
// Solo conectar websocket si el token es válido
|
||||
connectWebSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
function connectWebSocket() {
|
||||
// Cerrar conexión existente si hay una
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
|
||||
// Verificar si hay token de autenticación
|
||||
const token = authService.getToken();
|
||||
if (!token) {
|
||||
console.log('No hay token de autenticación, no se conectará WebSocket');
|
||||
wsConnected.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let wsUrl;
|
||||
|
||||
// Si hay una URL de API configurada, usarla para WebSocket también
|
||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||
if (apiBaseUrl && apiBaseUrl !== '/api') {
|
||||
// Extraer el host de la URL de la API y construir la URL del WebSocket
|
||||
try {
|
||||
const url = new URL(apiBaseUrl);
|
||||
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${url.host}/ws?token=${encodeURIComponent(token)}`;
|
||||
} catch (e) {
|
||||
// Si falla el parsing, usar la configuración por defecto
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
} else {
|
||||
// Use relative path so Vite proxy can handle it
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
wsConnected.value = true;
|
||||
console.log('WebSocket conectado');
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
wsConnected.value = false;
|
||||
|
||||
// Si el cierre fue por autenticación fallida (código 1008), no reintentar
|
||||
if (event.code === 1008) {
|
||||
console.log('WebSocket cerrado: autenticación fallida');
|
||||
// Si el token aún existe, intentar reconectar después de un delay más largo
|
||||
// para dar tiempo a que el usuario se autentique de nuevo
|
||||
if (authService.hasCredentials()) {
|
||||
setTimeout(connectWebSocket, 5000);
|
||||
}
|
||||
} else {
|
||||
// Para otros errores, reintentar después de 3 segundos si hay token
|
||||
if (authService.hasCredentials()) {
|
||||
console.log('WebSocket desconectado, reintentando...');
|
||||
setTimeout(connectWebSocket, 3000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('Error WebSocket:', error);
|
||||
wsConnected.value = false;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Los componentes individuales manejarán los mensajes (incluyendo ToastContainer)
|
||||
window.dispatchEvent(new CustomEvent('ws-message', { detail: data }));
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
308
web/dashboard/src/components/ArticleCard.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div
|
||||
class="card hover:shadow-lg transition-all duration-300"
|
||||
:class="{
|
||||
'highlight-new': isNew,
|
||||
'bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-700': isNew
|
||||
}"
|
||||
>
|
||||
<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 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
@click="goToDetail"
|
||||
>
|
||||
<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 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
@click="goToDetail"
|
||||
>
|
||||
<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 cursor-pointer hover:text-primary-600 dark:hover:text-primary-400 transition-colors"
|
||||
:title="article.title"
|
||||
@click="goToDetail"
|
||||
>
|
||||
{{ 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 { useRouter } from 'vue-router';
|
||||
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 router = useRouter();
|
||||
|
||||
const props = defineProps({
|
||||
article: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showRemoveButton: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isFavorite: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isNew: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['remove', 'added']);
|
||||
|
||||
const isAdding = ref(false);
|
||||
const isAuthenticated = ref(false);
|
||||
// Usar is_favorite directamente del artículo, con fallback a props.isFavorite
|
||||
const favoriteStatus = ref(props.article?.is_favorite ?? 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();
|
||||
// Actualizar favoriteStatus basado en el artículo (que viene del backend)
|
||||
if (props.article) {
|
||||
favoriteStatus.value = props.article.is_favorite ?? false;
|
||||
} else {
|
||||
favoriteStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goToDetail() {
|
||||
if (props.article.platform && props.article.id) {
|
||||
router.push(`/articles/${props.article.platform}/${props.article.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkAuth();
|
||||
// Inicializar favoriteStatus desde el artículo
|
||||
if (props.article) {
|
||||
favoriteStatus.value = props.article.is_favorite ?? false;
|
||||
}
|
||||
// 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>
|
||||
|
||||
<style scoped>
|
||||
@keyframes highlight-pulse {
|
||||
0% {
|
||||
background-color: rgb(239 246 255);
|
||||
box-shadow: 0 0 0 0 rgb(59 130 246 / 0.7), 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
50% {
|
||||
background-color: rgb(219 234 254);
|
||||
box-shadow: 0 0 20px 5px rgb(59 130 246 / 0.5), 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
100% {
|
||||
background-color: rgb(239 246 255);
|
||||
box-shadow: 0 0 0 0 rgb(59 130 246 / 0), 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes highlight-pulse-dark {
|
||||
0% {
|
||||
background-color: rgb(30 58 138 / 0.3);
|
||||
box-shadow: 0 0 0 0 rgb(96 165 250 / 0.7), 0 4px 6px -1px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
50% {
|
||||
background-color: rgb(30 64 175 / 0.5);
|
||||
box-shadow: 0 0 20px 5px rgb(96 165 250 / 0.5), 0 10px 15px -3px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
100% {
|
||||
background-color: rgb(30 58 138 / 0.15);
|
||||
box-shadow: 0 0 0 0 rgb(96 165 250 / 0), 0 4px 6px -1px rgb(0 0 0 / 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-new {
|
||||
animation: highlight-pulse 2s ease-out;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.dark .highlight-new {
|
||||
animation: highlight-pulse-dark 2s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
127
web/dashboard/src/components/ToastContainer.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="fixed top-20 right-6 z-50">
|
||||
<!-- Botón para cerrar todos los toasts -->
|
||||
<button
|
||||
v-if="toasts.length > 0"
|
||||
@click="clearAllToasts"
|
||||
class="mb-3 ml-auto flex items-center gap-2 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Cerrar todas las notificaciones"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4" />
|
||||
Cerrar todas ({{ toasts.length }})
|
||||
</button>
|
||||
|
||||
<transition-group
|
||||
name="toast"
|
||||
tag="div"
|
||||
class="space-y-3"
|
||||
>
|
||||
<ToastNotification
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
:toast="toast"
|
||||
@close="removeToast(toast.id)"
|
||||
/>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline';
|
||||
import ToastNotification from './ToastNotification.vue';
|
||||
|
||||
const toasts = ref([]);
|
||||
let toastIdCounter = 0;
|
||||
const toastTimeouts = new Map();
|
||||
|
||||
function addToast(article) {
|
||||
const id = ++toastIdCounter;
|
||||
toasts.value.push({
|
||||
id,
|
||||
...article,
|
||||
});
|
||||
|
||||
// Limpiar timeout anterior si existe (por si acaso)
|
||||
if (toastTimeouts.has(id)) {
|
||||
clearTimeout(toastTimeouts.get(id));
|
||||
}
|
||||
|
||||
// Auto-remover después de 5 segundos
|
||||
const timeoutId = setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, 5000);
|
||||
|
||||
toastTimeouts.set(id, timeoutId);
|
||||
}
|
||||
|
||||
function removeToast(id) {
|
||||
// Limpiar timeout si existe
|
||||
if (toastTimeouts.has(id)) {
|
||||
clearTimeout(toastTimeouts.get(id));
|
||||
toastTimeouts.delete(id);
|
||||
}
|
||||
|
||||
// Eliminar directamente - Vue transition-group manejará la animación
|
||||
const index = toasts.value.findIndex(t => t.id === id);
|
||||
if (index > -1) {
|
||||
toasts.value.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllToasts() {
|
||||
// Limpiar todos los timeouts
|
||||
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
toastTimeouts.clear();
|
||||
|
||||
// Eliminar todos los toasts
|
||||
toasts.value = [];
|
||||
}
|
||||
|
||||
// Escuchar eventos de WebSocket para nuevos artículos
|
||||
function handleWebSocketMessage(event) {
|
||||
const data = event.detail;
|
||||
|
||||
// Manejar notificaciones de artículos nuevos
|
||||
if (data.type === 'new_articles' && data.data) {
|
||||
// Mostrar toasts para cada artículo nuevo
|
||||
for (const article of data.data) {
|
||||
addToast(article);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('ws-message', handleWebSocketMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('ws-message', handleWebSocketMessage);
|
||||
|
||||
// Limpiar todos los timeouts de toasts
|
||||
toastTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
|
||||
toastTimeouts.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
80
web/dashboard/src/components/ToastNotification.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div
|
||||
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] backdrop-blur-sm cursor-pointer hover:shadow-2xl hover:scale-[1.02] transition-all duration-200"
|
||||
@click="goToDetail"
|
||||
>
|
||||
<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-12 h-12 object-cover rounded-lg"
|
||||
@error="($event) => $event.target.style.display = 'none'"
|
||||
/>
|
||||
<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-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
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-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-base font-bold text-primary-600 dark:text-primary-400 mb-2">
|
||||
{{ toast.price }} {{ toast.currency || '€' }}
|
||||
</p>
|
||||
<a
|
||||
v-if="toast.url"
|
||||
:href="toast.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click.stop
|
||||
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 artículo →
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
@click.stop="$emit('close')"
|
||||
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 z-10"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const props = defineProps({
|
||||
toast: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function goToDetail() {
|
||||
if (props.toast.platform && props.toast.id) {
|
||||
router.push(`/articles/${props.toast.platform}/${props.toast.id}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
117
web/dashboard/src/main.js
Normal file
@@ -0,0 +1,117 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import App from './App.vue';
|
||||
import Dashboard from './views/Dashboard.vue';
|
||||
import Articles from './views/Articles.vue';
|
||||
import ArticleDetail from './views/ArticleDetail.vue';
|
||||
import Favorites from './views/Favorites.vue';
|
||||
import Workers from './views/Workers.vue';
|
||||
import Users from './views/Users.vue';
|
||||
import Settings from './views/Settings.vue';
|
||||
import Subscription from './views/Subscription.vue';
|
||||
import Logs from './views/Logs.vue';
|
||||
import RateLimiter from './views/RateLimiter.vue';
|
||||
import Sessions from './views/Sessions.vue';
|
||||
import Login from './views/Login.vue';
|
||||
import './style.css';
|
||||
import authService from './services/auth';
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: Login, name: 'login' },
|
||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } }, // Redirige a /dashboard
|
||||
{ path: '/articles', component: Articles, meta: { requiresAuth: true } },
|
||||
{ path: '/articles/:platform/:id', component: ArticleDetail, meta: { requiresAuth: true } },
|
||||
{ path: '/favorites', component: Favorites, meta: { requiresAuth: true } },
|
||||
{ path: '/workers', component: Workers, meta: { requiresAuth: true } },
|
||||
{ path: '/subscription', component: Subscription, meta: { requiresAuth: true } },
|
||||
{ path: '/settings', component: Settings, meta: { requiresAuth: true } },
|
||||
{ path: '/users', component: Users, meta: { requiresAuth: true, requiresAdmin: true } },
|
||||
{ path: '/logs', component: Logs, meta: { requiresAuth: true } },
|
||||
{ path: '/rate-limiter', component: RateLimiter, meta: { requiresAuth: true } },
|
||||
{ path: '/sessions', component: Sessions, meta: { requiresAuth: true } },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/dashboard'),
|
||||
routes,
|
||||
});
|
||||
|
||||
// Guard de navegación para verificar autenticación
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// Si la ruta es /login y ya está autenticado, redirigir al dashboard
|
||||
if (to.path === '/login') {
|
||||
if (authService.hasCredentials()) {
|
||||
const isValid = await authService.validateSession();
|
||||
if (isValid) {
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Si accede a la raíz y está autenticado, redirigir al dashboard
|
||||
if (to.path === '/') {
|
||||
if (authService.hasCredentials()) {
|
||||
const isValid = await authService.validateSession();
|
||||
if (isValid) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Si no está autenticado, redirigir a login
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Para todas las demás rutas, verificar autenticación
|
||||
if (to.meta.requiresAuth) {
|
||||
// Verificar si hay token almacenado
|
||||
if (!authService.hasCredentials()) {
|
||||
// No hay token, redirigir a login
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hay token, validar si sigue siendo válido
|
||||
const isValid = await authService.validateSession();
|
||||
if (!isValid) {
|
||||
// Token inválido o expirado, redirigir a login
|
||||
next('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar si requiere permisos de administrador
|
||||
if (to.meta.requiresAdmin) {
|
||||
const isAdmin = authService.isAdmin();
|
||||
if (!isAdmin) {
|
||||
// No es admin, redirigir a dashboard (raíz relativa)
|
||||
next('/');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Continuar la navegación
|
||||
next();
|
||||
});
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
|
||||
// Registrar Service Worker automáticamente al cargar la app
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/'
|
||||
});
|
||||
console.log('Service Worker registrado:', registration.scope);
|
||||
} catch (error) {
|
||||
console.error('Error registrando Service Worker:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
197
web/dashboard/src/services/api.js
Normal file
@@ -0,0 +1,197 @@
|
||||
import axios from 'axios';
|
||||
import authService from './auth';
|
||||
|
||||
// Usar variable de entorno si está disponible, sino usar '/api' (proxy en desarrollo)
|
||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api';
|
||||
|
||||
console.log('baseURL', baseURL);
|
||||
|
||||
const api = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Interceptor para añadir autenticación a las peticiones
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const authHeader = authService.getAuthHeader();
|
||||
if (authHeader) {
|
||||
config.headers.Authorization = authHeader;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Interceptor para manejar errores 401 (no autenticado)
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Disparar evento personalizado para mostrar diálogo de login
|
||||
window.dispatchEvent(new CustomEvent('auth-required', {
|
||||
detail: {
|
||||
message: 'Se requiere autenticación para esta acción',
|
||||
config: error.config
|
||||
}
|
||||
}));
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default {
|
||||
// Estadísticas
|
||||
async getStats() {
|
||||
const response = await api.get('/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Workers
|
||||
async getWorkers() {
|
||||
const response = await api.get('/workers');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateWorkers(workers) {
|
||||
const response = await api.put('/workers', workers);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Favoritos
|
||||
async getFavorites() {
|
||||
const response = await api.get('/favorites');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async addFavorite(favorite) {
|
||||
const response = await api.post('/favorites', favorite);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async removeFavorite(platform, id) {
|
||||
const response = await api.delete(`/favorites/${platform}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Artículos
|
||||
async getArticles(limit = 100, offset = 0, additionalParams = {}) {
|
||||
const params = { limit, offset, ...additionalParams };
|
||||
const response = await api.get('/articles', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getArticleFacets() {
|
||||
const response = await api.get('/articles/facets');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async searchArticles(query, mode = 'AND') {
|
||||
const response = await api.get('/articles/search', {
|
||||
params: { q: query, mode: mode },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getArticle(platform, id) {
|
||||
const response = await api.get(`/articles/${platform}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Logs
|
||||
async getLogs(limit = 500, sinceLine = null) {
|
||||
const params = { limit };
|
||||
if (sinceLine !== null && sinceLine > 0) {
|
||||
params.since = sinceLine;
|
||||
}
|
||||
const response = await api.get('/logs', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Configuración
|
||||
async getConfig() {
|
||||
const response = await api.get('/config');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 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;
|
||||
},
|
||||
|
||||
// Usuarios
|
||||
async getUsers() {
|
||||
const response = await api.get('/users');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createUser(userData) {
|
||||
const response = await api.post('/users', userData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteUser(username) {
|
||||
const response = await api.delete(`/users/${username}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async changePassword(passwordData) {
|
||||
const response = await api.post('/users/change-password', passwordData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin - Rate Limiter
|
||||
async getRateLimiterInfo() {
|
||||
const response = await api.get('/admin/rate-limiter');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin - Sessions
|
||||
async getSessions() {
|
||||
const response = await api.get('/admin/sessions');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteSession(token) {
|
||||
const response = await api.delete(`/admin/sessions/${token}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Suscripciones
|
||||
async getSubscriptionPlans() {
|
||||
const response = await api.get('/subscription/plans');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getMySubscription() {
|
||||
const response = await api.get('/subscription/me');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateSubscription(subscriptionData) {
|
||||
const response = await api.put('/subscription/me', subscriptionData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin: Actualizar suscripción de otro usuario
|
||||
async updateUserSubscription(username, subscriptionData) {
|
||||
const response = await api.put(`/subscription/${username}`, subscriptionData);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
230
web/dashboard/src/services/auth.js
Normal file
@@ -0,0 +1,230 @@
|
||||
// Servicio de autenticación para gestionar tokens
|
||||
import { getDeviceFingerprint } from './fingerprint.js';
|
||||
|
||||
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
|
||||
loadToken() {
|
||||
try {
|
||||
return localStorage.getItem(AUTH_STORAGE_KEY) || '';
|
||||
} catch (error) {
|
||||
console.error('Error cargando token:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Cargar username desde localStorage
|
||||
loadUsername() {
|
||||
try {
|
||||
return localStorage.getItem(USERNAME_STORAGE_KEY) || '';
|
||||
} catch (error) {
|
||||
console.error('Error cargando username:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Obtener token actual
|
||||
getToken() {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
// Obtener username actual
|
||||
getUsername() {
|
||||
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;
|
||||
}
|
||||
|
||||
// Generar header de autenticación Bearer
|
||||
getAuthHeader() {
|
||||
if (!this.token) {
|
||||
return null;
|
||||
}
|
||||
return `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
// Hacer login (llamar al endpoint de login)
|
||||
async login(username, password) {
|
||||
try {
|
||||
// Obtener fingerprint del dispositivo
|
||||
let fingerprintData = null;
|
||||
try {
|
||||
fingerprintData = await getDeviceFingerprint();
|
||||
} catch (error) {
|
||||
console.warn('Error obteniendo fingerprint, continuando sin él:', error);
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
username,
|
||||
password,
|
||||
};
|
||||
|
||||
// Agregar fingerprint si está disponible
|
||||
if (fingerprintData) {
|
||||
requestBody.fingerprint = fingerprintData.fingerprint;
|
||||
requestBody.deviceInfo = fingerprintData.deviceInfo;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Error en login');
|
||||
}
|
||||
|
||||
if (data.success && data.token) {
|
||||
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');
|
||||
} catch (error) {
|
||||
console.error('Error en login:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Hacer logout (llamar al endpoint de logout)
|
||||
async logout() {
|
||||
try {
|
||||
const token = this.token;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
await fetch('/api/users/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Si falla el logout en el servidor, aún así limpiar localmente
|
||||
console.error('Error al cerrar sesión en el servidor:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.clearSession();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error en logout:', error);
|
||||
this.clearSession(); // Limpiar localmente de todas formas
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar si el token sigue siendo válido
|
||||
async validateSession() {
|
||||
if (!this.token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users/me', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.token}`,
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Si el token es inválido, limpiar sesión
|
||||
if (response.status === 401) {
|
||||
this.clearSession();
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Error validando sesión:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exportar instancia singleton
|
||||
const authService = new AuthService();
|
||||
export default authService;
|
||||
|
||||
154
web/dashboard/src/services/fingerprint.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import FingerprintJS from '@fingerprintjs/fingerprintjs';
|
||||
|
||||
let fpPromise = null;
|
||||
let cachedFingerprint = null;
|
||||
let cachedDeviceInfo = null;
|
||||
|
||||
/**
|
||||
* Inicializa FingerprintJS (solo una vez)
|
||||
*/
|
||||
function initFingerprintJS() {
|
||||
if (!fpPromise) {
|
||||
fpPromise = FingerprintJS.load();
|
||||
}
|
||||
return fpPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtiene el fingerprint del dispositivo
|
||||
* @returns {Promise<{fingerprint: string, deviceInfo: Object}>}
|
||||
*/
|
||||
export async function getDeviceFingerprint() {
|
||||
// Si ya tenemos el fingerprint en caché, devolverlo
|
||||
if (cachedFingerprint && cachedDeviceInfo) {
|
||||
return {
|
||||
fingerprint: cachedFingerprint,
|
||||
deviceInfo: cachedDeviceInfo,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const fp = await initFingerprintJS();
|
||||
const result = await fp.get();
|
||||
|
||||
// Extraer información del dispositivo desde los componentes
|
||||
const deviceInfo = extractDeviceInfo(result.components);
|
||||
|
||||
cachedFingerprint = result.visitorId;
|
||||
cachedDeviceInfo = deviceInfo;
|
||||
|
||||
return {
|
||||
fingerprint: result.visitorId,
|
||||
deviceInfo: deviceInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error obteniendo fingerprint:', error);
|
||||
// Fallback: generar un fingerprint básico
|
||||
return {
|
||||
fingerprint: generateFallbackFingerprint(),
|
||||
deviceInfo: {
|
||||
browser: navigator.userAgent.includes('Chrome') ? 'Chrome' :
|
||||
navigator.userAgent.includes('Firefox') ? 'Firefox' :
|
||||
navigator.userAgent.includes('Safari') ? 'Safari' : 'Unknown',
|
||||
os: navigator.platform,
|
||||
device: 'Unknown',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae información legible del dispositivo desde los componentes de FingerprintJS
|
||||
* @param {Object} components - Componentes de FingerprintJS
|
||||
* @returns {Object} Información del dispositivo
|
||||
*/
|
||||
function extractDeviceInfo(components) {
|
||||
const info = {
|
||||
browser: 'Unknown',
|
||||
browserVersion: '',
|
||||
os: 'Unknown',
|
||||
osVersion: '',
|
||||
device: 'Unknown',
|
||||
screenResolution: '',
|
||||
timezone: '',
|
||||
language: navigator.language || '',
|
||||
};
|
||||
|
||||
// Información del navegador
|
||||
if (components.browserName) {
|
||||
info.browser = components.browserName.value || 'Unknown';
|
||||
}
|
||||
if (components.browserVersion) {
|
||||
info.browserVersion = components.browserVersion.value || '';
|
||||
}
|
||||
|
||||
// Información del sistema operativo
|
||||
if (components.os) {
|
||||
info.os = components.os.value || 'Unknown';
|
||||
}
|
||||
if (components.osVersion) {
|
||||
info.osVersion = components.osVersion.value || '';
|
||||
}
|
||||
|
||||
// Información del dispositivo
|
||||
if (components.deviceMemory) {
|
||||
info.device = components.deviceMemory.value ? 'Desktop' : 'Mobile';
|
||||
}
|
||||
if (components.platform) {
|
||||
const platform = components.platform.value?.toLowerCase() || '';
|
||||
if (platform.includes('mobile') || platform.includes('android') || platform.includes('iphone')) {
|
||||
info.device = 'Mobile';
|
||||
} else if (platform.includes('tablet') || platform.includes('ipad')) {
|
||||
info.device = 'Tablet';
|
||||
} else {
|
||||
info.device = 'Desktop';
|
||||
}
|
||||
}
|
||||
|
||||
// Resolución de pantalla
|
||||
if (components.screenResolution) {
|
||||
const res = components.screenResolution.value;
|
||||
if (res && res.length >= 2) {
|
||||
info.screenResolution = `${res[0]}x${res[1]}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Zona horaria
|
||||
if (components.timezone) {
|
||||
info.timezone = components.timezone.value || '';
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un fingerprint básico como fallback
|
||||
* @returns {string} Hash del fingerprint
|
||||
*/
|
||||
function generateFallbackFingerprint() {
|
||||
const data = [
|
||||
navigator.userAgent,
|
||||
navigator.language,
|
||||
navigator.platform,
|
||||
screen.width + 'x' + screen.height,
|
||||
new Date().getTimezoneOffset(),
|
||||
].join('|');
|
||||
|
||||
// Simple hash (no usar en producción, solo como fallback)
|
||||
let hash = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el caché del fingerprint (útil para testing)
|
||||
*/
|
||||
export function clearFingerprintCache() {
|
||||
cachedFingerprint = null;
|
||||
cachedDeviceInfo = null;
|
||||
}
|
||||
|
||||
195
web/dashboard/src/services/pushNotifications.js
Normal file
@@ -0,0 +1,195 @@
|
||||
// Servicio para manejar notificaciones push
|
||||
class PushNotificationService {
|
||||
constructor() {
|
||||
this.registration = null;
|
||||
this.subscription = null;
|
||||
}
|
||||
|
||||
// Registrar Service Worker
|
||||
async registerServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js', {
|
||||
scope: '/'
|
||||
});
|
||||
this.registration = registration;
|
||||
console.log('Service Worker registrado:', registration.scope);
|
||||
return registration;
|
||||
} catch (error) {
|
||||
console.error('Error registrando Service Worker:', error);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
console.warn('Service Workers no están soportados en este navegador');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Solicitar permisos de notificación
|
||||
async requestPermission() {
|
||||
if (!('Notification' in window)) {
|
||||
console.warn('Este navegador no soporta notificaciones');
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
if (Notification.permission === 'denied') {
|
||||
return 'denied';
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission;
|
||||
} catch (error) {
|
||||
console.error('Error solicitando permiso:', error);
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
// Suscribirse a notificaciones push
|
||||
async subscribe() {
|
||||
if (!this.registration) {
|
||||
await this.registerServiceWorker();
|
||||
}
|
||||
|
||||
if (!this.registration) {
|
||||
throw new Error('No se pudo registrar el Service Worker');
|
||||
}
|
||||
|
||||
try {
|
||||
// Verificar si ya existe una suscripción
|
||||
this.subscription = await this.registration.pushManager.getSubscription();
|
||||
|
||||
if (this.subscription) {
|
||||
console.log('Ya existe una suscripción push');
|
||||
return this.subscription;
|
||||
}
|
||||
|
||||
// Obtener la clave pública del servidor
|
||||
const response = await fetch('/api/push/public-key');
|
||||
const { publicKey } = await response.json();
|
||||
|
||||
// Convertir la clave pública a formato ArrayBuffer
|
||||
const applicationServerKey = this.urlBase64ToUint8Array(publicKey);
|
||||
|
||||
// Crear nueva suscripción
|
||||
this.subscription = await this.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey
|
||||
});
|
||||
|
||||
console.log('Suscripción push creada:', this.subscription);
|
||||
|
||||
// Enviar la suscripción al servidor
|
||||
await this.sendSubscriptionToServer(this.subscription);
|
||||
|
||||
return this.subscription;
|
||||
} catch (error) {
|
||||
console.error('Error suscribiéndose a push:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Enviar suscripción al servidor
|
||||
async sendSubscriptionToServer(subscription) {
|
||||
try {
|
||||
const response = await fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(subscription),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Error enviando suscripción al servidor');
|
||||
}
|
||||
|
||||
console.log('Suscripción enviada al servidor');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error enviando suscripción:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelar suscripción
|
||||
async unsubscribe() {
|
||||
if (this.subscription) {
|
||||
try {
|
||||
await this.subscription.unsubscribe();
|
||||
await fetch('/api/push/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(this.subscription),
|
||||
});
|
||||
this.subscription = null;
|
||||
console.log('Suscripción cancelada');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error cancelando suscripción:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verificar estado de suscripción
|
||||
async checkSubscription() {
|
||||
if (!this.registration) {
|
||||
await this.registerServiceWorker();
|
||||
}
|
||||
|
||||
if (!this.registration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.subscription = await this.registration.pushManager.getSubscription();
|
||||
return !!this.subscription;
|
||||
} catch (error) {
|
||||
console.error('Error verificando suscripción:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir clave pública de base64 URL a Uint8Array
|
||||
urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/\-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
// Inicializar todo el proceso
|
||||
async init() {
|
||||
const permission = await this.requestPermission();
|
||||
if (permission === 'granted') {
|
||||
await this.registerServiceWorker();
|
||||
try {
|
||||
await this.subscribe();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error inicializando notificaciones push:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Exportar instancia singleton
|
||||
export default new PushNotificationService();
|
||||
228
web/dashboard/src/style.css
Normal file
@@ -0,0 +1,228 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
body {
|
||||
@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-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.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 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 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 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.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 {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-out {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
/* Toast transitions */
|
||||
.toast-enter-active {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast-leave-active {
|
||||
animation: slide-out 0.3s ease-in;
|
||||
}
|
||||
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
476
web/dashboard/src/views/ArticleDetail.vue
Normal file
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Botón de volver -->
|
||||
<div class="mb-3">
|
||||
<button
|
||||
@click="$router.back()"
|
||||
class="btn btn-secondary flex items-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowLeftIcon class="w-4 h-4" />
|
||||
Volver
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" 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">Cargando artículo...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="card text-center py-12">
|
||||
<ExclamationTriangleIcon class="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">{{ error }}</p>
|
||||
<button @click="$router.back()" class="btn btn-secondary mt-4">Volver</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenido del artículo -->
|
||||
<div v-else-if="article" class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Columna izquierda: Carousel de imágenes -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card p-0 overflow-hidden">
|
||||
<!-- Carousel principal -->
|
||||
<div v-if="article.images && article.images.length > 0" class="relative">
|
||||
<!-- Imagen principal -->
|
||||
<div class="relative aspect-[4/3] bg-gray-100 dark:bg-gray-900 overflow-hidden">
|
||||
<img
|
||||
:src="article.images[currentImageIndex]"
|
||||
:alt="`Imagen ${currentImageIndex + 1} de ${article.title}`"
|
||||
class="w-full h-full object-contain transition-opacity duration-300"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Botones de navegación -->
|
||||
<button
|
||||
v-if="article.images.length > 1"
|
||||
@click="previousImage"
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
|
||||
:disabled="article.images.length <= 1"
|
||||
>
|
||||
<ChevronLeftIcon class="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
v-if="article.images.length > 1"
|
||||
@click="nextImage"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
|
||||
:disabled="article.images.length <= 1"
|
||||
>
|
||||
<ChevronRightIcon class="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<!-- Indicador de posición -->
|
||||
<div
|
||||
v-if="article.images.length > 1"
|
||||
class="absolute bottom-3 left-1/2 -translate-x-1/2 bg-black/50 text-white px-3 py-1 rounded-full text-xs font-medium z-10"
|
||||
>
|
||||
{{ currentImageIndex + 1 }} / {{ article.images.length }}
|
||||
</div>
|
||||
|
||||
<!-- Botón para ampliar -->
|
||||
<button
|
||||
@click="openImageModal(article.images[currentImageIndex])"
|
||||
class="absolute top-3 right-3 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full transition-all z-10"
|
||||
>
|
||||
<MagnifyingGlassPlusIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Miniaturas -->
|
||||
<div
|
||||
v-if="article.images.length > 1"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div class="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
||||
<button
|
||||
v-for="(image, index) in article.images"
|
||||
:key="index"
|
||||
@click="currentImageIndex = index"
|
||||
class="flex-shrink-0 w-20 h-20 rounded-lg overflow-hidden border-2 transition-all"
|
||||
:class="
|
||||
currentImageIndex === index
|
||||
? 'border-primary-600 dark:border-primary-400 ring-2 ring-primary-200 dark:ring-primary-800'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
|
||||
"
|
||||
>
|
||||
<img
|
||||
:src="image"
|
||||
:alt="`Miniatura ${index + 1}`"
|
||||
class="w-full h-full object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="aspect-[4/3] bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span class="text-gray-400 dark:text-gray-500">Sin imágenes disponibles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columna derecha: Información del artículo -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<!-- Header compacto -->
|
||||
<div class="card">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold rounded"
|
||||
: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">
|
||||
👤 {{ 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">
|
||||
⚙️ {{ article.worker_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-3 line-clamp-2">
|
||||
{{ article.title || 'Sin título' }}
|
||||
</h1>
|
||||
|
||||
<div v-if="article.price !== null && article.price !== undefined" class="mb-4">
|
||||
<span class="text-3xl font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ article.price }} {{ article.currency || '€' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<a
|
||||
v-if="article.url"
|
||||
:href="article.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-primary flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon class="w-4 h-4" />
|
||||
Ver anuncio original
|
||||
</a>
|
||||
<button
|
||||
v-if="isAuthenticated && !isAdding"
|
||||
@click="handleAddFavorite"
|
||||
class="btn flex items-center justify-center gap-2 text-sm"
|
||||
:class="favoriteStatus ? 'btn-secondary' : 'bg-pink-500 hover:bg-pink-600 text-white border-pink-600'"
|
||||
:disabled="favoriteStatus"
|
||||
>
|
||||
<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="isAuthenticated && isAdding"
|
||||
disabled
|
||||
class="btn btn-secondary opacity-50 cursor-not-allowed text-sm"
|
||||
>
|
||||
<span class="inline-block animate-spin mr-1">⏳</span>
|
||||
Añadiendo...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información compacta -->
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Información</h2>
|
||||
<div class="space-y-3">
|
||||
<div v-if="article.location" class="flex items-start gap-2">
|
||||
<MapPinIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Localidad</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 truncate">{{ article.location }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="article.allows_shipping !== null && article.allows_shipping !== undefined" class="flex items-start gap-2">
|
||||
<TruckIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Envío</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ article.allows_shipping ? '✅ Acepta envíos' : '❌ No acepta envíos' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="article.modified_at" class="flex items-start gap-2">
|
||||
<ClockIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Modificado</p>
|
||||
<p class="text-xs text-gray-900 dark:text-gray-100">{{ formatDateShort(article.modified_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="article.notifiedAt" class="flex items-start gap-2">
|
||||
<BellIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Notificado</p>
|
||||
<p class="text-xs text-gray-900 dark:text-gray-100">{{ formatDateShort(article.notifiedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<HashtagIcon class="w-4 h-4 text-gray-500 dark:text-gray-400 flex-shrink-0 mt-0.5" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">ID</p>
|
||||
<p class="text-xs text-gray-900 dark:text-gray-100 font-mono truncate">{{ article.id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Descripción (debajo del grid principal) -->
|
||||
<div v-if="article && article.description" class="mt-4">
|
||||
<div class="card">
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Descripción</h2>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap max-h-48 overflow-y-auto">
|
||||
{{ article.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de imagen -->
|
||||
<div
|
||||
v-if="selectedImage"
|
||||
@click="selectedImage = null"
|
||||
class="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center p-4 cursor-pointer"
|
||||
>
|
||||
<div class="relative max-w-6xl max-h-full w-full" @click.stop>
|
||||
<button
|
||||
@click="selectedImage = null"
|
||||
class="absolute -top-12 right-0 text-white hover:text-gray-300 transition-colors z-10"
|
||||
>
|
||||
<XMarkIcon class="w-8 h-8" />
|
||||
</button>
|
||||
<img
|
||||
:src="selectedImage"
|
||||
alt="Imagen ampliada"
|
||||
class="max-w-full max-h-[90vh] object-contain rounded-lg mx-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
HeartIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
MapPinIcon,
|
||||
TruckIcon,
|
||||
ClockIcon,
|
||||
BellIcon,
|
||||
HashtagIcon,
|
||||
XMarkIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
import { HeartIcon as HeartIconSolid } from '@heroicons/vue/24/solid';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const article = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const selectedImage = ref(null);
|
||||
const currentImageIndex = ref(0);
|
||||
const isAuthenticated = ref(false);
|
||||
const favoriteStatus = ref(false);
|
||||
const isAdding = ref(false);
|
||||
|
||||
function formatDateShort(timestamp) {
|
||||
if (!timestamp) return 'N/A';
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleDateString('es-ES', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
if (!timestamp) return 'N/A';
|
||||
return new Date(timestamp).toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function handleImageError(event) {
|
||||
event.target.onerror = null;
|
||||
event.target.src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgdmlld0JveD0iMCAwIDEyOCAxMjgiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiBmaWxsPSIjRjNGNEY2Ii8+CjxwYXRoIGQ9Ik00OCA0OEg4ME04MCA4MEg0OE00OCA0OEw2NCA2NEw4MCA0OE00OCA4MEw2NCA2NE04MCA4MEw2NCA2NEw0OCA4MCIgc3Ryb2tlPSIjOUI5Q0E0IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K';
|
||||
}
|
||||
|
||||
function openImageModal(image) {
|
||||
selectedImage.value = image;
|
||||
}
|
||||
|
||||
function nextImage() {
|
||||
if (article.value?.images && article.value.images.length > 0) {
|
||||
currentImageIndex.value = (currentImageIndex.value + 1) % article.value.images.length;
|
||||
}
|
||||
}
|
||||
|
||||
function previousImage() {
|
||||
if (article.value?.images && article.value.images.length > 0) {
|
||||
currentImageIndex.value = currentImageIndex.value === 0
|
||||
? article.value.images.length - 1
|
||||
: currentImageIndex.value - 1;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (!article.value?.images || article.value.images.length <= 1) return;
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
previousImage();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
nextImage();
|
||||
} else if (event.key === 'Escape' && selectedImage.value) {
|
||||
selectedImage.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuth() {
|
||||
isAuthenticated.value = authService.hasCredentials();
|
||||
}
|
||||
|
||||
async function handleAddFavorite() {
|
||||
if (!isAuthenticated.value || favoriteStatus.value || isAdding.value || !article.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const favorite = {
|
||||
platform: article.value.platform,
|
||||
id: String(article.value.id),
|
||||
};
|
||||
|
||||
await api.addFavorite(favorite);
|
||||
favoriteStatus.value = true;
|
||||
} catch (error) {
|
||||
console.error('Error añadiendo a favoritos:', error);
|
||||
if (error.response?.status === 404) {
|
||||
alert('El artículo no se encontró en la base de datos.');
|
||||
} 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();
|
||||
// Actualizar favoriteStatus basado en el artículo (que viene del backend)
|
||||
if (article.value) {
|
||||
favoriteStatus.value = article.value.is_favorite ?? false;
|
||||
} else {
|
||||
favoriteStatus.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadArticle() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const platform = route.params.platform;
|
||||
const id = route.params.id;
|
||||
|
||||
if (!platform || !id) {
|
||||
error.value = 'Parámetros inválidos';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
article.value = await api.getArticle(platform, id);
|
||||
|
||||
if (!article.value) {
|
||||
error.value = 'Artículo no encontrado';
|
||||
} else {
|
||||
currentImageIndex.value = 0;
|
||||
checkAuth();
|
||||
// Actualizar favoriteStatus desde el artículo (que viene del backend con is_favorite)
|
||||
favoriteStatus.value = article.value.is_favorite ?? false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error cargando artículo:', err);
|
||||
if (err.response?.status === 404) {
|
||||
error.value = 'Artículo no encontrado';
|
||||
} else if (err.response?.status === 403) {
|
||||
error.value = 'No tienes permiso para ver este artículo';
|
||||
} else {
|
||||
error.value = 'Error al cargar el artículo: ' + (err.message || 'Error desconocido');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadArticle();
|
||||
checkAuth();
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200 inline-flex items-center justify-center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 hover:bg-primary-700 text-white border border-primary-600;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
545
web/dashboard/src/views/Articles.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<!-- Búsqueda y Filtros -->
|
||||
<div class="card p-4 mb-4">
|
||||
<!-- Campo de búsqueda -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<MagnifyingGlassIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
Búsqueda
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Buscar artículos por título, descripción, precio, localidad..."
|
||||
class="input pr-10 pl-10"
|
||||
@input="searchQuery = $event.target.value"
|
||||
/>
|
||||
<MagnifyingGlassIcon class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<span v-if="searching" class="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
|
||||
</span>
|
||||
<button
|
||||
v-else-if="searchQuery"
|
||||
@click="searchQuery = ''"
|
||||
class="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
title="Limpiar búsqueda"
|
||||
>
|
||||
<XMarkIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap">Modo:</label>
|
||||
<select
|
||||
v-model="searchMode"
|
||||
class="input text-sm w-24"
|
||||
title="AND: todas las palabras deben estar presentes | OR: al menos una palabra debe estar presente"
|
||||
>
|
||||
<option value="AND">AND</option>
|
||||
<option value="OR">OR</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span v-if="searchMode === 'AND'">Todas las palabras deben estar presentes</span>
|
||||
<span v-else>Al menos una palabra debe estar presente</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Separador -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mb-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<FunnelIcon class="w-5 h-5 text-primary-600" />
|
||||
Filtros
|
||||
<span v-if="hasActiveFilters" class="ml-2 px-2 py-0.5 bg-primary-600 text-white rounded-full text-xs font-semibold">
|
||||
{{ activeFiltersCount }}
|
||||
</span>
|
||||
</h2>
|
||||
<button
|
||||
v-if="hasActiveFilters"
|
||||
@click="clearAllFilters"
|
||||
class="btn btn-secondary text-sm"
|
||||
>
|
||||
<XMarkIcon class="w-4 h-4 mr-1" />
|
||||
Limpiar todo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<ServerIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
Plataforma
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedPlatform"
|
||||
@change="loadArticles"
|
||||
class="input text-sm w-full"
|
||||
>
|
||||
<option value="">Todas las plataformas</option>
|
||||
<option v-for="platform in facets.platforms" :key="platform" :value="platform">
|
||||
{{ platform === 'wallapop' ? 'Wallapop' : platform === 'vinted' ? 'Vinted' : platform }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<UserIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
Usuario
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedUsername"
|
||||
@change="loadArticles"
|
||||
class="input text-sm w-full"
|
||||
>
|
||||
<option value="">Todos los usuarios</option>
|
||||
<option v-for="username in availableUsernames" :key="username" :value="username">
|
||||
{{ username }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
|
||||
<BriefcaseIcon class="w-4 h-4 text-gray-500 dark:text-gray-400" />
|
||||
Worker
|
||||
</label>
|
||||
<select
|
||||
v-model="selectedWorker"
|
||||
@change="loadArticles"
|
||||
class="input text-sm w-full"
|
||||
>
|
||||
<option value="">Todos los workers</option>
|
||||
<option v-for="worker in availableWorkers" :key="worker" :value="worker">
|
||||
{{ worker }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<button @click="loadArticles" class="btn btn-primary whitespace-nowrap">
|
||||
<ArrowPathIcon class="w-4 h-4 mr-1" />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros activos (chips) -->
|
||||
<div v-if="hasActiveFilters" class="flex flex-wrap gap-2 mt-3 mb-4">
|
||||
<span
|
||||
v-if="selectedPlatform"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-sm font-medium"
|
||||
>
|
||||
<ServerIcon class="w-3.5 h-3.5" />
|
||||
{{ selectedPlatform === 'wallapop' ? 'Wallapop' : 'Vinted' }}
|
||||
<button
|
||||
@click="selectedPlatform = ''; loadArticles()"
|
||||
class="ml-1 hover:text-primary-900 dark:hover:text-primary-100"
|
||||
title="Eliminar filtro"
|
||||
>
|
||||
<XMarkIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedUsername"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-sm font-medium"
|
||||
>
|
||||
<UserIcon class="w-3.5 h-3.5" />
|
||||
{{ selectedUsername }}
|
||||
<button
|
||||
@click="selectedUsername = ''; loadArticles()"
|
||||
class="ml-1 hover:text-primary-900 dark:hover:text-primary-100"
|
||||
title="Eliminar filtro"
|
||||
>
|
||||
<XMarkIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="selectedWorker"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-sm font-medium"
|
||||
>
|
||||
<BriefcaseIcon class="w-3.5 h-3.5" />
|
||||
{{ selectedWorker }}
|
||||
<button
|
||||
@click="selectedWorker = ''; loadArticles()"
|
||||
class="ml-1 hover:text-primary-900 dark:hover:text-primary-100"
|
||||
title="Eliminar filtro"
|
||||
>
|
||||
<XMarkIcon class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="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">Cargando artículos...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredArticles.length === 0 && !searchQuery" class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400">No hay artículos para mostrar</p>
|
||||
</div>
|
||||
|
||||
<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...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredArticles.length === 0 && searchQuery && !searching" class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400">No se encontraron artículos que coincidan con "{{ searchQuery }}"</p>
|
||||
<button @click="searchQuery = ''" class="btn btn-secondary mt-4">Limpiar búsqueda</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<ArticleCard
|
||||
v-for="article in filteredArticles"
|
||||
:key="`${article.platform}-${article.id}`"
|
||||
:article="article"
|
||||
:is-new="newArticleIds.has(`${article.platform}-${article.id}`)"
|
||||
/>
|
||||
|
||||
<div v-if="!searchQuery" class="flex justify-center space-x-2 mt-6">
|
||||
<button
|
||||
@click="loadMore"
|
||||
:disabled="allArticles.length >= total"
|
||||
class="btn btn-secondary"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': allArticles.length >= total }"
|
||||
>
|
||||
Cargar más
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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
|
||||
<span class="block sm:inline sm:ml-2 text-xs text-primary-600">(de {{ total }} artículos totales)</span>
|
||||
</span>
|
||||
<span v-else>
|
||||
Mostrando {{ filteredArticles.length }} de {{ total }} artículos
|
||||
<span class="block sm:inline sm:ml-2 text-xs text-gray-400">(Actualización automática cada 30s)</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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';
|
||||
import {
|
||||
FunnelIcon,
|
||||
XMarkIcon,
|
||||
ServerIcon,
|
||||
UserIcon,
|
||||
BriefcaseIcon,
|
||||
ArrowPathIcon,
|
||||
MagnifyingGlassIcon
|
||||
} from '@heroicons/vue/24/outline';
|
||||
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
|
||||
const allArticles = ref([]);
|
||||
const searchResults = ref([]);
|
||||
const loading = ref(true);
|
||||
const searching = ref(false);
|
||||
const total = ref(0);
|
||||
const offset = ref(0);
|
||||
const limit = 50;
|
||||
const selectedPlatform = ref('');
|
||||
const selectedUsername = ref('');
|
||||
const selectedWorker = ref('');
|
||||
const searchQuery = ref('');
|
||||
const searchMode = ref('AND'); // 'AND' o 'OR'
|
||||
const autoPollInterval = ref(null);
|
||||
const searchTimeout = ref(null);
|
||||
const POLL_INTERVAL = 30000; // 30 segundos
|
||||
const SEARCH_DEBOUNCE = 500; // 500ms de debounce para búsqueda
|
||||
|
||||
// Rastreo de artículos nuevos para efectos visuales
|
||||
const existingArticleIds = ref(new Set());
|
||||
const newArticleIds = ref(new Set());
|
||||
|
||||
// Facets obtenidos del backend
|
||||
const facets = ref({
|
||||
platforms: [],
|
||||
usernames: [],
|
||||
workers: []
|
||||
});
|
||||
|
||||
// Usar facets del backend en lugar de calcular desde artículos cargados
|
||||
const availableUsernames = computed(() => {
|
||||
return facets.value.usernames || [];
|
||||
});
|
||||
|
||||
const availableWorkers = computed(() => {
|
||||
return facets.value.workers || [];
|
||||
});
|
||||
|
||||
// Artículos que se muestran (búsqueda o lista normal)
|
||||
const filteredArticles = computed(() => {
|
||||
if (searchQuery.value.trim()) {
|
||||
return searchResults.value;
|
||||
}
|
||||
return allArticles.value;
|
||||
});
|
||||
|
||||
// Computed para filtros activos
|
||||
const hasActiveFilters = computed(() => {
|
||||
return !!(selectedPlatform.value || selectedUsername.value || selectedWorker.value);
|
||||
});
|
||||
|
||||
const activeFiltersCount = computed(() => {
|
||||
let count = 0;
|
||||
if (selectedPlatform.value) count++;
|
||||
if (selectedUsername.value) count++;
|
||||
if (selectedWorker.value) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
function clearAllFilters() {
|
||||
selectedPlatform.value = '';
|
||||
selectedUsername.value = '';
|
||||
selectedWorker.value = '';
|
||||
loadArticles();
|
||||
}
|
||||
|
||||
async function loadFacets() {
|
||||
try {
|
||||
const data = await api.getArticleFacets();
|
||||
facets.value = {
|
||||
platforms: data.platforms || [],
|
||||
usernames: data.usernames || [],
|
||||
workers: data.workers || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error cargando facets:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function loadArticles(reset = true, silent = false) {
|
||||
// Guardar IDs existentes antes de cargar (para detectar artículos nuevos en polling)
|
||||
let previousIds = new Set();
|
||||
if (silent && reset) {
|
||||
// Para polling automático, guardar los IDs de los artículos actuales antes de resetear
|
||||
allArticles.value.forEach(article => {
|
||||
previousIds.add(`${article.platform}-${article.id}`);
|
||||
});
|
||||
} else if (!reset) {
|
||||
// Para cargar más, usar los IDs ya existentes
|
||||
previousIds = new Set(existingArticleIds.value);
|
||||
}
|
||||
|
||||
if (reset) {
|
||||
offset.value = 0;
|
||||
if (!silent) {
|
||||
// Solo limpiar si no es polling silencioso
|
||||
newArticleIds.value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Si es polling silencioso, detectar artículos nuevos comparando con los anteriores
|
||||
if (silent && reset && previousIds.size > 0) {
|
||||
newArticleIds.value.clear(); // Limpiar IDs anteriores
|
||||
filtered.forEach(article => {
|
||||
const articleId = `${article.platform}-${article.id}`;
|
||||
if (!previousIds.has(articleId)) {
|
||||
newArticleIds.value.add(articleId);
|
||||
}
|
||||
});
|
||||
// Limpiar IDs de artículos nuevos después de 5 segundos
|
||||
if (newArticleIds.value.size > 0) {
|
||||
setTimeout(() => {
|
||||
newArticleIds.value.clear();
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar el Set de IDs existentes
|
||||
if (reset) {
|
||||
existingArticleIds.value.clear();
|
||||
}
|
||||
filtered.forEach(article => {
|
||||
existingArticleIds.value.add(`${article.platform}-${article.id}`);
|
||||
});
|
||||
|
||||
if (reset) {
|
||||
allArticles.value = filtered;
|
||||
offset.value = limit;
|
||||
} else {
|
||||
allArticles.value.push(...filtered);
|
||||
offset.value += limit;
|
||||
}
|
||||
|
||||
total.value = data.total;
|
||||
} catch (error) {
|
||||
console.error('Error cargando artículos:', error);
|
||||
} finally {
|
||||
if (!silent) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
if (currentUser.value) {
|
||||
loadFacets(); // Recargar facets cuando cambie el usuario
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
loadArticles(false);
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'articles_updated') {
|
||||
loadFacets(); // Actualizar facets cuando se actualicen los artículos
|
||||
loadArticles();
|
||||
}
|
||||
}
|
||||
|
||||
async function searchArticles(query) {
|
||||
if (!query.trim()) {
|
||||
searchResults.value = [];
|
||||
searching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
searching.value = true;
|
||||
|
||||
try {
|
||||
const data = await api.searchArticles(query, searchMode.value);
|
||||
|
||||
let filtered = data.articles || [];
|
||||
|
||||
// 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) {
|
||||
console.error('Error buscando artículos:', error);
|
||||
searchResults.value = [];
|
||||
} finally {
|
||||
searching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Watch para buscar cuando cambie searchQuery o searchMode (con debounce)
|
||||
watch([searchQuery, searchMode], ([newQuery]) => {
|
||||
// Limpiar timeout anterior
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
// Si está vacío, limpiar resultados
|
||||
if (!newQuery.trim()) {
|
||||
searchResults.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Añadir debounce antes de buscar
|
||||
searchTimeout.value = setTimeout(() => {
|
||||
searchArticles(newQuery);
|
||||
}, SEARCH_DEBOUNCE);
|
||||
});
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
// Si no es admin, no permitir filtrar por username
|
||||
if (!isAdmin.value && selectedUsername.value) {
|
||||
selectedUsername.value = '';
|
||||
}
|
||||
loadFacets(); // Cargar facets primero
|
||||
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(() => {
|
||||
loadArticles(true, true); // Reset silencioso cada 30 segundos
|
||||
loadFacets(); // Actualizar facets también
|
||||
}, POLL_INTERVAL);
|
||||
});
|
||||
|
||||
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) {
|
||||
clearInterval(autoPollInterval.value);
|
||||
autoPollInterval.value = null;
|
||||
}
|
||||
|
||||
// Limpiar el timeout de búsqueda
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
searchTimeout.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
303
web/dashboard/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Charts and Quick Actions -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Platform Distribution -->
|
||||
<div class="card">
|
||||
<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 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-3 overflow-hidden">
|
||||
<div
|
||||
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 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-3 overflow-hidden">
|
||||
<div
|
||||
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)}%`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card">
|
||||
<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-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"
|
||||
>
|
||||
<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-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"
|
||||
>
|
||||
<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-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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import {
|
||||
Cog6ToothIcon,
|
||||
HeartIcon,
|
||||
DocumentTextIcon,
|
||||
ChartBarIcon,
|
||||
ArrowRightIcon,
|
||||
} from '@heroicons/vue/24/outline';
|
||||
|
||||
const stats = ref({
|
||||
totalWorkers: 0,
|
||||
activeWorkers: 0,
|
||||
totalFavorites: 0,
|
||||
totalNotified: 0,
|
||||
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);
|
||||
}
|
||||
|
||||
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 handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
if (currentUser.value) {
|
||||
loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'workers_updated' || data.type === 'favorites_updated') {
|
||||
loadStats();
|
||||
}
|
||||
}
|
||||
|
||||
let interval = null;
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadStats();
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
window.addEventListener('auth-logout', handleAuthChange);
|
||||
window.addEventListener('auth-login', handleAuthChange);
|
||||
interval = setInterval(loadStats, 10000); // Actualizar cada 10 segundos
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
window.removeEventListener('auth-logout', handleAuthChange);
|
||||
window.removeEventListener('auth-login', handleAuthChange);
|
||||
});
|
||||
</script>
|
||||
|
||||
133
web/dashboard/src/views/Favorites.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="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">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>
|
||||
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||
Los artículos que marques como favoritos aparecerán aquí
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<ArticleCard
|
||||
v-for="favorite in favorites"
|
||||
:key="`${favorite.platform}-${favorite.id}`"
|
||||
:article="favorite"
|
||||
:show-remove-button="true"
|
||||
@remove="removeFavorite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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);
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFavorite(platform, id) {
|
||||
if (!confirm('¿Estás seguro de que quieres eliminar este favorito?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.removeFavorite(platform, id);
|
||||
await loadFavorites();
|
||||
} catch (error) {
|
||||
console.error('Error eliminando favorito:', error);
|
||||
alert('Error al eliminar el favorito');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'favorites_updated') {
|
||||
// Solo actualizar si es para el usuario actual
|
||||
if (data.username === currentUser.value) {
|
||||
favorites.value = data.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuthChange() {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
if (currentUser.value) {
|
||||
loadFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
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>
|
||||
|
||||
300
web/dashboard/src/views/Login.vue
Normal file
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-white dark:bg-gray-900 flex">
|
||||
<!-- Left Panel - Branding -->
|
||||
<div class="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-primary-600 via-primary-700 to-primary-800 relative overflow-hidden">
|
||||
<!-- Decorative Elements -->
|
||||
<div class="absolute inset-0 opacity-10">
|
||||
<div class="absolute top-0 left-0 w-96 h-96 bg-white rounded-full -translate-x-1/2 -translate-y-1/2 blur-3xl"></div>
|
||||
<div class="absolute bottom-0 right-0 w-96 h-96 bg-primary-300 rounded-full translate-x-1/2 translate-y-1/2 blur-3xl"></div>
|
||||
<div class="absolute top-1/2 left-1/2 w-64 h-64 bg-primary-400 rounded-full -translate-x-1/2 -translate-y-1/2 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-10 flex flex-col justify-between p-12 text-white">
|
||||
<div>
|
||||
<div class="flex items-center space-x-3 mb-8">
|
||||
<div class="w-14 h-14 bg-white/20 backdrop-blur-sm rounded-xl overflow-hidden ring-2 ring-white/30 shadow-xl">
|
||||
<img
|
||||
src="/logo.jpg"
|
||||
alt="Wallabicher Logo"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Wallabicher</h1>
|
||||
<p class="text-sm text-white/80">Admin Panel</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="text-4xl font-bold mb-4 leading-tight">
|
||||
Bienvenido de vuelta
|
||||
</h2>
|
||||
<p class="text-lg text-white/90 leading-relaxed">
|
||||
Gestiona y monitoriza tus búsquedas de productos con nuestro panel de administración profesional.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="space-y-4 pt-4 border-t border-white/20">
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mt-0.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">Monitoreo en tiempo real</p>
|
||||
<p class="text-sm text-white/80">Recibe notificaciones instantáneas de nuevos artículos</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mt-0.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">Gestión avanzada</p>
|
||||
<p class="text-sm text-white/80">Control total sobre workers, usuarios y configuraciones</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-white/20 backdrop-blur-sm flex items-center justify-center mt-0.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-white">Seguridad empresarial</p>
|
||||
<p class="text-sm text-white/80">Autenticación robusta y gestión de permisos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-white/70">
|
||||
© {{ new Date().getFullYear() }} Wallabicher. Todos los derechos reservados.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel - Login Form -->
|
||||
<div class="flex-1 flex items-center justify-center p-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div class="w-full max-w-md">
|
||||
<!-- Mobile Logo -->
|
||||
<div class="lg:hidden flex items-center justify-center space-x-3 mb-8">
|
||||
<div class="w-12 h-12 bg-primary-600 rounded-xl overflow-hidden ring-2 ring-primary-500/50 shadow-lg">
|
||||
<img
|
||||
src="/logo.jpg"
|
||||
alt="Wallabicher Logo"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Wallabicher</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Admin Panel</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Card -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-8">
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Iniciar Sesión
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Ingresa tus credenciales para acceder al panel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div
|
||||
v-if="loginError"
|
||||
class="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4 animate-shake"
|
||||
>
|
||||
<div class="flex items-start space-x-3">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
{{ loginError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form @submit.prevent="handleLogin" class="space-y-6">
|
||||
<!-- Username Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Usuario
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="loginForm.username"
|
||||
type="text"
|
||||
class="input w-full pl-12 pr-4 py-3 text-base"
|
||||
placeholder="Ingresa tu usuario"
|
||||
required
|
||||
autocomplete="username"
|
||||
:disabled="loginLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
class="input w-full pl-12 pr-4 py-3 text-base"
|
||||
placeholder="Ingresa tu contraseña"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
:disabled="loginLoading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="loginForm.remember"
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 transition-colors cursor-pointer"
|
||||
:disabled="loginLoading"
|
||||
/>
|
||||
<label for="remember" class="ml-2 block text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||
Recordar sesión
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full btn btn-primary py-3.5 text-base font-semibold shadow-lg hover:shadow-xl transition-all duration-200 transform hover:scale-[1.02] active:scale-[0.98]"
|
||||
:disabled="loginLoading"
|
||||
>
|
||||
<span v-if="!loginLoading" class="flex items-center justify-center">
|
||||
<span>Iniciar Sesión</span>
|
||||
<svg class="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span v-else class="flex items-center justify-center">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Iniciando sesión...
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-xs text-center text-gray-500 dark:text-gray-400">
|
||||
¿Necesitas ayuda? Contacta con el administrador del sistema
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import authService from '../services/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const loginError = ref('');
|
||||
const loginLoading = ref(false);
|
||||
const loginForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
remember: true,
|
||||
});
|
||||
|
||||
async function handleLogin() {
|
||||
loginError.value = '';
|
||||
loginLoading.value = true;
|
||||
|
||||
if (!loginForm.value.username || !loginForm.value.password) {
|
||||
loginError.value = 'Usuario y contraseña son requeridos';
|
||||
loginLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.login(
|
||||
loginForm.value.username,
|
||||
loginForm.value.password
|
||||
);
|
||||
|
||||
// Si llegamos aquí, el login fue exitoso
|
||||
// Redirigir al dashboard
|
||||
router.push('/dashboard');
|
||||
|
||||
// Disparar evento para que App.vue se actualice
|
||||
window.dispatchEvent(new CustomEvent('auth-login'));
|
||||
} catch (error) {
|
||||
console.error('Error en login:', error);
|
||||
loginError.value = error.message || 'Usuario o contraseña incorrectos';
|
||||
authService.clearSession();
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Si ya está autenticado, redirigir al dashboard
|
||||
if (authService.hasCredentials()) {
|
||||
router.push('/dashboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cargar username guardado si existe
|
||||
const username = authService.getUsername();
|
||||
if (username) {
|
||||
loginForm.value.username = username;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
262
web/dashboard/src/views/Logs.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4 mb-4 sm:mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3 sm:gap-4">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-gray-100">Logs del Sistema</h1>
|
||||
<div class="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:space-x-4">
|
||||
<select v-model="logLevel" @change="" class="input text-sm sm:text-base" style="width: 100%; min-width: 160px;">
|
||||
<option value="">Todos los niveles</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
</select>
|
||||
<button @click="loadLogs(true, followLatestLog)" class="btn btn-primary text-sm sm:text-base whitespace-nowrap">
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panel de configuración de auto-refresh -->
|
||||
<div class="card p-4 bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="autoRefresh"
|
||||
@change="handleAutoRefreshChange"
|
||||
class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
>
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">Auto-refresh</span>
|
||||
</label>
|
||||
|
||||
<div v-if="autoRefresh" class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">Intervalo:</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="refreshIntervalSeconds"
|
||||
@change="updateRefreshInterval"
|
||||
min="1"
|
||||
max="300"
|
||||
class="input text-sm w-20"
|
||||
>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap">segundos</span>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="followLatestLog"
|
||||
class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Seguir último log</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-green-400"></div>
|
||||
<p class="mt-2 text-gray-400">Cargando logs...</p>
|
||||
</div>
|
||||
<div v-else-if="filteredLogs.length === 0" class="text-gray-500">
|
||||
No hay logs disponibles
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="(log, index) in filteredLogs"
|
||||
:key="index"
|
||||
class="mb-1"
|
||||
:class="getLogColor(log)"
|
||||
>
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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);
|
||||
const logLevel = ref('');
|
||||
const autoRefresh = ref(false);
|
||||
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(() => {
|
||||
if (!logLevel.value) {
|
||||
return logs.value;
|
||||
}
|
||||
return logs.value.filter(log => log.includes(logLevel.value));
|
||||
});
|
||||
|
||||
function getLogColor(log) {
|
||||
if (log.includes('ERROR')) return 'text-red-400';
|
||||
if (log.includes('WARNING')) return 'text-yellow-400';
|
||||
if (log.includes('INFO')) return 'text-blue-400';
|
||||
if (log.includes('DEBUG')) return 'text-gray-400';
|
||||
return 'text-green-400';
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
// Guardar la posición del scroll antes de actualizar
|
||||
const previousScrollTop = logsContainer.value?.scrollTop || 0;
|
||||
const wasAtBottom = logsContainer.value
|
||||
? logsContainer.value.scrollTop + logsContainer.value.clientHeight >= logsContainer.value.scrollHeight - 10
|
||||
: false;
|
||||
|
||||
// Solo mostrar loader en carga inicial o recarga forzada
|
||||
const isInitialLoad = forceReload || lastLineNumber.value === -1;
|
||||
if (isInitialLoad) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Si es carga inicial o forzada, no enviar sinceLine (cargar últimas líneas)
|
||||
// Si es actualización incremental, enviar lastLineNumber + 1 para obtener solo las nuevas
|
||||
const sinceLine = isInitialLoad ? null : lastLineNumber.value + 1;
|
||||
const data = await api.getLogs(500, sinceLine);
|
||||
|
||||
const newLogs = data.logs || [];
|
||||
const newLastLineNumber = data.lastLineNumber !== undefined ? data.lastLineNumber : -1;
|
||||
|
||||
if (isInitialLoad) {
|
||||
// Carga inicial o recarga forzada: reemplazar todo
|
||||
logs.value = newLogs;
|
||||
lastLineNumber.value = newLastLineNumber;
|
||||
|
||||
await nextTick();
|
||||
if (logsContainer.value && shouldAutoScroll) {
|
||||
// Scroll al final (abajo) donde están los logs más recientes
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
|
||||
}
|
||||
} else {
|
||||
// Actualización incremental: añadir solo las líneas nuevas al final
|
||||
if (newLogs.length > 0 && newLastLineNumber > lastLineNumber.value) {
|
||||
// Añadir las nuevas líneas al final
|
||||
// Limitar el número total de logs para evitar crecimiento infinito
|
||||
const maxLogs = 1000;
|
||||
logs.value = [...logs.value, ...newLogs].slice(-maxLogs);
|
||||
|
||||
// Actualizar el número de la última línea leída
|
||||
lastLineNumber.value = newLastLineNumber;
|
||||
|
||||
await nextTick();
|
||||
|
||||
// Ajustar el scroll
|
||||
if (logsContainer.value) {
|
||||
if (shouldAutoScroll) {
|
||||
// Si debe seguir el último log, ir al final (abajo)
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
|
||||
} else if (wasAtBottom) {
|
||||
// Si estaba abajo, mantenerlo abajo
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
|
||||
}
|
||||
// Si no estaba abajo y no sigue logs, mantener posición (no hacer nada)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAutoRefreshChange() {
|
||||
updateRefreshInterval();
|
||||
}
|
||||
|
||||
function updateRefreshInterval() {
|
||||
// Limpiar intervalo anterior
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
|
||||
// Crear nuevo intervalo si auto-refresh está activado
|
||||
if (autoRefresh.value && refreshIntervalSeconds.value > 0) {
|
||||
refreshInterval = setInterval(() => {
|
||||
if (autoRefresh.value) {
|
||||
// Actualización incremental (no forzada)
|
||||
loadLogs(false, followLatestLog.value);
|
||||
}
|
||||
}, refreshIntervalSeconds.value * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Nota: El scroll ahora se maneja dentro de loadLogs para mejor control
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
// Ya no escuchamos logs_updated porque usamos polling con números de línea
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadLogs(true, true); // Primera carga forzada siempre hace scroll
|
||||
window.addEventListener('ws-message', handleWSMessage);
|
||||
|
||||
// Inicializar auto-refresh si está activado
|
||||
if (autoRefresh.value && isAdmin.value) {
|
||||
updateRefreshInterval();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
window.removeEventListener('ws-message', handleWSMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
209
web/dashboard/src/views/RateLimiter.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<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">Rate Limiter</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="loadRateLimiterInfo(true)"
|
||||
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
🔄 Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
|
||||
<ShieldExclamationIcon 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 el rate limiter
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Estadísticas -->
|
||||
<div v-if="rateLimiterInfo && rateLimiterInfo.enabled" class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total de Claves</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ rateLimiterInfo.stats?.totalBlocks || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Bloqueos Activos</div>
|
||||
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{{ rateLimiterInfo.stats?.activeBlocks || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Tipo</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100 capitalize">
|
||||
{{ rateLimiterInfo.type }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuración -->
|
||||
<div v-if="rateLimiterInfo && rateLimiterInfo.config" class="card p-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Configuración</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Puntos</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ rateLimiterInfo.config.points }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Duración (seg)</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ rateLimiterInfo.config.duration }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Duración Bloqueo (seg)</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ rateLimiterInfo.config.blockDuration }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje si no está habilitado -->
|
||||
<div v-if="rateLimiterInfo && !rateLimiterInfo.enabled" class="card p-6 text-center">
|
||||
<ShieldExclamationIcon 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">Rate Limiter Deshabilitado</p>
|
||||
<p class="text-gray-400 dark:text-gray-500 text-sm mt-2">
|
||||
{{ rateLimiterInfo.message || 'El rate limiter no está configurado' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Nota para rate limiter en memoria -->
|
||||
<div v-if="rateLimiterInfo && rateLimiterInfo.note" class="card p-4 mb-6 bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
|
||||
<p class="text-sm text-yellow-800 dark:text-yellow-200">{{ rateLimiterInfo.note }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Lista de bloqueos -->
|
||||
<div v-if="rateLimiterInfo && rateLimiterInfo.blocks && rateLimiterInfo.blocks.length > 0" class="card p-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Bloqueos</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Clave/IP
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Puntos Restantes
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Total Hits
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Tiempo Restante
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="block in rateLimiterInfo.blocks" :key="block.key">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-mono text-gray-900 dark:text-gray-100">
|
||||
{{ block.key }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
:class="block.isBlocked
|
||||
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||
>
|
||||
{{ block.isBlocked ? 'Bloqueado' : 'Activo' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ block.remainingPoints }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ block.totalHits }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatTimeRemaining(block.msBeforeNext) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="rateLimiterInfo && rateLimiterInfo.enabled" class="card p-6 text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">No hay bloqueos registrados</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card p-6 text-center">
|
||||
<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">Cargando información del rate limiter...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import { ShieldExclamationIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const rateLimiterInfo = ref(null);
|
||||
const loading = ref(true);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
|
||||
function formatTimeRemaining(ms) {
|
||||
if (!ms || ms <= 0) return 'N/A';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
} else {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRateLimiterInfo(showLoading = false) {
|
||||
if (!isAdmin.value) {
|
||||
accessDenied.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
accessDenied.value = false;
|
||||
|
||||
if (showLoading) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getRateLimiterInfo();
|
||||
rateLimiterInfo.value = data;
|
||||
} catch (error) {
|
||||
console.error('Error cargando información del rate limiter:', error);
|
||||
if (error.response?.status === 403) {
|
||||
accessDenied.value = true;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadRateLimiterInfo(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
261
web/dashboard/src/views/Sessions.vue
Normal file
@@ -0,0 +1,261 @@
|
||||
<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">Sesiones</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="loadSessions(true)"
|
||||
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
🔄 Actualizar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="accessDenied || (!isAdmin && currentUser)" class="card text-center py-12">
|
||||
<UserGroupIcon 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 las sesiones
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Estadísticas -->
|
||||
<div v-if="sessionsData && sessionsData.stats" class="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Sesiones</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ sessionsData.stats.total || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Activas</div>
|
||||
<div class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{{ sessionsData.stats.active || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Sesiones Expiradas</div>
|
||||
<div class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{{ sessionsData.stats.expired || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card p-4">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Usuarios Únicos</div>
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{{ Object.keys(sessionsData.stats.byUser || {}).length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sesiones por usuario -->
|
||||
<div v-if="sessionsData && sessionsData.stats && sessionsData.stats.byUser && Object.keys(sessionsData.stats.byUser).length > 0" class="card p-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3">Sesiones por Usuario</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="(count, username) in sessionsData.stats.byUser"
|
||||
:key="username"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">{{ username }}</span>
|
||||
<span class="px-2 py-1 text-sm font-semibold rounded bg-primary-100 dark:bg-primary-900 text-primary-800 dark:text-primary-200">
|
||||
{{ count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de sesiones -->
|
||||
<div v-if="sessionsData && sessionsData.sessions && sessionsData.sessions.length > 0" class="card p-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Todas las Sesiones</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Usuario
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Dispositivo
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Token
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Creada
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Expira
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Estado
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Acciones
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="session in sessionsData.sessions" :key="session.token">
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ session.username }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div v-if="session.deviceInfo" class="space-y-1">
|
||||
<div class="font-medium">
|
||||
{{ formatDeviceInfo(session.deviceInfo) }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ session.deviceInfo.os || 'Unknown OS' }}
|
||||
<span v-if="session.deviceInfo.osVersion"> {{ session.deviceInfo.osVersion }}</span>
|
||||
</div>
|
||||
<div v-if="session.deviceInfo.ip" class="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
IP: {{ session.deviceInfo.ip }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-gray-400 dark:text-gray-500 italic">
|
||||
Sin información
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||
<span class="truncate max-w-xs inline-block" :title="session.token">
|
||||
{{ session.token.substring(0, 16) }}...
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatDate(session.createdAt) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{{ formatDate(session.expiresAt) }}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
:class="session.isExpired
|
||||
? 'px-2 py-1 text-xs font-semibold rounded bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
||||
: 'px-2 py-1 text-xs font-semibold rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'"
|
||||
>
|
||||
{{ session.isExpired ? 'Expirada' : 'Activa' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm">
|
||||
<button
|
||||
@click="confirmDeleteSession(session.token)"
|
||||
class="btn btn-danger text-xs"
|
||||
:disabled="session.isExpired"
|
||||
title="Eliminar sesión"
|
||||
>
|
||||
🗑️ Eliminar
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sessionsData && sessionsData.sessions && sessionsData.sessions.length === 0" class="card p-6 text-center">
|
||||
<UserGroupIcon 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">No hay sesiones registradas</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card p-6 text-center">
|
||||
<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">Cargando sesiones...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
import { UserGroupIcon } from '@heroicons/vue/24/outline';
|
||||
|
||||
const sessionsData = ref(null);
|
||||
const loading = ref(true);
|
||||
const currentUser = ref(authService.getUsername() || null);
|
||||
const isAdmin = ref(false);
|
||||
const accessDenied = ref(false);
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDeviceInfo(deviceInfo) {
|
||||
if (!deviceInfo) return 'Unknown';
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (deviceInfo.browser) {
|
||||
parts.push(deviceInfo.browser);
|
||||
if (deviceInfo.browserVersion) {
|
||||
parts.push(deviceInfo.browserVersion.split('.')[0]); // Solo versión mayor
|
||||
}
|
||||
}
|
||||
|
||||
if (deviceInfo.device && deviceInfo.device !== 'Desktop') {
|
||||
parts.push(`(${deviceInfo.device})`);
|
||||
}
|
||||
|
||||
return parts.join(' ') || 'Unknown';
|
||||
}
|
||||
|
||||
async function loadSessions(showLoading = false) {
|
||||
if (!isAdmin.value) {
|
||||
accessDenied.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
accessDenied.value = false;
|
||||
|
||||
if (showLoading) {
|
||||
loading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getSessions();
|
||||
sessionsData.value = data;
|
||||
} catch (error) {
|
||||
console.error('Error cargando sesiones:', error);
|
||||
if (error.response?.status === 403) {
|
||||
accessDenied.value = true;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSession(token) {
|
||||
if (!confirm('¿Estás seguro de que quieres eliminar esta sesión?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.deleteSession(token);
|
||||
// Recargar sesiones después de eliminar
|
||||
await loadSessions(false);
|
||||
} catch (error) {
|
||||
console.error('Error eliminando sesión:', error);
|
||||
alert('Error al eliminar la sesión: ' + (error.response?.data?.error || error.message));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
currentUser.value = authService.getUsername() || null;
|
||||
isAdmin.value = authService.isAdmin();
|
||||
loadSessions(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
314
web/dashboard/src/views/Settings.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<template>
|
||||
<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">Configuración</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Gestiona tu configuración personal y preferencias
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cambiar Contraseña -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Cambiar Contraseña</h3>
|
||||
<p class="card-subtitle">Actualiza tu contraseña de acceso</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div v-if="passwordError" 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">
|
||||
{{ passwordError }}
|
||||
</div>
|
||||
|
||||
<div v-if="passwordSuccess" 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">
|
||||
{{ passwordSuccess }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contraseña actual <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nueva contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="passwordForm.newPassword"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mínimo 6 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirmar nueva contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="passwordForm.newPasswordConfirm"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Cambiando...' : 'Cambiar Contraseña' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Configuración de Telegram -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Configuración de Telegram</h3>
|
||||
<p class="card-subtitle">Configura tu bot de Telegram y canal para recibir notificaciones</p>
|
||||
</div>
|
||||
|
||||
<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 justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Guardando...' : 'Guardar Configuración' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Información de la cuenta -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Información de la Cuenta</h3>
|
||||
<p class="card-subtitle">Datos de tu cuenta</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nombre de usuario</p>
|
||||
<p class="text-base text-gray-900 dark:text-gray-100 font-semibold">{{ currentUser }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rol</p>
|
||||
<span
|
||||
:class="isAdmin ? 'badge badge-warning' : 'badge badge-primary'"
|
||||
>
|
||||
{{ isAdmin ? 'Administrador' : 'Usuario' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
|
||||
const loadingAction = ref(false);
|
||||
const passwordError = ref('');
|
||||
const passwordSuccess = ref('');
|
||||
const telegramError = ref('');
|
||||
const telegramSuccess = ref('');
|
||||
|
||||
const passwordForm = ref({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
newPasswordConfirm: '',
|
||||
});
|
||||
|
||||
const telegramForm = ref({
|
||||
token: '',
|
||||
channel: '',
|
||||
enable_polling: false
|
||||
});
|
||||
|
||||
const currentUser = computed(() => {
|
||||
return authService.getUsername() || '';
|
||||
});
|
||||
|
||||
const isAdmin = computed(() => {
|
||||
return authService.isAdmin();
|
||||
});
|
||||
|
||||
async function handleChangePassword() {
|
||||
passwordError.value = '';
|
||||
passwordSuccess.value = '';
|
||||
loadingAction.value = true;
|
||||
|
||||
if (!passwordForm.value.currentPassword || !passwordForm.value.newPassword || !passwordForm.value.newPasswordConfirm) {
|
||||
passwordError.value = 'Todos los campos son requeridos';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword.length < 6) {
|
||||
passwordError.value = 'La nueva contraseña debe tener al menos 6 caracteres';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.value.newPassword !== passwordForm.value.newPasswordConfirm) {
|
||||
passwordError.value = 'Las contraseñas no coinciden';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.changePassword({
|
||||
currentPassword: passwordForm.value.currentPassword,
|
||||
newPassword: passwordForm.value.newPassword,
|
||||
});
|
||||
|
||||
passwordSuccess.value = 'Contraseña actualizada correctamente. Por favor, inicia sesión nuevamente.';
|
||||
|
||||
// Invalidar la sesión actual - el usuario deberá hacer login nuevamente
|
||||
setTimeout(async () => {
|
||||
await authService.logout();
|
||||
// Recargar página para forzar nuevo login
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error cambiando contraseña:', error);
|
||||
if (error.response?.data?.error) {
|
||||
passwordError.value = error.response.data.error;
|
||||
} else {
|
||||
passwordError.value = 'Error cambiando contraseña. Intenta de nuevo.';
|
||||
}
|
||||
} finally {
|
||||
loadingAction.value = 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(() => {
|
||||
telegramSuccess.value = '';
|
||||
}, 3000);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTelegramConfig();
|
||||
});
|
||||
</script>
|
||||
|
||||
264
web/dashboard/src/views/Subscription.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<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">Suscripción</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Gestiona tu plan de suscripción y límites
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Plan Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Plan Actual</h3>
|
||||
<p class="card-subtitle">Tu plan de suscripción actual y uso</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando información...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="subscription && subscription.subscription && subscription.subscription.plan" class="space-y-6">
|
||||
<!-- Plan Info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="p-6 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-xl border border-primary-200 dark:border-primary-800">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Plan</h4>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{{ subscription.subscription.plan?.name || 'Gratis' }}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">{{ subscription.subscription.plan?.description || 'Plan gratuito' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-800 dark:to-gray-700 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Estado</h4>
|
||||
<span
|
||||
:class="subscription.subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
|
||||
class="text-lg font-semibold"
|
||||
>
|
||||
{{ subscription.subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
<p v-if="subscription.subscription?.currentPeriodEnd" class="text-sm text-gray-600 dark:text-gray-400 mt-2">
|
||||
Renovación: {{ formatDate(subscription.subscription.currentPeriodEnd) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Búsquedas activas</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ subscription.usage?.workers || 0 }} / {{ subscription.usage?.maxWorkers || 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
class="bg-gradient-to-r from-primary-600 to-teal-600 h-2 rounded-full transition-all duration-300"
|
||||
:style="{ width: getUsagePercentage(subscription.usage?.workers || 0, subscription.usage?.maxWorkers) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Plataformas disponibles</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="platform in (subscription.subscription.plan?.limits?.platforms || [])"
|
||||
:key="platform"
|
||||
class="badge badge-primary"
|
||||
>
|
||||
{{ platform }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Características incluidas</h4>
|
||||
<ul class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<li v-for="feature in (subscription.subscription.plan?.features || [])" :key="feature" class="flex items-start">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="subscription && (!subscription.subscription || !subscription.subscription.plan)" class="text-center py-8">
|
||||
<p class="text-gray-600 dark:text-gray-400">No se pudo cargar la información del plan. Por favor, recarga la página.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Plans -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Planes Disponibles</h3>
|
||||
<p class="card-subtitle">Información de los planes disponibles</p>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje informativo -->
|
||||
<div class="mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="text-sm font-semibold text-yellow-800 dark:text-yellow-300 mb-1">Cambio de plan temporalmente deshabilitado</h4>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-400">
|
||||
El cambio de plan no está disponible por el momento. Si necesitas cambiar tu plan, contacta con el administrador.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingPlans" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="plans && plans.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div
|
||||
v-for="plan in plans"
|
||||
:key="plan.id"
|
||||
class="p-6 rounded-xl border-2 transition-all duration-300 opacity-75"
|
||||
:class="
|
||||
plan.id === subscription?.subscription?.planId
|
||||
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-800'
|
||||
"
|
||||
>
|
||||
<div class="text-center mb-4">
|
||||
<h4 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-1">{{ plan.name }}</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">{{ plan.description }}</p>
|
||||
<div class="mb-4">
|
||||
<span class="text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
€{{ plan.price.monthly.toFixed(2) }}
|
||||
</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">/mes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2 mb-6">
|
||||
<li v-for="feature in plan.features.slice(0, 3)" :key="feature" class="flex items-start text-sm">
|
||||
<svg class="w-4 h-4 text-primary-600 dark:text-primary-400 mr-2 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700 dark:text-gray-300">{{ feature }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
v-if="plan.id !== subscription?.subscription?.planId"
|
||||
class="w-full btn btn-primary opacity-50 cursor-not-allowed"
|
||||
disabled
|
||||
title="Cambio de plan temporalmente deshabilitado"
|
||||
>
|
||||
No disponible
|
||||
</button>
|
||||
<div v-else class="w-full text-center py-2 px-4 bg-primary-100 dark:bg-primary-900/50 text-primary-700 dark:text-primary-400 rounded-lg font-semibold">
|
||||
Plan Actual
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../services/api';
|
||||
|
||||
const subscription = ref(null);
|
||||
const plans = ref([]);
|
||||
const loading = ref(true);
|
||||
const loadingPlans = ref(true);
|
||||
const upgrading = ref(false);
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return 'N/A';
|
||||
return new Date(date).toLocaleDateString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getUsagePercentage(current, max) {
|
||||
if (max === 'Ilimitado' || max === -1) return 0;
|
||||
if (typeof max === 'string') return 0;
|
||||
if (max === 0) return 0;
|
||||
return Math.min((current / max) * 100, 100);
|
||||
}
|
||||
|
||||
async function loadSubscription() {
|
||||
try {
|
||||
loading.value = true;
|
||||
const data = await api.getMySubscription();
|
||||
// Asegurar que la estructura de datos sea correcta
|
||||
if (data && !data.plan && data.subscription) {
|
||||
// Si viene la estructura del backend directamente
|
||||
subscription.value = data;
|
||||
} else if (data) {
|
||||
subscription.value = data;
|
||||
} else {
|
||||
subscription.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando suscripción:', error);
|
||||
if (error.response?.status === 401) {
|
||||
// El usuario no está autenticado, se manejará automáticamente
|
||||
subscription.value = null;
|
||||
return;
|
||||
}
|
||||
subscription.value = null;
|
||||
// Mostrar error genérico (solo en consola, no interrumpir la UI)
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPlans() {
|
||||
try {
|
||||
loadingPlans.value = true;
|
||||
const data = await api.getSubscriptionPlans();
|
||||
plans.value = data.plans || [];
|
||||
} catch (error) {
|
||||
console.error('Error cargando planes:', error);
|
||||
// Error al cargar planes (solo en consola)
|
||||
} finally {
|
||||
loadingPlans.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function upgradePlan(planId) {
|
||||
if (upgrading.value) return;
|
||||
|
||||
if (!confirm(`¿Estás seguro de que quieres cambiar a el plan ${plans.value.find(p => p.id === planId)?.name || planId}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
upgrading.value = true;
|
||||
await api.updateSubscription({ planId });
|
||||
// Plan actualizado correctamente
|
||||
await loadSubscription();
|
||||
} catch (error) {
|
||||
console.error('Error actualizando plan:', error);
|
||||
const errorMessage = error.response?.data?.message || error.response?.data?.error || 'Error al actualizar el plan';
|
||||
alert(errorMessage);
|
||||
} finally {
|
||||
upgrading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSubscription();
|
||||
loadPlans();
|
||||
});
|
||||
</script>
|
||||
|
||||
706
web/dashboard/src/views/Users.vue
Normal file
@@ -0,0 +1,706 @@
|
||||
<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 Usuarios</h1>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
@click="showAddModal = true"
|
||||
class="btn btn-primary text-xs sm:text-sm whitespace-nowrap"
|
||||
>
|
||||
+ Crear Usuario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isAdmin" class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400 text-lg font-semibold mb-2">Acceso Denegado</p>
|
||||
<p class="text-gray-500 dark:text-gray-500 text-sm">
|
||||
Solo los administradores pueden acceder a esta sección.
|
||||
</p>
|
||||
<router-link to="/settings" class="btn btn-primary mt-4 inline-block">
|
||||
Ir a Configuración
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="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">Cargando usuarios...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Lista de usuarios -->
|
||||
<div v-if="users.length > 0" class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.username"
|
||||
class="card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
|
||||
<span
|
||||
v-if="user.username === currentUser"
|
||||
class="px-2 py-1 text-xs font-semibold rounded bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
|
||||
>
|
||||
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 mb-3">
|
||||
<div v-if="user.createdAt">
|
||||
<span class="font-medium">Creado:</span>
|
||||
<span class="ml-2">{{ formatDate(user.createdAt) }}</span>
|
||||
</div>
|
||||
<div v-if="user.createdBy">
|
||||
<span class="font-medium">Por:</span>
|
||||
<span class="ml-2">{{ user.createdBy }}</span>
|
||||
</div>
|
||||
<div v-if="user.updatedAt">
|
||||
<span class="font-medium">Actualizado:</span>
|
||||
<span class="ml-2">{{ formatDate(user.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Información de suscripción -->
|
||||
<div v-if="userSubscriptions[user.username]" class="mt-3 p-3 bg-gradient-to-r from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-lg border border-primary-200 dark:border-primary-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300">Plan:</span>
|
||||
<span class="ml-2 text-sm font-bold text-primary-700 dark:text-primary-400">
|
||||
{{ userSubscriptions[user.username].plan?.name || 'Gratis' }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
:class="userSubscriptions[user.username].subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ userSubscriptions[user.username].subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="userSubscriptions[user.username].usage" class="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>Uso: {{ userSubscriptions[user.username].usage.workers }} / {{ userSubscriptions[user.username].usage.maxWorkers === 'Ilimitado' ? '∞' : userSubscriptions[user.username].usage.maxWorkers }} búsquedas</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
@click="openSubscriptionModal(user.username)"
|
||||
class="btn btn-primary text-xs sm:text-sm"
|
||||
title="Gestionar suscripción"
|
||||
>
|
||||
💳 Plan
|
||||
</button>
|
||||
<button
|
||||
v-if="user.username !== currentUser && isAdmin"
|
||||
@click="confirmDeleteUser(user.username)"
|
||||
class="btn btn-danger text-xs sm:text-sm"
|
||||
title="Eliminar usuario"
|
||||
>
|
||||
🗑️ Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="card text-center py-12">
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">No hay usuarios configurados</p>
|
||||
<button @click="showAddModal = true" class="btn btn-primary">
|
||||
+ Crear primer usuario
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para crear/editar usuario -->
|
||||
<div
|
||||
v-if="showAddModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeAddModal"
|
||||
>
|
||||
<div class="card max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">Crear Usuario</h2>
|
||||
<button
|
||||
@click="closeAddModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleCreateUser" class="space-y-4">
|
||||
<div v-if="addError" 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">
|
||||
{{ addError }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nombre de usuario <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="userForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
placeholder="nuevo_usuario"
|
||||
required
|
||||
minlength="3"
|
||||
autocomplete="username"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mínimo 3 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="userForm.password"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Mínimo 6 caracteres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Confirmar contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="userForm.passwordConfirm"
|
||||
type="password"
|
||||
class="input"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</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="closeAddModal"
|
||||
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 ? 'Creando...' : 'Crear Usuario' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Modal para gestionar suscripción (solo admin) -->
|
||||
<div
|
||||
v-if="showSubscriptionModal && selectedUserForSubscription"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
@click.self="closeSubscriptionModal"
|
||||
>
|
||||
<div class="card max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Gestionar Suscripción: {{ selectedUserForSubscription }}
|
||||
</h2>
|
||||
<button
|
||||
@click="closeSubscriptionModal"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingSubscription" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Cargando información...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Información actual -->
|
||||
<div v-if="selectedUserSubscription && selectedUserSubscription.subscription" class="p-4 bg-gradient-to-br from-primary-50 to-teal-50 dark:from-primary-900/20 dark:to-teal-900/20 rounded-xl border border-primary-200 dark:border-primary-800">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Plan Actual</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Plan</p>
|
||||
<p class="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{{ selectedUserSubscription.subscription?.plan?.name || 'Gratis' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Estado</p>
|
||||
<span
|
||||
:class="selectedUserSubscription.subscription?.status === 'active' ? 'badge badge-success' : 'badge badge-warning'"
|
||||
>
|
||||
{{ selectedUserSubscription.subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="selectedUserSubscription.usage">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">Búsquedas</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ selectedUserSubscription.usage.workers }} / {{ selectedUserSubscription.usage.maxWorkers === 'Ilimitado' ? '∞' : selectedUserSubscription.usage.maxWorkers }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cambiar plan -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Cambiar Plan</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
v-for="plan in availablePlans"
|
||||
:key="plan.id"
|
||||
@click="subscriptionForm.planId = plan.id"
|
||||
class="p-4 rounded-lg border-2 text-left transition-all"
|
||||
:class="
|
||||
subscriptionForm.planId === plan.id
|
||||
? 'border-primary-500 dark:border-primary-400 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-primary-300 dark:hover:border-primary-700'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-bold text-gray-900 dark:text-gray-100">{{ plan.name }}</p>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mt-1">{{ plan.description }}</p>
|
||||
</div>
|
||||
<div v-if="subscriptionForm.planId === plan.id" class="text-primary-600 dark:text-primary-400">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulario de actualización -->
|
||||
<form @submit.prevent="handleUpdateUserSubscription" class="space-y-4">
|
||||
<div v-if="subscriptionError" 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">
|
||||
{{ subscriptionError }}
|
||||
</div>
|
||||
|
||||
<div v-if="subscriptionSuccess" 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">
|
||||
{{ subscriptionSuccess }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Estado de la suscripción
|
||||
</label>
|
||||
<select
|
||||
v-model="subscriptionForm.status"
|
||||
class="input"
|
||||
>
|
||||
<option value="active">Activo</option>
|
||||
<option value="inactive">Inactivo</option>
|
||||
<option value="cancelled">Cancelado</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="subscriptionForm.cancelAtPeriodEnd"
|
||||
type="checkbox"
|
||||
id="cancelAtPeriodEnd"
|
||||
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<label for="cancelAtPeriodEnd" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Cancelar al final del período
|
||||
</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="closeSubscriptionModal"
|
||||
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 ? 'Actualizando...' : 'Actualizar Suscripción' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de confirmación para eliminar -->
|
||||
<div
|
||||
v-if="userToDelete"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 dark:bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
@click.self="userToDelete = null"
|
||||
>
|
||||
<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">Confirmar Eliminación</h2>
|
||||
<button
|
||||
@click="userToDelete = null"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
title="Cerrar"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-700 dark:text-gray-300 mb-4">
|
||||
¿Estás seguro de que deseas eliminar al usuario <strong>{{ userToDelete }}</strong>?
|
||||
</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 mb-4">
|
||||
Esta acción no se puede deshacer.
|
||||
</p>
|
||||
|
||||
<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="userToDelete = null"
|
||||
class="btn btn-secondary text-sm sm:text-base"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
@click="handleDeleteUser"
|
||||
class="btn btn-danger text-sm sm:text-base"
|
||||
:disabled="loadingAction"
|
||||
>
|
||||
{{ loadingAction ? 'Eliminando...' : 'Eliminar Usuario' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import api from '../services/api';
|
||||
import authService from '../services/auth';
|
||||
|
||||
const users = ref([]);
|
||||
const loading = ref(true);
|
||||
const loadingAction = ref(false);
|
||||
const showAddModal = ref(false);
|
||||
const showSubscriptionModal = ref(false);
|
||||
const userToDelete = ref(null);
|
||||
const selectedUserForSubscription = ref(null);
|
||||
const loadingSubscription = ref(false);
|
||||
const userSubscriptions = ref({});
|
||||
const availablePlans = ref([]);
|
||||
const subscriptionError = ref('');
|
||||
const subscriptionSuccess = ref('');
|
||||
const addError = ref('');
|
||||
|
||||
const userForm = ref({
|
||||
username: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
});
|
||||
|
||||
const subscriptionForm = ref({
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: false,
|
||||
});
|
||||
|
||||
const selectedUserSubscription = ref(null);
|
||||
|
||||
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';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('es-ES', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.getUsers();
|
||||
users.value = data.users || [];
|
||||
|
||||
// Cargar información de suscripción para todos los usuarios (solo si es admin)
|
||||
if (isAdmin.value) {
|
||||
await loadAllUserSubscriptions();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando usuarios:', error);
|
||||
// El modal de login se manejará automáticamente desde App.vue
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllUserSubscriptions() {
|
||||
// Cargar suscripciones de todos los usuarios en paralelo
|
||||
const subscriptionPromises = users.value.map(async (user) => {
|
||||
try {
|
||||
// Usar el endpoint de admin para obtener suscripción de cualquier usuario
|
||||
const response = await fetch(`/api/subscription/${user.username}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': authService.getAuthHeader(),
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
userSubscriptions.value[user.username] = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error cargando suscripción de ${user.username}:`, error);
|
||||
}
|
||||
});
|
||||
await Promise.all(subscriptionPromises);
|
||||
}
|
||||
|
||||
async function loadAvailablePlans() {
|
||||
try {
|
||||
const data = await api.getSubscriptionPlans();
|
||||
availablePlans.value = data.plans || [];
|
||||
} catch (error) {
|
||||
console.error('Error cargando planes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSubscriptionModal(username) {
|
||||
selectedUserForSubscription.value = username;
|
||||
showSubscriptionModal.value = true;
|
||||
loadingSubscription.value = true;
|
||||
subscriptionError.value = '';
|
||||
subscriptionSuccess.value = '';
|
||||
|
||||
try {
|
||||
// Cargar planes disponibles
|
||||
await loadAvailablePlans();
|
||||
|
||||
// Cargar suscripción del usuario seleccionado
|
||||
const response = await fetch(`/api/subscription/${username}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': authService.getAuthHeader(),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
selectedUserSubscription.value = data;
|
||||
subscriptionForm.value = {
|
||||
planId: data.subscription?.planId || 'free',
|
||||
status: data.subscription?.status || 'active',
|
||||
cancelAtPeriodEnd: data.subscription?.cancelAtPeriodEnd || false,
|
||||
};
|
||||
} else {
|
||||
// Si no hay suscripción, usar valores por defecto
|
||||
selectedUserSubscription.value = {
|
||||
subscription: {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
plan: { name: 'Gratis', description: 'Plan gratuito' }
|
||||
},
|
||||
usage: { workers: 0, maxWorkers: 2 },
|
||||
};
|
||||
subscriptionForm.value = {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando suscripción:', error);
|
||||
subscriptionError.value = 'Error al cargar información de suscripción';
|
||||
} finally {
|
||||
loadingSubscription.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeSubscriptionModal() {
|
||||
showSubscriptionModal.value = false;
|
||||
selectedUserForSubscription.value = null;
|
||||
selectedUserSubscription.value = null;
|
||||
subscriptionError.value = '';
|
||||
subscriptionSuccess.value = '';
|
||||
subscriptionForm.value = {
|
||||
planId: 'free',
|
||||
status: 'active',
|
||||
cancelAtPeriodEnd: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUpdateUserSubscription() {
|
||||
if (!selectedUserForSubscription.value) return;
|
||||
|
||||
subscriptionError.value = '';
|
||||
subscriptionSuccess.value = '';
|
||||
loadingAction.value = true;
|
||||
|
||||
try {
|
||||
await api.updateUserSubscription(selectedUserForSubscription.value, {
|
||||
planId: subscriptionForm.value.planId,
|
||||
status: subscriptionForm.value.status,
|
||||
cancelAtPeriodEnd: subscriptionForm.value.cancelAtPeriodEnd,
|
||||
});
|
||||
|
||||
subscriptionSuccess.value = 'Suscripción actualizada correctamente';
|
||||
|
||||
// Actualizar la información en la lista
|
||||
await loadAllUserSubscriptions();
|
||||
|
||||
// Recargar la información del modal después de un momento
|
||||
setTimeout(async () => {
|
||||
await openSubscriptionModal(selectedUserForSubscription.value);
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Error actualizando suscripción:', error);
|
||||
subscriptionError.value = error.response?.data?.error || 'Error al actualizar la suscripción';
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
addError.value = '';
|
||||
loadingAction.value = true;
|
||||
|
||||
if (!userForm.value.username || !userForm.value.password || !userForm.value.passwordConfirm) {
|
||||
addError.value = 'Todos los campos son requeridos';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userForm.value.username.length < 3) {
|
||||
addError.value = 'El nombre de usuario debe tener al menos 3 caracteres';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userForm.value.password.length < 6) {
|
||||
addError.value = 'La contraseña debe tener al menos 6 caracteres';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (userForm.value.password !== userForm.value.passwordConfirm) {
|
||||
addError.value = 'Las contraseñas no coinciden';
|
||||
loadingAction.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createUser({
|
||||
username: userForm.value.username,
|
||||
password: userForm.value.password,
|
||||
});
|
||||
|
||||
closeAddModal();
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error creando usuario:', error);
|
||||
if (error.response?.data?.error) {
|
||||
addError.value = error.response.data.error;
|
||||
} else {
|
||||
addError.value = 'Error creando usuario. Intenta de nuevo.';
|
||||
}
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function handleDeleteUser() {
|
||||
if (!userToDelete.value) return;
|
||||
|
||||
loadingAction.value = true;
|
||||
try {
|
||||
await api.deleteUser(userToDelete.value);
|
||||
userToDelete.value = null;
|
||||
await loadUsers();
|
||||
} catch (error) {
|
||||
console.error('Error eliminando usuario:', error);
|
||||
alert(error.response?.data?.error || 'Error eliminando usuario. Intenta de nuevo.');
|
||||
} finally {
|
||||
loadingAction.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteUser(username) {
|
||||
userToDelete.value = username;
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
showAddModal.value = false;
|
||||
addError.value = '';
|
||||
userForm.value = {
|
||||
username: '',
|
||||
password: '',
|
||||
passwordConfirm: '',
|
||||
};
|
||||
}
|
||||
|
||||
function handleAuthLogout() {
|
||||
// Cuando el usuario se desconecta globalmente, limpiar datos
|
||||
users.value = [];
|
||||
showAddModal.value = false;
|
||||
userToDelete.value = null;
|
||||
addError.value = '';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
window.addEventListener('auth-logout', handleAuthLogout);
|
||||
// Escuchar evento de login exitoso para recargar usuarios
|
||||
window.addEventListener('auth-login', () => {
|
||||
loadUsers();
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('auth-logout', handleAuthLogout);
|
||||
window.removeEventListener('auth-login', loadUsers);
|
||||
});
|
||||
</script>
|
||||
|
||||
804
web/dashboard/src/views/Workers.vue
Normal file
@@ -0,0 +1,804 @@
|
||||
<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">
|
||||
<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="showAddModal = true" class="btn btn-primary text-xs sm:text-sm whitespace-nowrap">
|
||||
+ Añadir Worker
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="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">Cargando workers...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Workers activos -->
|
||||
<div v-if="activeWorkers.length > 0">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-4">Workers Activos ({{ activeWorkers.length }})</h2>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="(worker, index) in activeWorkers"
|
||||
:key="index"
|
||||
class="card hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-3">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{{ worker.name }}</h3>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded bg-green-100 text-green-800">
|
||||
Activo
|
||||
</span>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded" :class="worker.platform === 'wallapop' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'">
|
||||
{{ (worker.platform || 'wallapop').toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 text-xs sm:text-sm mb-3">
|
||||
<div>
|
||||
<span class="text-gray-600 block mb-1">Búsqueda:</span>
|
||||
<p class="font-medium">{{ worker.search_query }}</p>
|
||||
</div>
|
||||
<div v-if="worker.min_price || worker.max_price">
|
||||
<span class="text-gray-600 block mb-1">Precio:</span>
|
||||
<p class="font-medium">
|
||||
{{ worker.min_price || '0' }}€ - {{ worker.max_price || '∞' }}€
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="worker.thread_id">
|
||||
<span class="text-gray-600 block mb-1">Thread ID:</span>
|
||||
<p class="font-medium">{{ worker.thread_id }}</p>
|
||||
</div>
|
||||
<div v-if="worker.latitude && worker.longitude">
|
||||
<span class="text-gray-600 block mb-1">Ubicación:</span>
|
||||
<p class="font-medium">{{ worker.latitude }}, {{ worker.longitude }}</p>
|
||||
</div>
|
||||
<div v-if="worker.max_distance">
|
||||
<span class="text-gray-600 block mb-1">Distancia Máx:</span>
|
||||
<p class="font-medium">{{ worker.max_distance }} km</p>
|
||||
</div>
|
||||
<div v-if="worker.check_every">
|
||||
<span class="text-gray-600 block mb-1">Check cada:</span>
|
||||
<p class="font-medium">{{ worker.check_every }}s</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros aplicados -->
|
||||
<div v-if="hasFilters(worker)" class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<details class="text-xs">
|
||||
<summary class="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 font-medium">
|
||||
Ver filtros ({{ countFilters(worker) }})
|
||||
</summary>
|
||||
<div class="mt-2 space-y-1 text-gray-600 dark:text-gray-400">
|
||||
<div v-if="worker.title_exclude?.length" class="flex items-start">
|
||||
<span class="font-medium mr-2">Excluir título:</span>
|
||||
<span>{{ worker.title_exclude.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="worker.description_exclude?.length" class="flex items-start">
|
||||
<span class="font-medium mr-2">Excluir descripción:</span>
|
||||
<span>{{ worker.description_exclude.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="worker.title_must_include?.length" class="flex items-start">
|
||||
<span class="font-medium mr-2">Requerir título:</span>
|
||||
<span>{{ worker.title_must_include.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="worker.description_must_include?.length" class="flex items-start">
|
||||
<span class="font-medium mr-2">Requerir descripción:</span>
|
||||
<span>{{ worker.description_must_include.join(', ') }}</span>
|
||||
</div>
|
||||
<div v-if="worker.title_first_word_exclude?.length" class="flex items-start">
|
||||
<span class="font-medium mr-2">Excluir primera palabra:</span>
|
||||
<span>{{ worker.title_first_word_exclude.join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:space-x-2 sm:ml-4 mt-3 sm:mt-0">
|
||||
<button
|
||||
@click="editWorker(worker, activeWorkersIndex(index))"
|
||||
class="btn btn-secondary text-xs sm:text-sm flex-1 sm:flex-none"
|
||||
>
|
||||
✏️ Editar
|
||||
</button>
|
||||
<button
|
||||
@click="deleteWorker(worker.name)"
|
||||
class="btn btn-danger text-xs sm:text-sm flex-1 sm:flex-none"
|
||||
>
|
||||
🗑️ Eliminar
|
||||
</button>
|
||||
<button
|
||||
@click="disableWorker(worker)"
|
||||
class="btn btn-secondary text-xs sm:text-sm flex-1 sm:flex-none"
|
||||
>
|
||||
⏸️ Desactivar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workers desactivados -->
|
||||
<div v-if="disabledWorkers.length > 0" class="mt-6 sm:mt-8">
|
||||
<h2 class="text-lg sm:text-xl font-semibold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Workers Desactivados ({{ disabledWorkers.length }})</h2>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="(worker, index) in disabledWorkers"
|
||||
:key="index"
|
||||
class="card opacity-60 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100">{{ worker.name }}</h3>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded bg-red-100 text-red-800">
|
||||
Desactivado
|
||||
</span>
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded" :class="worker.platform === 'wallapop' ? 'bg-blue-100 text-blue-800' : 'bg-green-100 text-green-800'">
|
||||
{{ (worker.platform || 'wallapop').toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400">{{ worker.search_query }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 sm:space-x-2 sm:ml-4">
|
||||
<button
|
||||
@click="editWorker(worker, disabledWorkersIndex(index))"
|
||||
class="btn btn-secondary text-xs sm:text-sm flex-1 sm:flex-none"
|
||||
>
|
||||
✏️ Editar
|
||||
</button>
|
||||
<button
|
||||
@click="enableWorker(worker)"
|
||||
class="btn btn-primary text-xs sm:text-sm flex-1 sm:flex-none"
|
||||
>
|
||||
▶️ Activar
|
||||
</button>
|
||||
<button
|
||||
@click="deleteWorker(worker.name)"
|
||||
class="btn btn-danger text-xs sm:text-sm flex-1 sm:flex-none"
|
||||
>
|
||||
🗑️ Eliminar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para añadir/editar worker -->
|
||||
<div
|
||||
v-if="showAddModal || editingWorker"
|
||||
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="closeModal"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 sm:p-6 max-w-4xl 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">
|
||||
{{ editingWorker ? 'Editar Worker' : 'Añadir Worker' }}
|
||||
</h2>
|
||||
<form @submit.prevent="saveWorker" class="space-y-6">
|
||||
<!-- Información básica -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-3 sm:pb-4">
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Información Básica</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Nombre *</label>
|
||||
<input v-model="workerForm.name" type="text" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Plataforma</label>
|
||||
<select v-model="workerForm.platform" class="input">
|
||||
<option value="wallapop">Wallapop</option>
|
||||
<option value="vinted">Vinted</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Búsqueda *</label>
|
||||
<input v-model="workerForm.search_query" type="text" class="input" required placeholder="ej: playstation 1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Precios y Thread -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700 pb-3 sm:pb-4">
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-gray-100 mb-3 sm:mb-4">Precios y Notificaciones</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Precio Mínimo (€)</label>
|
||||
<input v-model.number="workerForm.min_price" type="number" class="input" min="0" step="0.01" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Precio Máximo (€)</label>
|
||||
<input v-model.number="workerForm.max_price" type="number" class="input" min="0" step="0.01" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Thread ID (Telegram)</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model.number="workerForm.thread_id" type="number" class="input flex-1" placeholder="Ej: 8" />
|
||||
<button
|
||||
type="button"
|
||||
@click="loadTelegramThreads"
|
||||
:disabled="loadingThreads"
|
||||
class="btn btn-secondary text-sm whitespace-nowrap"
|
||||
>
|
||||
{{ loadingThreads ? 'Cargando...' : '📋 Obtener Threads' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Opcional: ID del hilo donde enviar notificaciones</p>
|
||||
|
||||
<!-- Lista de threads disponibles -->
|
||||
<div v-if="availableThreads.length > 0" class="mt-2 p-2 bg-gray-50 dark:bg-gray-700 rounded border border-gray-200 dark:border-gray-600 max-h-40 overflow-y-auto">
|
||||
<p class="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">Threads disponibles:</p>
|
||||
<div
|
||||
v-for="thread in availableThreads"
|
||||
:key="thread.id"
|
||||
@click="selectThread(thread.id)"
|
||||
class="flex items-center justify-between p-2 mb-1 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-600 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ thread.name }}</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 ml-2">ID: {{ thread.id }}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click.stop="selectThread(thread.id)"
|
||||
class="text-xs text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
Usar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensaje informativo si no hay threads -->
|
||||
<div v-if="threadsMessage" class="mt-2 p-2 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded">
|
||||
<p class="text-xs text-blue-800 dark:text-blue-300">{{ threadsMessage }}</p>
|
||||
<p v-if="threadsInfo" class="text-xs text-blue-700 dark:text-blue-400 mt-1">{{ threadsInfo }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ubicación -->
|
||||
<div class="border-b border-gray-200 pb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Búsqueda Local (Opcional)</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Latitud</label>
|
||||
<input v-model.number="workerForm.latitude" type="number" class="input" step="any" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Longitud</label>
|
||||
<input v-model.number="workerForm.longitude" type="number" class="input" step="any" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Distancia Máxima (km)</label>
|
||||
<input v-model.number="workerForm.max_distance" type="number" class="input" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros de exclusión -->
|
||||
<div class="border-b border-gray-200 pb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Filtros de Exclusión</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Excluir palabras del título</label>
|
||||
<textarea
|
||||
v-model="workerForm.title_exclude_text"
|
||||
class="input"
|
||||
rows="3"
|
||||
placeholder="Una palabra por línea o separadas por comas"
|
||||
></textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Si aparece alguna palabra, se excluye el artículo</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Excluir palabras de la descripción</label>
|
||||
<textarea
|
||||
v-model="workerForm.description_exclude_text"
|
||||
class="input"
|
||||
rows="3"
|
||||
placeholder="Una palabra por línea o separadas por comas"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Excluir si primera palabra del título es</label>
|
||||
<textarea
|
||||
v-model="workerForm.title_first_word_exclude_text"
|
||||
class="input"
|
||||
rows="2"
|
||||
placeholder="Una palabra por línea o separadas por comas"
|
||||
></textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Ej: "Reacondicionado", "Vendido", etc.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros de inclusión -->
|
||||
<div class="border-b border-gray-200 pb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Filtros de Inclusión (Requeridos)</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Palabras requeridas en el título</label>
|
||||
<textarea
|
||||
v-model="workerForm.title_must_include_text"
|
||||
class="input"
|
||||
rows="3"
|
||||
placeholder="Una palabra por línea o separadas por comas"
|
||||
></textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">TODAS las palabras deben aparecer</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Palabras requeridas en la descripción</label>
|
||||
<textarea
|
||||
v-model="workerForm.description_must_include_text"
|
||||
class="input"
|
||||
rows="3"
|
||||
placeholder="Una palabra por línea o separadas por comas"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuración avanzada -->
|
||||
<div class="border-b border-gray-200 pb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">Configuración Avanzada</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Intervalo de verificación (segundos)</label>
|
||||
<input v-model.number="workerForm.check_every" type="number" class="input" min="1" />
|
||||
<p class="text-xs text-gray-500 mt-1">Cada cuántos segundos se actualiza la búsqueda (por defecto 30s)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col-reverse sm:flex-row justify-end gap-2 sm:space-x-2 pt-4">
|
||||
<button type="button" @click="closeModal" class="btn btn-secondary text-sm sm:text-base">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary text-sm sm:text-base">
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal para configuración general -->
|
||||
<div
|
||||
v-if="showGeneralModal"
|
||||
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="closeGeneralModal"
|
||||
>
|
||||
<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 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>
|
||||
<textarea
|
||||
v-model="generalForm.title_exclude_text"
|
||||
class="input"
|
||||
rows="4"
|
||||
placeholder="Una palabra por línea o separadas por comas"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Excluir palabras de la descripción (global)</label>
|
||||
<textarea
|
||||
v-model="generalForm.description_exclude_text"
|
||||
class="input"
|
||||
rows="4"
|
||||
placeholder="Una palabra por línea o separadas por comas"
|
||||
></textarea>
|
||||
</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="closeGeneralModal" class="btn btn-secondary text-sm sm:text-base">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary text-sm sm:text-base">
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 => {
|
||||
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 => {
|
||||
const workerId = w.id || w.worker_id;
|
||||
const workerName = w.name;
|
||||
return workers.value.disabled?.includes(workerId) || workers.value.disabled?.includes(workerName);
|
||||
}
|
||||
) || [];
|
||||
});
|
||||
|
||||
function activeWorkersIndex(index) {
|
||||
return workers.value.items?.findIndex(w => w.name === activeWorkers.value[index]?.name) ?? -1;
|
||||
}
|
||||
|
||||
function disabledWorkersIndex(index) {
|
||||
return workers.value.items?.findIndex(w => w.name === disabledWorkers.value[index]?.name) ?? -1;
|
||||
}
|
||||
|
||||
function hasFilters(worker) {
|
||||
return !!(worker.title_exclude?.length || worker.description_exclude?.length ||
|
||||
worker.title_must_include?.length || worker.description_must_include?.length ||
|
||||
worker.title_first_word_exclude?.length);
|
||||
}
|
||||
|
||||
function countFilters(worker) {
|
||||
let count = 0;
|
||||
if (worker.title_exclude?.length) count++;
|
||||
if (worker.description_exclude?.length) count++;
|
||||
if (worker.title_must_include?.length) count++;
|
||||
if (worker.description_must_include?.length) count++;
|
||||
if (worker.title_first_word_exclude?.length) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
function textToArray(text) {
|
||||
if (!text || !text.trim()) return [];
|
||||
return text.split(/\n|,/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
}
|
||||
|
||||
function arrayToText(arr) {
|
||||
if (!arr || !Array.isArray(arr) || arr.length === 0) return '';
|
||||
return arr.join('\n');
|
||||
}
|
||||
|
||||
const workerForm = ref({
|
||||
name: '',
|
||||
platform: 'wallapop',
|
||||
search_query: '',
|
||||
min_price: null,
|
||||
max_price: null,
|
||||
thread_id: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
max_distance: null,
|
||||
title_exclude_text: '',
|
||||
description_exclude_text: '',
|
||||
title_must_include_text: '',
|
||||
description_must_include_text: '',
|
||||
title_first_word_exclude_text: '',
|
||||
check_every: null,
|
||||
});
|
||||
|
||||
const generalForm = ref({
|
||||
title_exclude_text: '',
|
||||
description_exclude_text: '',
|
||||
});
|
||||
|
||||
const availableThreads = ref([]);
|
||||
const loadingThreads = ref(false);
|
||||
const threadsMessage = ref('');
|
||||
const threadsInfo = ref('');
|
||||
|
||||
async function loadWorkers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
workers.value = await api.getWorkers();
|
||||
// Actualizar formulario general
|
||||
generalForm.value = {
|
||||
title_exclude_text: arrayToText(workers.value.general?.title_exclude || []),
|
||||
description_exclude_text: arrayToText(workers.value.general?.description_exclude || []),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error cargando workers:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTelegramThreads() {
|
||||
loadingThreads.value = true;
|
||||
availableThreads.value = [];
|
||||
threadsMessage.value = '';
|
||||
threadsInfo.value = '';
|
||||
|
||||
try {
|
||||
const result = await api.getTelegramThreads();
|
||||
|
||||
if (result.success && result.threads && result.threads.length > 0) {
|
||||
availableThreads.value = result.threads;
|
||||
threadsMessage.value = '';
|
||||
threadsInfo.value = '';
|
||||
} else {
|
||||
availableThreads.value = [];
|
||||
threadsMessage.value = result.message || 'No se pudieron obtener los threads automáticamente';
|
||||
threadsInfo.value = result.info || '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error cargando threads de Telegram:', error);
|
||||
availableThreads.value = [];
|
||||
threadsMessage.value = 'Error al obtener threads de Telegram. Verifica que el bot y el canal estén configurados correctamente.';
|
||||
threadsInfo.value = 'Para obtener el Thread ID manualmente: 1. Haz clic derecho en el tema/hilo en Telegram 2. Selecciona "Copiar enlace del tema" 3. El número al final de la URL es el Thread ID (ej: t.me/c/1234567890/8 → Thread ID = 8)';
|
||||
} finally {
|
||||
loadingThreads.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectThread(threadId) {
|
||||
workerForm.value.thread_id = threadId;
|
||||
// Opcional: limpiar la lista después de seleccionar
|
||||
// availableThreads.value = [];
|
||||
}
|
||||
|
||||
function editWorker(worker, index) {
|
||||
editingWorker.value = { worker, index };
|
||||
workerForm.value = {
|
||||
name: worker.name || '',
|
||||
platform: worker.platform || 'wallapop',
|
||||
search_query: worker.search_query || '',
|
||||
min_price: worker.min_price || null,
|
||||
max_price: worker.max_price || null,
|
||||
thread_id: worker.thread_id || null,
|
||||
latitude: worker.latitude || null,
|
||||
longitude: worker.longitude || null,
|
||||
max_distance: worker.max_distance || null,
|
||||
title_exclude_text: arrayToText(worker.title_exclude || []),
|
||||
description_exclude_text: arrayToText(worker.description_exclude || []),
|
||||
title_must_include_text: arrayToText(worker.title_must_include || []),
|
||||
description_must_include_text: arrayToText(worker.description_must_include || []),
|
||||
title_first_word_exclude_text: arrayToText(worker.title_first_word_exclude || []),
|
||||
check_every: worker.check_every || null,
|
||||
};
|
||||
showAddModal.value = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showAddModal.value = false;
|
||||
editingWorker.value = null;
|
||||
workerForm.value = {
|
||||
name: '',
|
||||
platform: 'wallapop',
|
||||
search_query: '',
|
||||
min_price: null,
|
||||
max_price: null,
|
||||
thread_id: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
max_distance: null,
|
||||
title_exclude_text: '',
|
||||
description_exclude_text: '',
|
||||
title_must_include_text: '',
|
||||
description_must_include_text: '',
|
||||
title_first_word_exclude_text: '',
|
||||
check_every: null,
|
||||
};
|
||||
}
|
||||
|
||||
function closeGeneralModal() {
|
||||
showGeneralModal.value = false;
|
||||
}
|
||||
|
||||
async function saveWorker() {
|
||||
try {
|
||||
const updatedWorkers = {
|
||||
...workers.value,
|
||||
items: [...(workers.value.items || [])],
|
||||
disabled: [...(workers.value.disabled || [])],
|
||||
general: workers.value.general || {}
|
||||
};
|
||||
|
||||
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,
|
||||
...(workerForm.value.min_price !== null && { min_price: workerForm.value.min_price }),
|
||||
...(workerForm.value.max_price !== null && { max_price: workerForm.value.max_price }),
|
||||
...(workerForm.value.thread_id !== null && { thread_id: workerForm.value.thread_id }),
|
||||
...(workerForm.value.latitude !== null && { latitude: workerForm.value.latitude }),
|
||||
...(workerForm.value.longitude !== null && { longitude: workerForm.value.longitude }),
|
||||
...(workerForm.value.max_distance !== null && { max_distance: String(workerForm.value.max_distance) }),
|
||||
...(workerForm.value.check_every !== null && { check_every: workerForm.value.check_every }),
|
||||
...(textToArray(workerForm.value.title_exclude_text).length > 0 && { title_exclude: textToArray(workerForm.value.title_exclude_text) }),
|
||||
...(textToArray(workerForm.value.description_exclude_text).length > 0 && { description_exclude: textToArray(workerForm.value.description_exclude_text) }),
|
||||
...(textToArray(workerForm.value.title_must_include_text).length > 0 && { title_must_include: textToArray(workerForm.value.title_must_include_text) }),
|
||||
...(textToArray(workerForm.value.description_must_include_text).length > 0 && { description_must_include: textToArray(workerForm.value.description_must_include_text) }),
|
||||
...(textToArray(workerForm.value.title_first_word_exclude_text).length > 0 && { title_first_word_exclude: textToArray(workerForm.value.title_first_word_exclude_text) }),
|
||||
};
|
||||
|
||||
if (editingWorker.value) {
|
||||
// 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 con ID único
|
||||
updatedWorkers.items.push(workerData);
|
||||
}
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Error guardando worker:', error);
|
||||
alert('Error al guardar el worker');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGeneralConfig() {
|
||||
try {
|
||||
const updatedWorkers = {
|
||||
...workers.value,
|
||||
items: workers.value.items || [],
|
||||
disabled: workers.value.disabled || [],
|
||||
general: {
|
||||
...(textToArray(generalForm.value.title_exclude_text).length > 0 && { title_exclude: textToArray(generalForm.value.title_exclude_text) }),
|
||||
...(textToArray(generalForm.value.description_exclude_text).length > 0 && { description_exclude: textToArray(generalForm.value.description_exclude_text) }),
|
||||
}
|
||||
};
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
closeGeneralModal();
|
||||
} catch (error) {
|
||||
console.error('Error guardando configuración general:', error);
|
||||
alert('Error al guardar la configuración general');
|
||||
}
|
||||
}
|
||||
|
||||
async function disableWorker(worker) {
|
||||
const workerId = worker.id || worker.worker_id;
|
||||
const workerName = worker.name;
|
||||
if (!confirm(`¿Desactivar el worker "${workerName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedWorkers = {
|
||||
...workers.value,
|
||||
items: workers.value.items || [],
|
||||
disabled: [...(workers.value.disabled || [])]
|
||||
};
|
||||
|
||||
// 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);
|
||||
await loadWorkers();
|
||||
} catch (error) {
|
||||
console.error('Error desactivando worker:', error);
|
||||
alert('Error al desactivar el worker');
|
||||
}
|
||||
}
|
||||
|
||||
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(d => d !== workerId && d !== workerName && d !== worker.worker_id)
|
||||
};
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
} catch (error) {
|
||||
console.error('Error activando worker:', error);
|
||||
alert('Error al activar el worker');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorker(name) {
|
||||
if (!confirm(`¿Eliminar permanentemente el worker "${name}"? Esta acción no se puede deshacer.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedWorkers = {
|
||||
...workers.value,
|
||||
items: (workers.value.items || []).filter(w => w.name !== name),
|
||||
disabled: (workers.value.disabled || []).filter(n => n !== name),
|
||||
general: workers.value.general || {}
|
||||
};
|
||||
|
||||
await api.updateWorkers(updatedWorkers);
|
||||
await loadWorkers();
|
||||
} catch (error) {
|
||||
console.error('Error eliminando worker:', error);
|
||||
alert('Error al eliminar el worker');
|
||||
}
|
||||
}
|
||||
|
||||
function handleWSMessage(event) {
|
||||
const data = event.detail;
|
||||
if (data.type === 'workers_updated') {
|
||||
// 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>
|
||||
55
web/dashboard/tailwind.config.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f0f9ff',
|
||||
100: '#e0f2fe',
|
||||
200: '#bae6fd',
|
||||
300: '#7dd3fc',
|
||||
400: '#38bdf8',
|
||||
500: '#0ea5e9',
|
||||
600: '#0284c7',
|
||||
700: '#0369a1',
|
||||
800: '#075985',
|
||||
900: '#0c4a6e',
|
||||
},
|
||||
// Metronic-inspired color palette
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
boxShadow: {
|
||||
'sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
'DEFAULT': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
'md': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
},
|
||||
transitionDuration: {
|
||||
'DEFAULT': '200ms',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
27
web/dashboard/vite.config.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { fileURLToPath, URL } from 'url';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: '/dashboard/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:3001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||