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:
@@ -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