Add CA admin UI and extend admin API for the unified platform.

Deliver parsers, events, analytics, and PI management in Vue; fix Telegram session mount and map navigation to events by eventId.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 14:40:59 +03:00
co-authored by Cursor
parent 9dfe713668
commit 2c16bc0b56
26 changed files with 2550 additions and 396 deletions
@@ -0,0 +1,261 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { fetchEvents } from "../api/admin";
import type { EventFilters, EventRecord } from "../types/admin";
const router = useRouter();
const items = ref<EventRecord[]>([]);
const total = ref(0);
const loading = ref(true);
const error = ref("");
const filters = ref({
search: "",
source_type: "",
region: "",
topic: "",
locality: "",
date_from: "",
date_to: "",
});
const page = ref(1);
const pageSize = 25;
const selectedEvent = ref<EventRecord | null>(null);
const modalOpen = ref(false);
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
function buildApiFilters(): EventFilters {
return {
search: filters.value.search || undefined,
source_type: filters.value.source_type || undefined,
region: filters.value.region || undefined,
topic: filters.value.topic || undefined,
locality: filters.value.locality || undefined,
date_from: filters.value.date_from || undefined,
date_to: filters.value.date_to ? `${filters.value.date_to}T23:59:59` : undefined,
limit: pageSize,
offset: (page.value - 1) * pageSize,
};
}
async function loadEvents() {
loading.value = true;
error.value = "";
try {
const response = await fetchEvents(buildApiFilters());
items.value = response.items;
total.value = response.total;
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить события";
} finally {
loading.value = false;
}
}
function applyFilters() {
page.value = 1;
loadEvents();
}
function resetFilters() {
filters.value = {
search: "",
source_type: "",
region: "",
topic: "",
locality: "",
date_from: "",
date_to: "",
};
page.value = 1;
loadEvents();
}
function openModal(event: EventRecord) {
selectedEvent.value = event;
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
selectedEvent.value = null;
}
function goToMap(event: EventRecord) {
if (event.latitude == null || event.longitude == null) return;
router.push({ path: "/", query: { eventId: String(event.id) } });
}
function formatDate(value: string | null): string {
if (!value) return "—";
return new Date(value).toLocaleString("ru-RU");
}
function truncate(text: string, max = 80): string {
if (text.length <= max) return text;
return `${text.slice(0, max)}`;
}
watch(page, loadEvents);
onMounted(loadEvents);
</script>
<template>
<div class="page">
<h2 class="page-heading">События</h2>
<section class="card">
<form class="admin-form filters-form" @submit.prevent="applyFilters">
<div class="form-row">
<label>
Поиск
<input v-model="filters.search" type="text" placeholder="Заголовок, текст..." />
</label>
<label>
Источник
<input v-model="filters.source_type" type="text" placeholder="telegram" />
</label>
<label>
Регион
<input v-model="filters.region" type="text" />
</label>
<label>
Тема
<input v-model="filters.topic" type="text" />
</label>
<label>
Населённый пункт
<input v-model="filters.locality" type="text" />
</label>
<label>
Дата от
<input v-model="filters.date_from" type="date" />
</label>
<label>
Дата до
<input v-model="filters.date_to" type="date" />
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Применить</button>
<button type="button" class="btn" @click="resetFilters">Сбросить</button>
</div>
</form>
</section>
<p v-if="error" class="form-error">{{ error }}</p>
<section class="card">
<div class="table-header">
<span>Всего: {{ total }}</span>
<span v-if="loading" class="muted">Загрузка...</span>
</div>
<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>
</tr>
</thead>
<tbody>
<tr v-if="items.length === 0 && !loading">
<td colspan="8" class="empty">Нет событий</td>
</tr>
<tr v-for="event in items" :key="event.id">
<td>{{ event.id }}</td>
<td>
<button class="link-btn" @click="openModal(event)">
{{ truncate(event.title || event.description || "") }}
</button>
</td>
<td>{{ event.region ?? "—" }}</td>
<td>{{ event.topic ?? "—" }}</td>
<td>{{ event.locality || "—" }}</td>
<td>{{ formatDate(event.event_date) }}</td>
<td class="mono">
<template v-if="event.latitude != null && event.longitude != null">
{{ event.latitude.toFixed(4) }}, {{ event.longitude.toFixed(4) }}
</template>
<template v-else></template>
</td>
<td>
<button
v-if="event.latitude != null && event.longitude != null"
class="btn btn-sm"
@click="goToMap(event)"
>
На карте
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button class="btn btn-sm" :disabled="page <= 1" @click="page--"> Назад</button>
<span>Стр. {{ page }} / {{ totalPages }}</span>
<button class="btn btn-sm" :disabled="page >= totalPages" @click="page++">Вперёд </button>
</div>
</section>
<div v-if="modalOpen && selectedEvent" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<header class="modal-header">
<h3>Событие #{{ selectedEvent.id }}</h3>
<button class="btn btn-sm" @click="closeModal"></button>
</header>
<div class="modal-body">
<dl class="detail-list">
<dt>Заголовок</dt>
<dd>{{ selectedEvent.title || "—" }}</dd>
<dt>Описание</dt>
<dd>{{ selectedEvent.description || "—" }}</dd>
<dt>Источник</dt>
<dd>{{ selectedEvent.source_type }} {{ selectedEvent.source_url }}</dd>
<dt>Регион / Тема</dt>
<dd>{{ selectedEvent.region ?? "—" }} / {{ selectedEvent.topic ?? "—" }}</dd>
<dt>Населённый пункт</dt>
<dd>{{ selectedEvent.locality || "—" }}</dd>
<dt>Дата события</dt>
<dd>{{ formatDate(selectedEvent.event_date) }}</dd>
<dt>Ingested</dt>
<dd>{{ formatDate(selectedEvent.ingested_at) }}</dd>
<dt>Координаты</dt>
<dd>
<template v-if="selectedEvent.latitude != null">
{{ selectedEvent.latitude }}, {{ selectedEvent.longitude }}
</template>
<template v-else></template>
</dd>
<dt>Текст</dt>
<dd class="raw-text">{{ selectedEvent.raw_text || "—" }}</dd>
</dl>
</div>
<footer class="modal-footer">
<button
v-if="selectedEvent.latitude != null"
class="btn btn-primary"
@click="goToMap(selectedEvent)"
>
Показать на карте
</button>
<button class="btn" @click="closeModal">Закрыть</button>
</footer>
</div>
</div>
</div>
</template>