Add Telegram listener, scheduler, and extended parsers admin UI.
Introduce background scheduling and migrations for analytics ingest, expand parser management and map toolbar UX, and ignore local data/session files from version control. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, watch } from "vue";
|
||||
import { CITY_PRESETS } from "../../config/cities";
|
||||
import type { LeafletMapApi } from "../../composables/useLeafletMap";
|
||||
import { parseCoordsInput } from "../../composables/usePlaceSearch";
|
||||
@@ -16,13 +16,13 @@ function formatCoords(lat: number, lng: number): string {
|
||||
return `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
|
||||
}
|
||||
|
||||
async function copyText(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => props.centerCoords,
|
||||
(coords) => {
|
||||
coordsInput.value = formatCoords(coords.lat, coords.lng);
|
||||
},
|
||||
{ immediate: true, deep: true },
|
||||
);
|
||||
|
||||
function centerOnInput() {
|
||||
const parsed = parseCoordsInput(coordsInput.value);
|
||||
@@ -36,37 +36,37 @@ function centerOnCity() {
|
||||
props.mapApi.flyTo(city.latitude, city.longitude, city.zoom ?? 12);
|
||||
}
|
||||
|
||||
function copyCenter() {
|
||||
void copyText(formatCoords(props.centerCoords.lat, props.centerCoords.lng));
|
||||
}
|
||||
|
||||
function copyInput() {
|
||||
void copyText(coordsInput.value);
|
||||
function clearCoords() {
|
||||
coordsInput.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="coords-tools">
|
||||
<label class="coords-label">Центрировать на:</label>
|
||||
<input
|
||||
v-model="coordsInput"
|
||||
type="text"
|
||||
class="coord-input"
|
||||
placeholder="48.65, 37.67"
|
||||
@keydown.enter="centerOnInput"
|
||||
/>
|
||||
<button type="button" class="icon-btn" title="Перейти" @click="centerOnInput">➜</button>
|
||||
<button type="button" class="icon-btn" title="Копировать" @click="copyInput">⎘</button>
|
||||
<div class="coords-tools toolbar-group">
|
||||
<div class="coord-field">
|
||||
<input
|
||||
v-model="coordsInput"
|
||||
type="text"
|
||||
class="coord-input"
|
||||
placeholder="lat, lng"
|
||||
title="Координаты центра"
|
||||
@keydown.enter="centerOnInput"
|
||||
/>
|
||||
<button
|
||||
v-if="coordsInput"
|
||||
type="button"
|
||||
class="coord-clear"
|
||||
title="Очистить"
|
||||
@click="clearCoords"
|
||||
>×</button>
|
||||
</div>
|
||||
<button type="button" class="icon-btn" title="Перейти к координатам" @click="centerOnInput">➜</button>
|
||||
|
||||
<select v-model="selectedCity" class="city-select" @change="centerOnCity">
|
||||
<option value="" disabled>🏘 Город</option>
|
||||
<select v-model="selectedCity" class="city-select" title="Город" @change="centerOnCity">
|
||||
<option value="" disabled>🏘</option>
|
||||
<option v-for="city in CITY_PRESETS" :key="city.name" :value="city.name">
|
||||
{{ city.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<span class="current-center-label">Текущий центр:</span>
|
||||
<span class="current-coords">{{ formatCoords(centerCoords.lat, centerCoords.lng) }}</span>
|
||||
<button type="button" class="icon-btn" title="Копировать центр" @click="copyCenter">⎘</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -21,6 +21,8 @@ const props = defineProps<{
|
||||
topic: string;
|
||||
sourceType: string;
|
||||
loading?: boolean;
|
||||
statusText?: string;
|
||||
statusError?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -33,8 +35,10 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const dateInput = ref<HTMLInputElement | null>(null);
|
||||
const rangeBtnRef = ref<HTMLButtonElement | null>(null);
|
||||
const rangeOpen = ref(false);
|
||||
const filtersOpen = ref(false);
|
||||
const dropdownPos = ref({ top: 0, left: 0 });
|
||||
let picker: flatpickr.Instance | null = null;
|
||||
|
||||
const availableDates = computed(() => props.filters?.available_dates ?? []);
|
||||
@@ -74,6 +78,31 @@ function selectRange(preset: DateRangePreset) {
|
||||
emit("apply");
|
||||
}
|
||||
|
||||
function toggleRangeOpen(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
if (!rangeOpen.value && rangeBtnRef.value) {
|
||||
const rect = rangeBtnRef.value.getBoundingClientRect();
|
||||
dropdownPos.value = { top: rect.bottom + 4, left: rect.left };
|
||||
}
|
||||
rangeOpen.value = !rangeOpen.value;
|
||||
if (rangeOpen.value) {
|
||||
filtersOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleFiltersOpen(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
filtersOpen.value = !filtersOpen.value;
|
||||
if (filtersOpen.value) {
|
||||
rangeOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDropdowns() {
|
||||
rangeOpen.value = false;
|
||||
filtersOpen.value = false;
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
emit("update:region", "");
|
||||
emit("update:topic", "");
|
||||
@@ -82,6 +111,8 @@ function resetFilters() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("click", closeDropdowns);
|
||||
|
||||
if (!dateInput.value) return;
|
||||
picker = flatpickr(dateInput.value, {
|
||||
locale: Russian,
|
||||
@@ -101,6 +132,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("click", closeDropdowns);
|
||||
picker?.destroy();
|
||||
});
|
||||
|
||||
@@ -115,7 +147,8 @@ watch(
|
||||
|
||||
<template>
|
||||
<div class="map-toolbar">
|
||||
<div class="date-navigator">
|
||||
<div class="map-toolbar-inner">
|
||||
<div class="toolbar-group date-navigator">
|
||||
<button type="button" class="nav-btn" title="Первая дата" @click="navigateDate('first')">❮❮</button>
|
||||
<button type="button" class="nav-btn" title="Предыдущий" @click="navigateDate('prev')">❮</button>
|
||||
<div class="date-selector">
|
||||
@@ -126,34 +159,46 @@ watch(
|
||||
|
||||
<div class="filter-btn-container">
|
||||
<button
|
||||
ref="rangeBtnRef"
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
title="Период"
|
||||
:class="{ active: rangePreset !== 'all' }"
|
||||
@click="rangeOpen = !rangeOpen"
|
||||
@click="toggleRangeOpen"
|
||||
>⏳</button>
|
||||
<div v-if="rangeOpen" class="dropdown-content">
|
||||
<button
|
||||
v-for="opt in RANGE_OPTIONS"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="range-option"
|
||||
:class="{ active: rangePreset === opt.value }"
|
||||
@click="selectRange(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<button type="button" class="filter-btn mobile-toggle" @click="filtersOpen = !filtersOpen">📁</button>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="rangeOpen"
|
||||
class="dropdown-content range-dropdown dropdown-fixed"
|
||||
:style="{ top: `${dropdownPos.top}px`, left: `${dropdownPos.left}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
v-for="opt in RANGE_OPTIONS"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
class="range-option"
|
||||
:class="{ active: rangePreset === opt.value }"
|
||||
@click="selectRange(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="filter-controls" :class="{ open: filtersOpen }">
|
||||
<div class="toolbar-sep" />
|
||||
|
||||
<div class="toolbar-group filter-row">
|
||||
<button type="button" class="filter-btn mobile-toggle" title="Фильтры" @click="toggleFiltersOpen">📁</button>
|
||||
|
||||
<div class="filter-controls" :class="{ open: filtersOpen }" @click.stop>
|
||||
<select
|
||||
:value="region"
|
||||
class="filter-select"
|
||||
title="Регион"
|
||||
@change="emit('update:region', ($event.target as HTMLSelectElement).value); emit('apply')"
|
||||
>
|
||||
<option value="">Все регионы</option>
|
||||
@@ -163,6 +208,7 @@ watch(
|
||||
<select
|
||||
:value="topic"
|
||||
class="filter-select"
|
||||
title="Тема"
|
||||
@change="emit('update:topic', ($event.target as HTMLSelectElement).value); emit('apply')"
|
||||
>
|
||||
<option value="">Все темы</option>
|
||||
@@ -172,6 +218,7 @@ watch(
|
||||
<select
|
||||
:value="sourceType"
|
||||
class="filter-select"
|
||||
title="Источник"
|
||||
@change="emit('update:sourceType', ($event.target as HTMLSelectElement).value); emit('apply')"
|
||||
>
|
||||
<option value="">Все источники</option>
|
||||
@@ -180,8 +227,22 @@ watch(
|
||||
|
||||
<button type="button" class="btn btn-sm" @click="resetFilters">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span v-if="loading" class="toolbar-status">Загрузка...</span>
|
||||
<div class="toolbar-sep" />
|
||||
|
||||
<slot name="coords" />
|
||||
|
||||
<div class="toolbar-sep" />
|
||||
|
||||
<slot name="search" />
|
||||
|
||||
<span
|
||||
v-if="statusText"
|
||||
class="toolbar-status"
|
||||
:class="{ 'toolbar-status-error': statusError }"
|
||||
>{{ statusText }}</span>
|
||||
<span v-else-if="loading" class="toolbar-status">Загрузка...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -30,12 +30,13 @@ function selectPlace(place: PlaceSearchResult) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="place-search">
|
||||
<div class="place-search toolbar-group">
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Поиск населённого пункта"
|
||||
placeholder="Поиск НП"
|
||||
title="Поиск населённого пункта"
|
||||
@keydown.enter="runSearch"
|
||||
/>
|
||||
<button type="button" class="icon-btn" title="Найти" :disabled="searching" @click="runSearch">
|
||||
|
||||
Reference in New Issue
Block a user