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:
2026-07-02 20:47:56 +03:00
co-authored by Cursor
parent 1d6d54ab9f
commit 1492576fd9
23 changed files with 1124 additions and 267 deletions
@@ -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>