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:
@@ -9,6 +9,7 @@ import type {
|
||||
EventRecord,
|
||||
ParseJob,
|
||||
ParseJobCreate,
|
||||
ParseJobUpdate,
|
||||
TimelinePoint,
|
||||
TopItem,
|
||||
} from "../types/admin";
|
||||
@@ -44,6 +45,22 @@ export function retryJob(jobId: number): Promise<ParseJob> {
|
||||
);
|
||||
}
|
||||
|
||||
export function updateJob(jobId: number, payload: ParseJobUpdate): Promise<ParseJob> {
|
||||
return request<ParseJob>(
|
||||
`/jobs/${jobId}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteJob(jobId: number): Promise<void> {
|
||||
return request<void>(
|
||||
`/jobs/${jobId}`,
|
||||
{ method: "DELETE" },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchEvents(filters: EventFilters = {}): Promise<EventListResponse> {
|
||||
return request<EventListResponse>(
|
||||
`/events${buildQuery(filters as Record<string, string | number | undefined>)}`,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,33 +1,62 @@
|
||||
.map-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
position: relative;
|
||||
z-index: 50;
|
||||
flex-shrink: 0;
|
||||
background: #d4d4d4;
|
||||
border-bottom: 1px solid #b0b0b0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.map-toolbar-inner {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
min-height: 38px;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-sep {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: #aaa;
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.15rem;
|
||||
}
|
||||
|
||||
.date-navigator {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-btn,
|
||||
.filter-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #f0f0f0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 3px;
|
||||
background: #ececec;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-btn:hover,
|
||||
@@ -36,22 +65,37 @@
|
||||
}
|
||||
|
||||
.date-selector {
|
||||
width: 90px;
|
||||
width: 82px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.date-picker-input {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.filter-btn-container {
|
||||
position: relative;
|
||||
z-index: 60;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 1100;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
@@ -59,17 +103,13 @@
|
||||
background: #e6f2ff;
|
||||
}
|
||||
|
||||
.dropdown-content {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
margin-top: 4px;
|
||||
min-width: 140px;
|
||||
.range-dropdown {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dropdown-fixed {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.range-option {
|
||||
@@ -94,88 +134,127 @@
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
height: 30px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
max-width: 130px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.filter-select option {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.toolbar-status {
|
||||
margin-left: auto;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
padding-left: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #555;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-status-error {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.coords-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 0.85rem;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.coords-label,
|
||||
.current-center-label {
|
||||
color: #555;
|
||||
.coord-field {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coord-input,
|
||||
.search-input {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
width: 150px;
|
||||
padding: 0 22px 0 8px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.coord-input {
|
||||
width: 200px;
|
||||
font-family: ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.coord-clear {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.coord-clear:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.city-select {
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.current-coords {
|
||||
font-family: ui-monospace, monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
max-width: 110px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 3px;
|
||||
font-size: 0.78rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #f0f0f0;
|
||||
min-width: 28px;
|
||||
border: 1px solid #aaa;
|
||||
border-radius: 3px;
|
||||
background: #ececec;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.place-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.25rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -194,6 +273,7 @@
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.result-btn {
|
||||
@@ -222,16 +302,6 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.map-tools-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.map-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -240,35 +310,67 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-status {
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #eee;
|
||||
.map-toolbar .btn-sm {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.map-status.error {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1100px) {
|
||||
.filter-controls {
|
||||
display: none;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-controls.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
@media (min-width: 1101px) {
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.coord-input {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 110px;
|
||||
}
|
||||
|
||||
.city-select {
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.toolbar-sep {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface ParseJob {
|
||||
source_type: string;
|
||||
source_config: Record<string, unknown>;
|
||||
schedule: string | null;
|
||||
interval_seconds: number;
|
||||
is_active: boolean;
|
||||
status: string;
|
||||
last_run_at: string | null;
|
||||
last_error: string | null;
|
||||
@@ -13,6 +15,14 @@ export interface ParseJobCreate {
|
||||
source_type: string;
|
||||
source_config: Record<string, unknown>;
|
||||
schedule?: string | null;
|
||||
interval_seconds?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ParseJobUpdate {
|
||||
source_config?: Record<string, unknown>;
|
||||
interval_seconds?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface EventRecord {
|
||||
|
||||
@@ -309,16 +309,17 @@ onUnmounted(() => {
|
||||
v-model:source-type="sourceType"
|
||||
:filters="mapFilters"
|
||||
:loading="loading"
|
||||
:status-text="error || `${objects.length} объектов`"
|
||||
:status-error="Boolean(error)"
|
||||
@apply="loadObjects"
|
||||
/>
|
||||
|
||||
<div class="map-tools-row">
|
||||
<CoordsTools :map-api="mapApi" :center-coords="centerCoords" />
|
||||
<PlaceSearch :map-api="mapApi" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="map-status error">{{ error }}</div>
|
||||
<div v-else class="map-status">{{ objects.length }} объектов на карте</div>
|
||||
>
|
||||
<template #coords>
|
||||
<CoordsTools :map-api="mapApi" :center-coords="centerCoords" />
|
||||
</template>
|
||||
<template #search>
|
||||
<PlaceSearch :map-api="mapApi" />
|
||||
</template>
|
||||
</MapToolbar>
|
||||
|
||||
<div class="map-content">
|
||||
<MapView
|
||||
|
||||
@@ -1,21 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from "vue";
|
||||
import { createJob, fetchJobs, retryJob } from "../api/admin";
|
||||
import { createJob, deleteJob, fetchJobs, retryJob, updateJob } from "../api/admin";
|
||||
import type { ParseJob } from "../types/admin";
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 900, label: "15 мин" },
|
||||
{ value: 1800, label: "30 мин" },
|
||||
{ value: 3600, label: "1 ч" },
|
||||
{ value: 10800, label: "3 ч" },
|
||||
{ value: 21600, label: "6 ч" },
|
||||
{ value: 86400, label: "24 ч" },
|
||||
];
|
||||
|
||||
const jobs = ref<ParseJob[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
const editingJob = ref<ParseJob | null>(null);
|
||||
const editForm = ref({
|
||||
channel: "",
|
||||
limit: 50,
|
||||
interval_seconds: 3600,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const form = ref({
|
||||
source_type: "telegram",
|
||||
channel: "",
|
||||
limit: 50,
|
||||
interval_seconds: 3600,
|
||||
});
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function channelFromConfig(config: Record<string, unknown>): string {
|
||||
return typeof config.channel === "string" ? config.channel : "";
|
||||
}
|
||||
|
||||
function limitFromConfig(config: Record<string, unknown>): number {
|
||||
const limit = config.limit;
|
||||
return typeof limit === "number" ? limit : 50;
|
||||
}
|
||||
|
||||
function formatInterval(seconds: number): string {
|
||||
const opt = INTERVAL_OPTIONS.find((item) => item.value === seconds);
|
||||
if (opt) return opt.label;
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)} мин`;
|
||||
if (seconds < 86400) return `${Math.round(seconds / 3600)} ч`;
|
||||
return `${Math.round(seconds / 86400)} д`;
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
jobs.value = await fetchJobs();
|
||||
@@ -42,6 +76,8 @@ async function handleSubmit() {
|
||||
channel: form.value.channel.trim(),
|
||||
limit: form.value.limit,
|
||||
},
|
||||
interval_seconds: form.value.interval_seconds,
|
||||
is_active: true,
|
||||
});
|
||||
form.value.channel = "";
|
||||
await loadJobs();
|
||||
@@ -52,6 +88,61 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(job: ParseJob) {
|
||||
editingJob.value = job;
|
||||
editForm.value = {
|
||||
channel: channelFromConfig(job.source_config),
|
||||
limit: limitFromConfig(job.source_config),
|
||||
interval_seconds: job.interval_seconds,
|
||||
is_active: job.is_active,
|
||||
};
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
editingJob.value = null;
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!editingJob.value) return;
|
||||
if (!editForm.value.channel.trim()) {
|
||||
error.value = "Укажите канал Telegram";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await updateJob(editingJob.value.id, {
|
||||
source_config: {
|
||||
channel: editForm.value.channel.trim(),
|
||||
limit: editForm.value.limit,
|
||||
},
|
||||
interval_seconds: editForm.value.interval_seconds,
|
||||
is_active: editForm.value.is_active,
|
||||
});
|
||||
closeEdit();
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось сохранить задание";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(job: ParseJob) {
|
||||
if (!window.confirm(`Удалить парсер #${job.id} (${channelFromConfig(job.source_config)})?`)) {
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
try {
|
||||
await deleteJob(job.id);
|
||||
if (editingJob.value?.id === job.id) closeEdit();
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось удалить задание";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetry(jobId: number) {
|
||||
try {
|
||||
await retryJob(jobId);
|
||||
@@ -88,7 +179,12 @@ onUnmounted(() => {
|
||||
<h2 class="page-heading">Парсеры</h2>
|
||||
|
||||
<section class="card">
|
||||
<h3>Новое задание</h3>
|
||||
<h3>Новый парсер</h3>
|
||||
<p class="hint">
|
||||
Активные парсеры подхватываются real-time listener (новые посты сразу в БД).
|
||||
Дополнительно batch-прогон по интервалу забирает последние N постов.
|
||||
Дубликаты по <code>source_url</code> не записываются.
|
||||
</p>
|
||||
<form class="admin-form" @submit.prevent="handleSubmit">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
@@ -105,10 +201,18 @@ onUnmounted(() => {
|
||||
Лимит
|
||||
<input v-model.number="form.limit" type="number" min="1" max="1000" />
|
||||
</label>
|
||||
<label>
|
||||
Интервал
|
||||
<select v-model.number="form.interval_seconds">
|
||||
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Создание..." : "Создать задание" }}
|
||||
{{ submitting ? "Создание..." : "Создать парсер" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -116,14 +220,16 @@ onUnmounted(() => {
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Задания <span v-if="loading" class="muted">(загрузка...)</span></h3>
|
||||
<h3>Парсеры <span v-if="loading" class="muted">(загрузка...)</span></h3>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Источник</th>
|
||||
<th>Конфиг</th>
|
||||
<th>Канал</th>
|
||||
<th>Лимит</th>
|
||||
<th>Интервал</th>
|
||||
<th>Активен</th>
|
||||
<th>Статус</th>
|
||||
<th>Последний запуск</th>
|
||||
<th>Ошибка</th>
|
||||
@@ -132,30 +238,122 @@ onUnmounted(() => {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="jobs.length === 0 && !loading">
|
||||
<td colspan="7" class="empty">Нет заданий</td>
|
||||
<td colspan="9" class="empty">Нет парсеров</td>
|
||||
</tr>
|
||||
<tr v-for="job in jobs" :key="job.id">
|
||||
<td>{{ job.id }}</td>
|
||||
<td>{{ job.source_type }}</td>
|
||||
<td class="mono">{{ JSON.stringify(job.source_config) }}</td>
|
||||
<td>{{ channelFromConfig(job.source_config) }}</td>
|
||||
<td>{{ limitFromConfig(job.source_config) }}</td>
|
||||
<td>{{ formatInterval(job.interval_seconds) }}</td>
|
||||
<td>{{ job.is_active ? "да" : "нет" }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="statusClass(job.status)">{{ job.status }}</span>
|
||||
</td>
|
||||
<td>{{ formatDate(job.last_run_at) }}</td>
|
||||
<td class="error-cell">{{ job.last_error ?? "—" }}</td>
|
||||
<td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(job)">Изменить</button>
|
||||
<button
|
||||
v-if="job.status === 'failed' || job.status === 'error'"
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
@click="handleRetry(job.id)"
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="job.status === 'running'"
|
||||
@click="handleDelete(job)"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="editingJob" class="modal-backdrop" @click.self="closeEdit">
|
||||
<div class="modal card">
|
||||
<h3>Редактировать парсер #{{ editingJob.id }}</h3>
|
||||
<form class="admin-form" @submit.prevent="handleSaveEdit">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Канал
|
||||
<input v-model="editForm.channel" type="text" required />
|
||||
</label>
|
||||
<label>
|
||||
Лимит
|
||||
<input v-model.number="editForm.limit" type="number" min="1" max="1000" />
|
||||
</label>
|
||||
<label>
|
||||
Интервал
|
||||
<select v-model.number="editForm.interval_seconds">
|
||||
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input v-model="editForm.is_active" type="checkbox" />
|
||||
<span>Активен (периодический запуск)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" @click="closeEdit">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Сохранение..." : "Сохранить" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions-cell .btn {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(520px, 92vw);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user