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>
262 lines
8.1 KiB
Vue
262 lines
8.1 KiB
Vue
<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>
|