Files
wallabicher/web/dashboard/src/views/Workers.vue
Omar Sánchez Pizarro 6ec8855c00 add landing and subscription plans
Signed-off-by: Omar Sánchez Pizarro <omar.sanchez@pistacero.net>
2026-01-20 23:49:19 +01:00

805 lines
35 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>