Refactor worker management and enhance configuration options

- Updated docker-compose.yml to allow read-write access for workers.json.
- Introduced WorkerManager class in wallamonitor.py to manage workers dynamically based on workers.json configuration.
- Enhanced Worker class to support controlled start/stop operations and updated general arguments.
- Improved Workers.vue to include a general configuration modal and refined UI for active and disabled workers.
- Added functionality for global filters and improved worker editing capabilities.
- Implemented methods for saving general configuration and deleting workers.
This commit is contained in:
Omar Sánchez Pizarro
2026-01-19 21:00:59 +01:00
parent 06e763c7ab
commit c3ef3daf5e
5 changed files with 729 additions and 92 deletions

View File

@@ -2,9 +2,14 @@
<div>
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Gestión de Workers</h1>
<button @click="showAddModal = true" class="btn btn-primary">
+ Añadir Worker
</button>
<div class="flex space-x-2">
<button @click="showGeneralModal = true" class="btn btn-secondary">
Configuración General
</button>
<button @click="showAddModal = true" class="btn btn-primary">
+ Añadir Worker
</button>
</div>
</div>
<div v-if="loading" class="text-center py-12">
@@ -15,54 +20,103 @@
<div v-else class="space-y-4">
<!-- Workers activos -->
<div v-if="activeWorkers.length > 0">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Workers Activos</h2>
<h2 class="text-xl font-semibold text-gray-900 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"
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-2">
<div class="flex items-center space-x-2 mb-3">
<h3 class="text-lg font-semibold text-gray-900">{{ 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-2 md:grid-cols-4 gap-4 mt-4 text-sm">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm mb-3">
<div>
<span class="text-gray-600">Plataforma:</span>
<p class="font-medium">{{ worker.platform || 'wallapop' }}</p>
</div>
<div>
<span class="text-gray-600">Búsqueda:</span>
<span class="text-gray-600 block mb-1">Búsqueda:</span>
<p class="font-medium">{{ worker.search_query }}</p>
</div>
<div>
<span class="text-gray-600">Precio:</span>
<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 || 'N/A' }} - {{ worker.max_price || 'N/A' }}
{{ worker.min_price || '0' }} - {{ worker.max_price || '' }}
</p>
</div>
<div>
<span class="text-gray-600">Thread ID:</span>
<p class="font-medium">{{ worker.thread_id || 'General' }}</p>
<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">
<details class="text-xs">
<summary class="cursor-pointer text-gray-600 hover:text-gray-900 font-medium">
Ver filtros ({{ countFilters(worker) }})
</summary>
<div class="mt-2 space-y-1 text-gray-600">
<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 space-x-2 ml-4">
<button
@click="editWorker(worker, index)"
@click="editWorker(worker, activeWorkersIndex(index))"
class="btn btn-secondary text-sm"
>
Editar
Editar
</button>
<button
@click="deleteWorker(worker.name)"
class="btn btn-danger text-sm"
>
🗑 Eliminar
</button>
<button
@click="disableWorker(worker.name)"
class="btn btn-danger text-sm"
class="btn btn-secondary text-sm"
>
Desactivar
Desactivar
</button>
</div>
</div>
@@ -72,12 +126,12 @@
<!-- Workers desactivados -->
<div v-if="disabledWorkers.length > 0" class="mt-8">
<h2 class="text-xl font-semibold text-gray-900 mb-4">Workers Desactivados</h2>
<h2 class="text-xl font-semibold text-gray-900 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"
class="card opacity-60 hover:opacity-80 transition-opacity"
>
<div class="flex items-start justify-between">
<div class="flex-1">
@@ -86,14 +140,32 @@
<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-sm text-gray-600">{{ worker.search_query }}</p>
</div>
<div class="flex space-x-2 ml-4">
<button
@click="editWorker(worker, disabledWorkersIndex(index))"
class="btn btn-secondary text-sm"
>
Editar
</button>
<button
@click="enableWorker(worker.name)"
class="btn btn-primary text-sm"
>
Activar
</button>
<button
@click="deleteWorker(worker.name)"
class="btn btn-danger text-sm"
>
🗑 Eliminar
</button>
</div>
<button
@click="enableWorker(worker.name)"
class="btn btn-primary text-sm ml-4"
>
Activar
</button>
</div>
</div>
</div>
@@ -101,6 +173,9 @@
<div v-if="activeWorkers.length === 0 && disabledWorkers.length === 0" class="card text-center py-12">
<p class="text-gray-600">No hay workers configurados</p>
<button @click="showAddModal = true" class="btn btn-primary mt-4">
+ Crear primer worker
</button>
</div>
</div>
@@ -110,40 +185,144 @@
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
@click.self="closeModal"
>
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="bg-white rounded-lg p-6 max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-2xl font-bold text-gray-900 mb-4">
{{ editingWorker ? 'Editar Worker' : 'Añadir Worker' }}
</h2>
<form @submit.prevent="saveWorker" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 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 mb-1">Plataforma</label>
<select v-model="workerForm.platform" class="input">
<option value="wallapop">Wallapop</option>
<option value="vinted">Vinted</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Búsqueda</label>
<input v-model="workerForm.search_query" type="text" class="input" required />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Precio Mínimo</label>
<input v-model.number="workerForm.min_price" type="number" class="input" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Precio Máximo</label>
<input v-model.number="workerForm.max_price" type="number" class="input" />
<form @submit.prevent="saveWorker" class="space-y-6">
<!-- Información básica -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Información Básica</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 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 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 mb-1">Búsqueda *</label>
<input v-model="workerForm.search_query" type="text" class="input" required placeholder="ej: playstation 1" />
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Thread ID (opcional)</label>
<input v-model.number="workerForm.thread_id" type="number" class="input" />
<!-- Precios y Thread -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Precios y Notificaciones</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 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 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 mb-1">Thread ID (Telegram)</label>
<input v-model.number="workerForm.thread_id" type="number" class="input" />
<p class="text-xs text-gray-500 mt-1">Opcional: ID del hilo donde enviar notificaciones</p>
</div>
</div>
</div>
<!-- Ubicación -->
<div class="border-b border-gray-200 pb-4">
<h3 class="text-lg font-semibold text-gray-900 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 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 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 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 mb-4">Filtros de Exclusión</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 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 mt-1">Si aparece alguna palabra, se excluye el artículo</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 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 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 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 mb-4">Filtros de Inclusión (Requeridos)</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 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 mt-1">TODAS las palabras deben aparecer</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 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 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 justify-end space-x-2 pt-4">
<button type="button" @click="closeModal" class="btn btn-secondary">
Cancelar
@@ -155,6 +334,46 @@
</form>
</div>
</div>
<!-- Modal para configuración general -->
<div
v-if="showGeneralModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
@click.self="closeGeneralModal"
>
<div class="bg-white rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<h2 class="text-2xl font-bold text-gray-900 mb-4">Configuración General</h2>
<p class="text-sm text-gray-600 mb-4">Estas configuraciones se aplican a todos los workers</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 justify-end space-x-2 pt-4 border-t border-gray-200">
<button type="button" @click="closeGeneralModal" class="btn btn-secondary">
Cancelar
</button>
<button type="submit" class="btn btn-primary">
Guardar
</button>
</div>
</form>
</div>
</div>
</div>
</template>
@@ -165,6 +384,7 @@ import api from '../services/api';
const workers = ref({ items: [], disabled: [], general: {} });
const loading = ref(true);
const showAddModal = ref(false);
const showGeneralModal = ref(false);
const editingWorker = ref(null);
const activeWorkers = computed(() => {
@@ -179,6 +399,42 @@ const disabledWorkers = computed(() => {
) || [];
});
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',
@@ -186,12 +442,31 @@ const workerForm = ref({
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: '',
});
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 {
@@ -201,7 +476,23 @@ async function loadWorkers() {
function editWorker(worker, index) {
editingWorker.value = { worker, index };
workerForm.value = { ...worker };
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;
}
@@ -215,23 +506,54 @@ function closeModal() {
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 };
if (!updatedWorkers.items) {
updatedWorkers.items = [];
}
const workerData = {
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
const index = editingWorker.value.index;
updatedWorkers.items[index] = { ...workerForm.value };
updatedWorkers.items[index] = workerData;
} else {
// Añadir nuevo worker
if (!updatedWorkers.items) {
updatedWorkers.items = [];
}
updatedWorkers.items.push({ ...workerForm.value });
updatedWorkers.items.push(workerData);
}
await api.updateWorkers(updatedWorkers);
@@ -243,6 +565,23 @@ async function saveWorker() {
}
}
async function saveGeneralConfig() {
try {
const updatedWorkers = { ...workers.value };
updatedWorkers.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(name) {
if (!confirm(`¿Desactivar el worker "${name}"?`)) {
return;
@@ -256,7 +595,7 @@ async function disableWorker(name) {
if (!updatedWorkers.disabled.includes(name)) {
updatedWorkers.disabled.push(name);
}
await api.updateWorkers(updatedWorkers);
await api.updateWshowGeneralModalorkers(updatedWorkers);
await loadWorkers();
} catch (error) {
console.error('Error desactivando worker:', error);
@@ -278,10 +617,34 @@ async function enableWorker(name) {
}
}
async function deleteWorker(name) {
if (!confirm(`¿Eliminar permanentemente el worker "${name}"? Esta acción no se puede deshacer.`)) {
return;
}
try {
const updatedWorkers = { ...workers.value };
updatedWorkers.items = updatedWorkers.items.filter(w => w.name !== name);
if (updatedWorkers.disabled) {
updatedWorkers.disabled = updatedWorkers.disabled.filter(n => n !== name);
}
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') {
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 || []),
};
}
}
@@ -294,4 +657,3 @@ onUnmounted(() => {
window.removeEventListener('ws-message', handleWSMessage);
});
</script>