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:
@@ -9,8 +9,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.7",
|
||||
"leaflet": "^1.9.4",
|
||||
"vue": "^3.5.13"
|
||||
"vue": "^3.5.13",
|
||||
"vue-chartjs": "^5.3.2",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.15",
|
||||
|
||||
@@ -1,349 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import {
|
||||
createObject,
|
||||
deleteObject,
|
||||
fetchObjects,
|
||||
updateObject,
|
||||
uploadObjectMedia,
|
||||
} from "./api/objects";
|
||||
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 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;
|
||||
}
|
||||
|
||||
async function loadObjects() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
objects.value = await fetchObjects();
|
||||
syncTimelineToMax();
|
||||
} 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;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(loadObjects);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app" @click="closeContextMenu">
|
||||
<header class="header">
|
||||
<h1>MapMil</h1>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<MapView
|
||||
:objects="visibleObjects"
|
||||
:selected-id="selectedId"
|
||||
@select="handleSelectObject"
|
||||
@contextmenu="handleMapContextMenu"
|
||||
@move="handleMoveObject"
|
||||
/>
|
||||
<ObjectPanel :object="selectedObject" />
|
||||
</main>
|
||||
|
||||
<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>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ADMIN_BASE, request } from "./client";
|
||||
import type {
|
||||
AnalyticsSummary,
|
||||
Consumer,
|
||||
ConsumerCreate,
|
||||
ConsumerUpdate,
|
||||
EventFilters,
|
||||
EventListResponse,
|
||||
EventRecord,
|
||||
ParseJob,
|
||||
ParseJobCreate,
|
||||
TimelinePoint,
|
||||
TopItem,
|
||||
} from "../types/admin";
|
||||
|
||||
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== "") {
|
||||
search.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const qs = search.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export function fetchJobs(): Promise<ParseJob[]> {
|
||||
return request<ParseJob[]>("/jobs", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function createJob(payload: ParseJobCreate): Promise<ParseJob> {
|
||||
return request<ParseJob>(
|
||||
"/jobs",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function retryJob(jobId: number): Promise<ParseJob> {
|
||||
return request<ParseJob>(
|
||||
`/jobs/${jobId}/retry`,
|
||||
{ method: "POST" },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchEvents(filters: EventFilters = {}): Promise<EventListResponse> {
|
||||
return request<EventListResponse>(
|
||||
`/events${buildQuery(filters as Record<string, string | number | undefined>)}`,
|
||||
undefined,
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchConsumers(): Promise<Consumer[]> {
|
||||
return request<Consumer[]>("/consumers", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function createConsumer(payload: ConsumerCreate): Promise<Consumer> {
|
||||
return request<Consumer>(
|
||||
"/consumers",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateConsumer(id: number, payload: ConsumerUpdate): Promise<Consumer> {
|
||||
return request<Consumer>(
|
||||
`/consumers/${id}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function rotateConsumerKey(id: number): Promise<Consumer> {
|
||||
return request<Consumer>(
|
||||
`/consumers/${id}/rotate-key`,
|
||||
{ method: "POST" },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchAnalyticsSummary(): Promise<AnalyticsSummary> {
|
||||
return request<AnalyticsSummary>("/analytics/summary", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function fetchAnalyticsTimeline(days = 30): Promise<TimelinePoint[]> {
|
||||
return request<TimelinePoint[]>(
|
||||
`/analytics/timeline${buildQuery({ days })}`,
|
||||
undefined,
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchTopLocalities(limit = 10): Promise<TopItem[]> {
|
||||
return request<TopItem[]>(
|
||||
`/analytics/top-localities${buildQuery({ limit })}`,
|
||||
undefined,
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchTopRegions(limit = 10): Promise<TopItem[]> {
|
||||
return request<TopItem[]>(
|
||||
`/analytics/top-regions${buildQuery({ limit })}`,
|
||||
undefined,
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export async function testDistribution(apiKey: string, limit = 5): Promise<EventRecord[]> {
|
||||
const response = await fetch(`/api/v1/events?limit=${limit}`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Ошибка запроса: ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<EventRecord[]>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
const API_BASE = "/api";
|
||||
const ADMIN_BASE = "/admin";
|
||||
|
||||
export async function request<T>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
base: string = API_BASE,
|
||||
): Promise<T> {
|
||||
const headers = new Headers(options?.headers);
|
||||
const isFormData = options?.body instanceof FormData;
|
||||
|
||||
if (!isFormData && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${base}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Ошибка запроса: ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export { API_BASE, ADMIN_BASE };
|
||||
@@ -1,32 +1,6 @@
|
||||
import { request } from "./client";
|
||||
import type { MapObject, MapObjectCreate, MapObjectUpdate, ObjectMedia } from "../types/object";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(options?.headers);
|
||||
const isFormData = options?.body instanceof FormData;
|
||||
|
||||
if (!isFormData && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Ошибка запроса: ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function fetchObjects(): Promise<MapObject[]> {
|
||||
return request<MapObject[]>("/objects");
|
||||
}
|
||||
|
||||
@@ -96,6 +96,18 @@ function bindMarker(marker: L.Marker, obj: MapObject) {
|
||||
}
|
||||
}
|
||||
|
||||
function panToObject(obj: MapObject) {
|
||||
if (!map) return;
|
||||
const zoom = Math.max(map.getZoom(), 12);
|
||||
map.flyTo([obj.latitude, obj.longitude], zoom, { duration: 0.6 });
|
||||
}
|
||||
|
||||
function panToSelected() {
|
||||
if (props.selectedId === null) return;
|
||||
const obj = props.objects.find((item) => item.id === props.selectedId);
|
||||
if (obj) panToObject(obj);
|
||||
}
|
||||
|
||||
function syncMarkers() {
|
||||
if (!map || !markersLayer) return;
|
||||
|
||||
@@ -175,6 +187,8 @@ onMounted(() => {
|
||||
markersLayer = L.layerGroup().addTo(map);
|
||||
mapContainer.value.addEventListener("contextmenu", handleMapContextMenu);
|
||||
syncMarkers();
|
||||
panToSelected();
|
||||
setTimeout(() => map?.invalidateSize(), 0);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -187,7 +201,10 @@ onUnmounted(() => {
|
||||
|
||||
watch(
|
||||
() => [props.objects, props.selectedId] as const,
|
||||
() => syncMarkers(),
|
||||
() => {
|
||||
syncMarkers();
|
||||
panToSelected();
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Карта", exact: true },
|
||||
{ to: "/parsers", label: "Парсеры" },
|
||||
{ to: "/events", label: "События" },
|
||||
{ to: "/analytics", label: "Аналитика" },
|
||||
{ to: "/consumers", label: "ПИ" },
|
||||
];
|
||||
|
||||
function isActive(path: string, exact = false): boolean {
|
||||
if (exact) return route.path === path;
|
||||
return route.path.startsWith(path);
|
||||
}
|
||||
|
||||
const pageTitle = computed(() => {
|
||||
const item = navItems.find((n) => isActive(n.to, n.exact));
|
||||
return item?.label ?? "MapMil";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<header class="admin-header">
|
||||
<div class="brand">
|
||||
<h1>MapMil</h1>
|
||||
<span class="page-title">{{ pageTitle }}</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="nav-link"
|
||||
:class="{ active: isActive(item.to, item.exact) }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="admin-main">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 0.6rem 1.25rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.brand h1 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: #444;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
createApp(App).use(router).mount("#app");
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import AppLayout from "../layouts/AppLayout.vue";
|
||||
import AnalyticsView from "../views/AnalyticsView.vue";
|
||||
import ConsumersView from "../views/ConsumersView.vue";
|
||||
import EventsView from "../views/EventsView.vue";
|
||||
import MapViewPage from "../views/MapViewPage.vue";
|
||||
import ParsersView from "../views/ParsersView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
component: AppLayout,
|
||||
children: [
|
||||
{ path: "", name: "map", component: MapViewPage },
|
||||
{ path: "parsers", name: "parsers", component: ParsersView },
|
||||
{ path: "events", name: "events", component: EventsView },
|
||||
{ path: "analytics", name: "analytics", component: AnalyticsView },
|
||||
{ path: "consumers", name: "consumers", component: ConsumersView },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -20,3 +20,278 @@ select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* Admin UI */
|
||||
|
||||
.page {
|
||||
padding: 1.25rem;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.admin-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.admin-form input,
|
||||
.admin-form select,
|
||||
.admin-form textarea {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
flex: 1 1 180px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: #c62828;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.4rem 0.85rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
border-color: #1565c0;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0d47a1;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #1565c0;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.admin-table .empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-table .mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-table .error-cell {
|
||||
color: #c62828;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.badge-running {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 560px;
|
||||
max-height: 85vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-list dt {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-list dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-list .raw-text {
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
background: #f9f9f9;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
export interface ParseJob {
|
||||
id: number;
|
||||
source_type: string;
|
||||
source_config: Record<string, unknown>;
|
||||
schedule: string | null;
|
||||
status: string;
|
||||
last_run_at: string | null;
|
||||
last_error: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ParseJobCreate {
|
||||
source_type: string;
|
||||
source_config: Record<string, unknown>;
|
||||
schedule?: string | null;
|
||||
}
|
||||
|
||||
export interface EventRecord {
|
||||
id: number;
|
||||
source_type: string;
|
||||
source_url: string;
|
||||
raw_text: string;
|
||||
title: string;
|
||||
description: string;
|
||||
locality: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
event_date: string | null;
|
||||
ingested_at: string;
|
||||
region: string | null;
|
||||
topic: string | null;
|
||||
tags: string[] | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface EventListResponse {
|
||||
items: EventRecord[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface EventFilters {
|
||||
source_type?: string;
|
||||
region?: string;
|
||||
topic?: string;
|
||||
locality?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface Consumer {
|
||||
id: number;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
api_key?: string | null;
|
||||
regions: string[] | null;
|
||||
topics: string[] | null;
|
||||
date_from: string | null;
|
||||
}
|
||||
|
||||
export interface ConsumerCreate {
|
||||
name: string;
|
||||
regions?: string[] | null;
|
||||
topics?: string[] | null;
|
||||
date_from?: string | null;
|
||||
}
|
||||
|
||||
export interface ConsumerUpdate {
|
||||
name?: string;
|
||||
is_active?: boolean;
|
||||
regions?: string[] | null;
|
||||
topics?: string[] | null;
|
||||
date_from?: string | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsSummary {
|
||||
total_events: number;
|
||||
events_with_coords: number;
|
||||
events_last_24h: number;
|
||||
total_consumers: number;
|
||||
active_consumers: number;
|
||||
total_jobs: number;
|
||||
pending_jobs: number;
|
||||
}
|
||||
|
||||
export interface TimelinePoint {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TopItem {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export interface EventRead {
|
||||
id: number;
|
||||
source_type: string;
|
||||
source_url: string;
|
||||
raw_text: string;
|
||||
title: string;
|
||||
description: string;
|
||||
locality: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
event_date: string | null;
|
||||
ingested_at: string;
|
||||
region: string | null;
|
||||
topic: string | null;
|
||||
tags: string[] | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export interface MapObject {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
created_at: string;
|
||||
event_id?: number | null;
|
||||
}
|
||||
|
||||
export interface MapObjectCreate {
|
||||
|
||||
@@ -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>
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
"/admin": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user