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,214 @@
<script setup lang="ts">
import {
ArcElement,
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LineElement,
LinearScale,
PointElement,
Title,
Tooltip,
} from "chart.js";
import { computed, onMounted, ref } from "vue";
import { Bar, Line } from "vue-chartjs";
import {
fetchAnalyticsSummary,
fetchAnalyticsTimeline,
fetchTopLocalities,
fetchTopRegions,
} from "../api/admin";
import type { AnalyticsSummary, TimelinePoint, TopItem } from "../types/admin";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
);
const summary = ref<AnalyticsSummary | null>(null);
const timeline = ref<TimelinePoint[]>([]);
const topLocalities = ref<TopItem[]>([]);
const topRegions = ref<TopItem[]>([]);
const loading = ref(true);
const error = ref("");
const kpiCards = computed(() => {
if (!summary.value) return [];
const s = summary.value;
return [
{ label: "Всего событий", value: s.total_events },
{ label: "С координатами", value: s.events_with_coords },
{ label: "За 24 часа", value: s.events_last_24h },
{ label: "Подписчики ПИ", value: `${s.active_consumers} / ${s.total_consumers}` },
{ label: "Задания парсинга", value: s.total_jobs },
{ label: "В очереди", value: s.pending_jobs },
];
});
const timelineChartData = computed(() => ({
labels: timeline.value.map((p) => p.date),
datasets: [
{
label: "События",
data: timeline.value.map((p) => p.count),
borderColor: "#1565c0",
backgroundColor: "rgba(21, 101, 192, 0.1)",
tension: 0.3,
fill: true,
},
],
}));
const localitiesChartData = computed(() => ({
labels: topLocalities.value.map((i) => i.name),
datasets: [
{
label: "События",
data: topLocalities.value.map((i) => i.count),
backgroundColor: "#42a5f5",
},
],
}));
const regionsChartData = computed(() => ({
labels: topRegions.value.map((i) => i.name),
datasets: [
{
label: "События",
data: topRegions.value.map((i) => i.count),
backgroundColor: "#66bb6a",
},
],
}));
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
};
onMounted(async () => {
loading.value = true;
error.value = "";
try {
const [s, t, loc, reg] = await Promise.all([
fetchAnalyticsSummary(),
fetchAnalyticsTimeline(30),
fetchTopLocalities(10),
fetchTopRegions(10),
]);
summary.value = s;
timeline.value = t;
topLocalities.value = loc;
topRegions.value = reg;
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить аналитику";
} finally {
loading.value = false;
}
});
</script>
<template>
<div class="page">
<h2 class="page-heading">Аналитика</h2>
<p v-if="error" class="form-error">{{ error }}</p>
<p v-if="loading" class="muted">Загрузка...</p>
<div v-if="summary" class="kpi-grid">
<div v-for="card in kpiCards" :key="card.label" class="kpi-card">
<div class="kpi-value">{{ card.value }}</div>
<div class="kpi-label">{{ card.label }}</div>
</div>
</div>
<div class="charts-grid">
<section class="card chart-card">
<h3>Динамика за 30 дней</h3>
<div class="chart-container">
<Line v-if="timeline.length" :data="timelineChartData" :options="chartOptions" />
<p v-else class="empty-chart">Нет данных</p>
</div>
</section>
<section class="card chart-card">
<h3>Топ населённых пунктов</h3>
<div class="chart-container">
<Bar
v-if="topLocalities.length"
:data="localitiesChartData"
:options="chartOptions"
/>
<p v-else class="empty-chart">Нет данных</p>
</div>
</section>
<section class="card chart-card">
<h3>Топ регионов</h3>
<div class="chart-container">
<Bar v-if="topRegions.length" :data="regionsChartData" :options="chartOptions" />
<p v-else class="empty-chart">Нет данных</p>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.kpi-card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1rem;
text-align: center;
}
.kpi-value {
font-size: 1.75rem;
font-weight: 700;
color: #1565c0;
}
.kpi-label {
font-size: 0.8rem;
color: #666;
margin-top: 0.25rem;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1rem;
}
.chart-card h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.chart-container {
height: 260px;
}
.empty-chart {
color: #999;
text-align: center;
padding: 3rem 0;
}
</style>
@@ -0,0 +1,438 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
createConsumer,
fetchConsumers,
rotateConsumerKey,
testDistribution,
updateConsumer,
} from "../api/admin";
import type { Consumer, EventRecord } from "../types/admin";
const consumers = ref<Consumer[]>([]);
const loading = ref(true);
const error = ref("");
const submitting = ref(false);
const form = ref({
name: "",
regions: "",
topics: "",
date_from: "",
});
const apiKeyModal = ref<{ open: boolean; key: string; consumerName: string }>({
open: false,
key: "",
consumerName: "",
});
const testModal = ref<{
open: boolean;
consumer: Consumer | null;
apiKey: string;
results: EventRecord[] | null;
loading: boolean;
error: string;
}>({
open: false,
consumer: null,
apiKey: "",
results: null,
loading: false,
error: "",
});
const editModal = ref<{
open: boolean;
consumer: Consumer | null;
name: string;
is_active: boolean;
regions: string;
topics: string;
date_from: string;
}>({
open: false,
consumer: null,
name: "",
is_active: true,
regions: "",
topics: "",
date_from: "",
});
function parseList(value: string): string[] | null {
const items = value
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return items.length ? items : null;
}
function formatList(items: string[] | null): string {
return items?.join(", ") ?? "—";
}
function formatDate(value: string | null): string {
if (!value) return "—";
return new Date(value).toLocaleString("ru-RU");
}
function toDateInput(value: string | null): string {
if (!value) return "";
return value.slice(0, 10);
}
async function loadConsumers() {
loading.value = true;
error.value = "";
try {
consumers.value = await fetchConsumers();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить подписчиков";
} finally {
loading.value = false;
}
}
async function handleCreate() {
if (!form.value.name.trim()) {
error.value = "Укажите имя подписчика";
return;
}
submitting.value = true;
error.value = "";
try {
const created = await createConsumer({
name: form.value.name.trim(),
regions: parseList(form.value.regions),
topics: parseList(form.value.topics),
date_from: form.value.date_from || null,
});
if (created.api_key) {
apiKeyModal.value = {
open: true,
key: created.api_key,
consumerName: created.name,
};
}
form.value = { name: "", regions: "", topics: "", date_from: "" };
await loadConsumers();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось создать подписчика";
} finally {
submitting.value = false;
}
}
function openEdit(consumer: Consumer) {
editModal.value = {
open: true,
consumer,
name: consumer.name,
is_active: consumer.is_active,
regions: consumer.regions?.join(", ") ?? "",
topics: consumer.topics?.join(", ") ?? "",
date_from: toDateInput(consumer.date_from),
};
}
function closeEdit() {
editModal.value.open = false;
editModal.value.consumer = null;
}
async function handleSaveEdit() {
if (!editModal.value.consumer) return;
try {
await updateConsumer(editModal.value.consumer.id, {
name: editModal.value.name.trim(),
is_active: editModal.value.is_active,
regions: parseList(editModal.value.regions),
topics: parseList(editModal.value.topics),
date_from: editModal.value.date_from || null,
});
closeEdit();
await loadConsumers();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось обновить подписчика";
}
}
async function handleRotateKey(consumer: Consumer) {
const confirmed = window.confirm(`Сгенерировать новый API-ключ для «${consumer.name}»?`);
if (!confirmed) return;
try {
const updated = await rotateConsumerKey(consumer.id);
if (updated.api_key) {
apiKeyModal.value = {
open: true,
key: updated.api_key,
consumerName: updated.name,
};
}
await loadConsumers();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось сменить ключ";
}
}
function openTest(consumer: Consumer) {
testModal.value = {
open: true,
consumer,
apiKey: "",
results: null,
loading: false,
error: "",
};
}
function closeTest() {
testModal.value.open = false;
testModal.value.consumer = null;
}
async function runTest() {
if (!testModal.value.apiKey.trim()) {
testModal.value.error = "Введите API-ключ";
return;
}
testModal.value.loading = true;
testModal.value.error = "";
testModal.value.results = null;
try {
testModal.value.results = await testDistribution(testModal.value.apiKey.trim(), 10);
} catch (err) {
testModal.value.error = err instanceof Error ? err.message : "Ошибка теста";
} finally {
testModal.value.loading = false;
}
}
function closeApiKeyModal() {
apiKeyModal.value.open = false;
}
function copyApiKey() {
navigator.clipboard.writeText(apiKeyModal.value.key);
}
onMounted(loadConsumers);
</script>
<template>
<div class="page">
<h2 class="page-heading">Подписчики ПИ</h2>
<section class="card">
<h3>Новый подписчик</h3>
<form class="admin-form" @submit.prevent="handleCreate">
<div class="form-row">
<label>
Имя
<input v-model="form.name" type="text" required placeholder="partner-1" />
</label>
<label>
Регионы (через запятую)
<input v-model="form.regions" type="text" placeholder="Москва, СПб" />
</label>
<label>
Темы (через запятую)
<input v-model="form.topics" type="text" />
</label>
<label>
Дата от
<input v-model="form.date_from" type="date" />
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Создание..." : "Создать" }}
</button>
</div>
</form>
<p v-if="error" class="form-error">{{ error }}</p>
</section>
<section class="card">
<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>
</tr>
</thead>
<tbody>
<tr v-if="consumers.length === 0 && !loading">
<td colspan="8" class="empty">Нет подписчиков</td>
</tr>
<tr v-for="consumer in consumers" :key="consumer.id">
<td>{{ consumer.id }}</td>
<td>{{ consumer.name }}</td>
<td>
<span class="badge" :class="consumer.is_active ? 'badge-success' : 'badge-error'">
{{ consumer.is_active ? "да" : "нет" }}
</span>
</td>
<td>{{ formatList(consumer.regions) }}</td>
<td>{{ formatList(consumer.topics) }}</td>
<td>{{ formatDate(consumer.date_from) }}</td>
<td>{{ formatDate(consumer.created_at) }}</td>
<td class="actions-cell">
<button class="btn btn-sm" @click="openEdit(consumer)">Изменить</button>
<button class="btn btn-sm" @click="handleRotateKey(consumer)">Новый ключ</button>
<button class="btn btn-sm" @click="openTest(consumer)">Тест</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<div v-if="apiKeyModal.open" class="modal-overlay" @click.self="closeApiKeyModal">
<div class="modal">
<header class="modal-header">
<h3>API-ключ: {{ apiKeyModal.consumerName }}</h3>
<button class="btn btn-sm" @click="closeApiKeyModal">✕</button>
</header>
<div class="modal-body">
<p class="warning-text">Сохраните ключ — он больше не будет показан.</p>
<code class="api-key-display">{{ apiKeyModal.key }}</code>
</div>
<footer class="modal-footer">
<button class="btn btn-primary" @click="copyApiKey">Копировать</button>
<button class="btn" @click="closeApiKeyModal">Закрыть</button>
</footer>
</div>
</div>
<div v-if="editModal.open" class="modal-overlay" @click.self="closeEdit">
<div class="modal">
<header class="modal-header">
<h3>Редактирование: {{ editModal.consumer?.name }}</h3>
<button class="btn btn-sm" @click="closeEdit">✕</button>
</header>
<form class="modal-body admin-form" @submit.prevent="handleSaveEdit">
<label>
Имя
<input v-model="editModal.name" type="text" required />
</label>
<label class="checkbox-label">
<input v-model="editModal.is_active" type="checkbox" />
Активен
</label>
<label>
Регионы
<input v-model="editModal.regions" type="text" />
</label>
<label>
Темы
<input v-model="editModal.topics" type="text" />
</label>
<label>
Дата от
<input v-model="editModal.date_from" type="date" />
</label>
<footer class="modal-footer">
<button type="submit" class="btn btn-primary">Сохранить</button>
<button type="button" class="btn" @click="closeEdit">Отмена</button>
</footer>
</form>
</div>
</div>
<div v-if="testModal.open" class="modal-overlay" @click.self="closeTest">
<div class="modal modal-wide">
<header class="modal-header">
<h3>Тест среза: {{ testModal.consumer?.name }}</h3>
<button class="btn btn-sm" @click="closeTest">✕</button>
</header>
<div class="modal-body">
<div class="admin-form">
<label>
API-ключ
<input v-model="testModal.apiKey" type="text" placeholder="Bearer token..." />
</label>
<div class="form-actions">
<button class="btn btn-primary" :disabled="testModal.loading" @click="runTest">
{{ testModal.loading ? "Запрос..." : "Получить срез (10)" }}
</button>
</div>
</div>
<p v-if="testModal.error" class="form-error">{{ testModal.error }}</p>
<div v-if="testModal.results" class="table-wrap">
<table class="admin-table">
<thead>
<tr>
<th>ID</th>
<th>Заголовок</th>
<th>Регион</th>
<th>Дата</th>
</tr>
</thead>
<tbody>
<tr v-if="testModal.results.length === 0">
<td colspan="4" class="empty">Пустой срез</td>
</tr>
<tr v-for="ev in testModal.results" :key="ev.id">
<td>{{ ev.id }}</td>
<td>{{ ev.title || ev.description || "—" }}</td>
<td>{{ ev.region ?? "—" }}</td>
<td>{{ formatDate(ev.event_date) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.actions-cell {
white-space: nowrap;
}
.actions-cell .btn {
margin-right: 0.25rem;
}
.api-key-display {
display: block;
padding: 0.75rem;
background: #f5f5f5;
border-radius: 6px;
word-break: break-all;
font-size: 0.85rem;
}
.warning-text {
color: #e65100;
font-size: 0.875rem;
margin-bottom: 0.75rem;
}
.checkbox-label {
flex-direction: row !important;
align-items: center;
gap: 0.5rem;
}
.modal-wide {
max-width: 720px;
}
</style>
@@ -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>
@@ -0,0 +1,383 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute } from "vue-router";
import {
createObject,
deleteObject,
fetchObjects,
updateObject,
uploadObjectMedia,
} from "../api/objects";
import { fetchEvents } from "../api/admin";
import ContextMenu from "../components/ContextMenu.vue";
import CreateObjectModal from "../components/CreateObjectModal.vue";
import EditObjectModal from "../components/EditObjectModal.vue";
import MapView from "../components/MapView.vue";
import ObjectPanel from "../components/ObjectPanel.vue";
import TimelineBar from "../components/TimelineBar.vue";
import type { MapObject, MapObjectCreate, ObjectType } from "../types/object";
const route = useRoute();
const objects = ref<MapObject[]>([]);
const selectedObject = ref<MapObject | null>(null);
const loading = ref(true);
const error = ref("");
const timelinePosition = ref(Date.now());
const contextMenu = ref<{
visible: boolean;
x: number;
y: number;
latitude: number;
longitude: number;
target: "map" | "object";
object: MapObject | null;
}>({
visible: false,
x: 0,
y: 0,
latitude: 0,
longitude: 0,
target: "map",
object: null,
});
const createModal = ref({
visible: false,
latitude: 0,
longitude: 0,
});
const editModal = ref({
visible: false,
object: null as MapObject | null,
});
const selectedId = computed(() => selectedObject.value?.id ?? null);
function objectTime(obj: MapObject): number {
return new Date(obj.created_at).getTime();
}
function replaceObject(updated: MapObject) {
objects.value = objects.value.map((obj) => (obj.id === updated.id ? updated : obj));
if (selectedObject.value?.id === updated.id) {
selectedObject.value = updated;
}
}
const timelineBounds = computed(() => {
if (objects.value.length === 0) {
const now = Date.now();
return { min: now, max: now };
}
const times = objects.value.map(objectTime);
return {
min: Math.min(...times),
max: Math.max(...times),
};
});
const visibleObjects = computed(() =>
objects.value.filter((obj) => objectTime(obj) <= timelinePosition.value),
);
function syncTimelineToMax() {
timelinePosition.value = timelineBounds.value.max;
}
function selectByEventId(eventId: string | undefined) {
if (!eventId) return;
const id = Number(eventId);
if (Number.isNaN(id)) return;
const match = objects.value.find((obj) => obj.event_id === id);
if (match) {
selectedObject.value = match;
timelinePosition.value = Math.max(timelinePosition.value, objectTime(match));
return;
}
void fetchEvents({ limit: 500 }).then((response) => {
const event = response.items.find((item) => item.id === id);
if (!event || event.latitude == null || event.longitude == null) {
error.value = "Событие не найдено на карте";
return;
}
const eventTime = event.event_date
? new Date(event.event_date).getTime()
: Date.now();
timelinePosition.value = Math.max(timelinePosition.value, eventTime);
const byCoords = objects.value.find(
(obj) =>
Math.abs(obj.latitude - event.latitude!) < 1e-4 &&
Math.abs(obj.longitude - event.longitude!) < 1e-4,
);
if (byCoords) {
selectedObject.value = byCoords;
} else {
error.value = "Объект карты для события ещё не создан";
}
});
}
async function loadObjects() {
loading.value = true;
error.value = "";
try {
objects.value = await fetchObjects();
syncTimelineToMax();
selectByEventId(route.query.eventId as string | undefined);
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить объекты";
} finally {
loading.value = false;
}
}
function handleSelectObject(obj: MapObject) {
selectedObject.value = obj;
}
function handleMapContextMenu(payload: {
x: number;
y: number;
latitude: number;
longitude: number;
object: MapObject | null;
}) {
contextMenu.value = {
visible: true,
x: payload.x,
y: payload.y,
latitude: payload.latitude,
longitude: payload.longitude,
target: payload.object ? "object" : "map",
object: payload.object,
};
if (payload.object) {
selectedObject.value = payload.object;
}
}
function closeContextMenu() {
contextMenu.value.visible = false;
}
function openCreateModal() {
createModal.value = {
visible: true,
latitude: contextMenu.value.latitude,
longitude: contextMenu.value.longitude,
};
closeContextMenu();
}
function closeCreateModal() {
createModal.value.visible = false;
}
function openEditModal() {
if (!contextMenu.value.object) return;
editModal.value = {
visible: true,
object: contextMenu.value.object,
};
closeContextMenu();
}
function closeEditModal() {
editModal.value.visible = false;
editModal.value.object = null;
}
async function handleCreateObject(payload: {
name: string;
description: string;
type: ObjectType;
created_at?: string;
files: File[];
}) {
const data: MapObjectCreate = {
name: payload.name,
description: payload.description,
type: payload.type,
latitude: createModal.value.latitude,
longitude: createModal.value.longitude,
created_at: payload.created_at,
};
const created = await createObject(data);
for (const file of payload.files) {
await uploadObjectMedia(created.id, file);
}
objects.value = [...objects.value, created];
selectedObject.value = created;
timelinePosition.value = objectTime(created);
closeCreateModal();
}
async function handleEditObject(payload: {
name: string;
description: string;
type: ObjectType;
created_at: string;
}) {
if (!editModal.value.object) return;
const updated = await updateObject(editModal.value.object.id, payload);
replaceObject(updated);
timelinePosition.value = objectTime(updated);
closeEditModal();
}
async function handleDeleteObject() {
const object = contextMenu.value.object;
if (!object) return;
const confirmed = window.confirm(`Удалить объект «${object.name}»?`);
if (!confirmed) return;
closeContextMenu();
try {
await deleteObject(object.id);
objects.value = objects.value.filter((item) => item.id !== object.id);
if (selectedObject.value?.id === object.id) {
selectedObject.value = null;
}
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось удалить объект";
}
}
async function handleMoveObject(payload: {
object: MapObject;
latitude: number;
longitude: number;
}) {
try {
const updated = await updateObject(payload.object.id, {
latitude: payload.latitude,
longitude: payload.longitude,
});
replaceObject(updated);
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось переместить объект";
}
}
watch(visibleObjects, (visible) => {
if (
selectedObject.value &&
!visible.some((obj) => obj.id === selectedObject.value?.id)
) {
selectedObject.value = null;
}
});
watch(
() => route.query.eventId,
(eventId) => selectByEventId(eventId as string | undefined),
);
onMounted(loadObjects);
</script>
<template>
<div class="map-page" @click="closeContextMenu">
<div class="map-status">
<span v-if="loading" class="status">Загрузка...</span>
<span v-else-if="error" class="status error">{{ error }}</span>
<span v-else class="status">
{{ visibleObjects.length }} / {{ objects.length }} объектов на карте
</span>
</div>
<div class="map-content">
<MapView
:objects="visibleObjects"
:selected-id="selectedId"
@select="handleSelectObject"
@contextmenu="handleMapContextMenu"
@move="handleMoveObject"
/>
<ObjectPanel :object="selectedObject" />
</div>
<TimelineBar
v-if="!loading && objects.length > 0"
v-model="timelinePosition"
:min="timelineBounds.min"
:max="timelineBounds.max"
:objects="objects"
:visible-count="visibleObjects.length"
/>
<ContextMenu
v-if="contextMenu.visible"
:x="contextMenu.x"
:y="contextMenu.y"
:target="contextMenu.target"
:object-name="contextMenu.object?.name"
@create="openCreateModal"
@edit="openEditModal"
@delete="handleDeleteObject"
/>
<CreateObjectModal
v-if="createModal.visible"
:latitude="createModal.latitude"
:longitude="createModal.longitude"
@close="closeCreateModal"
@submit="handleCreateObject"
/>
<EditObjectModal
v-if="editModal.visible && editModal.object"
:object="editModal.object"
@close="closeEditModal"
@submit="handleEditObject"
/>
</div>
</template>
<style scoped>
.map-page {
display: flex;
flex-direction: column;
height: 100%;
}
.map-status {
padding: 0.4rem 1.25rem;
background: #fafafa;
border-bottom: 1px solid #eee;
font-size: 0.875rem;
}
.status {
color: #666;
}
.status.error {
color: #c62828;
}
.map-content {
position: relative;
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
</style>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { createJob, fetchJobs, retryJob } from "../api/admin";
import type { ParseJob } from "../types/admin";
const jobs = ref<ParseJob[]>([]);
const loading = ref(true);
const error = ref("");
const submitting = ref(false);
const form = ref({
source_type: "telegram",
channel: "",
limit: 50,
});
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function loadJobs() {
try {
jobs.value = await fetchJobs();
error.value = "";
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить задания";
} finally {
loading.value = false;
}
}
async function handleSubmit() {
if (!form.value.channel.trim()) {
error.value = "Укажите канал Telegram";
return;
}
submitting.value = true;
error.value = "";
try {
await createJob({
source_type: form.value.source_type,
source_config: {
channel: form.value.channel.trim(),
limit: form.value.limit,
},
});
form.value.channel = "";
await loadJobs();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось создать задание";
} finally {
submitting.value = false;
}
}
async function handleRetry(jobId: number) {
try {
await retryJob(jobId);
await loadJobs();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось перезапустить задание";
}
}
function statusClass(status: string): string {
if (status === "completed" || status === "done") return "badge-success";
if (status === "failed" || status === "error") return "badge-error";
if (status === "running") return "badge-running";
return "badge-pending";
}
function formatDate(value: string | null): string {
if (!value) return "—";
return new Date(value).toLocaleString("ru-RU");
}
onMounted(() => {
loadJobs();
pollTimer = setInterval(loadJobs, 5000);
});
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer);
});
</script>
<template>
<div class="page">
<h2 class="page-heading">Парсеры</h2>
<section class="card">
<h3>Новое задание</h3>
<form class="admin-form" @submit.prevent="handleSubmit">
<div class="form-row">
<label>
Тип источника
<select v-model="form.source_type" disabled>
<option value="telegram">Telegram</option>
</select>
</label>
<label>
Канал
<input v-model="form.channel" type="text" placeholder="creamy_caprice" required />
</label>
<label>
Лимит
<input v-model.number="form.limit" type="number" min="1" max="1000" />
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting">
{{ submitting ? "Создание..." : "Создать задание" }}
</button>
</div>
</form>
<p v-if="error" class="form-error">{{ error }}</p>
</section>
<section class="card">
<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>
</tr>
</thead>
<tbody>
<tr v-if="jobs.length === 0 && !loading">
<td colspan="7" 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>
<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>
<button
v-if="job.status === 'failed' || job.status === 'error'"
class="btn btn-sm"
@click="handleRetry(job.id)"
>
Повторить
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</template>