Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
This commit is contained in:
Omar Sánchez Pizarro
2026-01-21 10:31:06 +01:00
parent c72ef29319
commit 7289ad6c26
10 changed files with 977 additions and 71 deletions

View File

@@ -34,64 +34,54 @@
<div
v-for="user in users"
:key="user.username"
class="card hover:shadow-lg transition-shadow"
class="card hover:shadow-lg transition-shadow overflow-hidden"
>
<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"
>
</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>
<!-- Header con nombre y badges -->
<div class="flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3">
<!-- Avatar -->
<div class="w-12 h-12 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white font-bold text-lg">
{{ user.username.charAt(0).toUpperCase() }}
</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"
<div>
<div class="flex items-center gap-2 mb-1">
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">{{ user.username }}</h3>
<span
v-if="user.username === currentUser"
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200"
>
{{ userSubscriptions[user.username].subscription?.status === 'active' ? 'Activo' : 'Inactivo' }}
</span>
<span
v-if="user.role === 'admin'"
class="px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200"
>
Admin
</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>
<!-- Estado de conexión inline -->
<div v-if="isAdmin" class="flex items-center gap-2">
<span
:class="{
'px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200': getUserConnectionStatus(user.username) === 'active',
'px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200': getUserConnectionStatus(user.username) === 'inactive',
'px-2 py-0.5 text-xs font-semibold rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300': getUserConnectionStatus(user.username) === 'offline'
}"
>
{{ getUserConnectionStatus(user.username) === 'active' ? '🟢 Conectado' : getUserConnectionStatus(user.username) === 'inactive' ? '🟡 Inactivo' : '⚫ Desconectado' }}
</span>
<span v-if="getUserLastActivity(user.username)" class="text-xs text-gray-500 dark:text-gray-400">
{{ formatRelativeTime(getUserLastActivity(user.username)) }}
</span>
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<!-- Botones de acción -->
<div class="flex gap-2">
<button
v-if="isAdmin"
@click="openSubscriptionModal(user.username)"
@@ -110,6 +100,73 @@
</button>
</div>
</div>
<!-- Grid de información -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Columna izquierda: Información general -->
<div class="space-y-3">
<div>
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Información</h4>
<div class="space-y-2 text-sm">
<div v-if="user.createdAt" class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Creado:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(user.createdAt) }}</span>
</div>
<div v-if="user.createdBy" class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Por:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ user.createdBy }}</span>
</div>
<div v-if="user.updatedAt" class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Actualizado:</span>
<span class="font-medium text-gray-900 dark:text-gray-100">{{ formatDate(user.updatedAt) }}</span>
</div>
<div v-if="isAdmin && getUserSessionCount(user.username) > 0" class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">Sesiones activas:</span>
<span class="font-semibold text-primary-600 dark:text-primary-400">{{ getUserSessionCount(user.username) }}</span>
</div>
</div>
</div>
</div>
<!-- Columna derecha: Suscripción -->
<div>
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase mb-2">Suscripción</h4>
<div v-if="userSubscriptions[user.username]" class="p-3 bg-gradient-to-br 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-700">
<div class="flex items-center justify-between mb-2">
<div>
<p class="text-xs text-gray-600 dark:text-gray-400">Plan</p>
<p class="text-lg font-bold text-primary-700 dark:text-primary-300">
{{ userSubscriptions[user.username].subscription?.plan?.name || 'Gratis' }}
</p>
</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="pt-2 border-t border-primary-200 dark:border-primary-800">
<div class="flex justify-between items-center">
<span class="text-xs text-gray-600 dark:text-gray-400">Uso de búsquedas</span>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ userSubscriptions[user.username].usage.workers }} / {{ userSubscriptions[user.username].usage.maxWorkers === 'Ilimitado' ? '∞' : userSubscriptions[user.username].usage.maxWorkers }}
</span>
</div>
<!-- Barra de progreso -->
<div v-if="userSubscriptions[user.username].usage.maxWorkers !== 'Ilimitado'" class="mt-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-gradient-to-r from-primary-500 to-primary-600 h-2 rounded-full transition-all"
:style="{ width: `${Math.min(100, (userSubscriptions[user.username].usage.workers / userSubscriptions[user.username].usage.maxWorkers) * 100)}%` }"
></div>
</div>
</div>
</div>
<div v-else class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500 dark:text-gray-400">
Sin información de suscripción
</div>
</div>
</div>
</div>
</div>
@@ -419,6 +476,8 @@ const availablePlans = ref([]);
const subscriptionError = ref('');
const subscriptionSuccess = ref('');
const addError = ref('');
const activeUsers = ref([]);
const userSessions = ref({});
const userForm = ref({
username: '',
@@ -458,6 +517,80 @@ function formatDate(dateString) {
}
}
function formatRelativeTime(timestamp) {
if (!timestamp) return 'Nunca';
const date = new Date(timestamp);
const now = new Date();
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return 'Hace unos segundos';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `Hace ${minutes} ${minutes === 1 ? 'minuto' : 'minutos'}`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `Hace ${hours} ${hours === 1 ? 'hora' : 'horas'}`;
} else {
const days = Math.floor(diffInSeconds / 86400);
return `Hace ${days} ${days === 1 ? 'día' : 'días'}`;
}
}
// Obtener el estado de conexión de un usuario
function getUserConnectionStatus(username) {
// Buscar si el usuario tiene alguna sesión activa
const activeSessions = userSessions.value[username] || [];
if (activeSessions.length === 0) {
return 'offline';
}
// Verificar si alguna sesión está activamente conectada
const hasActiveSession = activeSessions.some(session => session.isActive);
if (hasActiveSession) {
return 'active';
}
// Si tiene sesiones válidas pero ninguna activa
const hasValidSession = activeSessions.some(session => !session.isExpired);
if (hasValidSession) {
return 'inactive';
}
return 'offline';
}
// Obtener la última actividad de un usuario (de todas sus sesiones)
function getUserLastActivity(username) {
const sessions = userSessions.value[username] || [];
if (sessions.length === 0) {
return null;
}
// Encontrar la sesión con la actividad más reciente
let latestActivity = null;
for (const session of sessions) {
if (session.lastActivity) {
const activityDate = new Date(session.lastActivity);
if (!latestActivity || activityDate > latestActivity) {
latestActivity = activityDate;
}
}
}
return latestActivity ? latestActivity.toISOString() : null;
}
// Obtener el número de sesiones activas de un usuario
function getUserSessionCount(username) {
const sessions = userSessions.value[username] || [];
return sessions.filter(s => !s.isExpired).length;
}
async function loadUsers() {
loading.value = true;
try {
@@ -466,7 +599,10 @@ async function loadUsers() {
// Cargar información de suscripción para todos los usuarios (solo si es admin)
if (isAdmin.value) {
await loadAllUserSubscriptions();
await Promise.all([
loadAllUserSubscriptions(),
loadUserSessions(),
]);
}
} catch (error) {
console.error('Error cargando usuarios:', error);
@@ -476,6 +612,29 @@ async function loadUsers() {
}
}
async function loadUserSessions() {
if (!isAdmin.value) return;
try {
// Obtener todas las sesiones
const data = await api.getSessions();
const sessions = data.sessions || [];
// Agrupar sesiones por usuario
const sessionsByUser = {};
for (const session of sessions) {
if (!sessionsByUser[session.username]) {
sessionsByUser[session.username] = [];
}
sessionsByUser[session.username].push(session);
}
userSessions.value = sessionsByUser;
} catch (error) {
console.error('Error cargando sesiones de usuarios:', error);
}
}
async function loadAllUserSubscriptions() {
// Cargar suscripciones de todos los usuarios en paralelo
const subscriptionPromises = users.value.map(async (user) => {
@@ -489,10 +648,31 @@ async function loadAllUserSubscriptions() {
});
if (response.ok) {
const data = await response.json();
console.log(`Suscripción de ${user.username}:`, data);
userSubscriptions.value[user.username] = data;
} else {
console.warn(`No se pudo cargar suscripción de ${user.username}, usando valores por defecto`);
// Valores por defecto si no se puede cargar
userSubscriptions.value[user.username] = {
subscription: {
planId: 'free',
status: 'active',
plan: { name: 'Gratis', description: 'Plan gratuito' }
},
usage: { workers: 0, maxWorkers: 2 }
};
}
} catch (error) {
console.error(`Error cargando suscripción de ${user.username}:`, error);
// Valores por defecto en caso de error
userSubscriptions.value[user.username] = {
subscription: {
planId: 'free',
status: 'active',
plan: { name: 'Gratis', description: 'Plan gratuito' }
},
usage: { workers: 0, maxWorkers: 2 }
};
}
});
await Promise.all(subscriptionPromises);
@@ -687,8 +867,22 @@ function handleAuthLogout() {
showAddModal.value = false;
userToDelete.value = null;
addError.value = '';
activeUsers.value = [];
userSessions.value = {};
}
// Manejar cambios de estado de usuarios vía WebSocket
function handleUserStatusChange(event) {
const { username, status } = event.detail;
// Recargar sesiones para actualizar el estado
if (isAdmin.value) {
loadUserSessions();
}
}
let refreshInterval = null;
onMounted(() => {
loadUsers();
window.addEventListener('auth-logout', handleAuthLogout);
@@ -696,11 +890,26 @@ onMounted(() => {
window.addEventListener('auth-login', () => {
loadUsers();
});
// Escuchar cambios de estado de usuarios
window.addEventListener('user-status-change', handleUserStatusChange);
// Actualizar sesiones periódicamente (cada 30 segundos)
if (isAdmin.value) {
refreshInterval = setInterval(() => {
loadUserSessions();
}, 30000);
}
});
onUnmounted(() => {
window.removeEventListener('auth-logout', handleAuthLogout);
window.removeEventListener('auth-login', loadUsers);
window.removeEventListener('user-status-change', handleUserStatusChange);
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
</script>