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
+124 -16
View File
@@ -1,16 +1,36 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..database import get_db
from ..models import Event, ParseJob
from ..models import Consumer, Event, ParseJob
from ..schemas import (
AnalyticsSummary,
ConsumerCreate,
ConsumerRead,
ConsumerUpdate,
EventListResponse,
EventRead,
ParseJobCreate,
ParseJobRead,
TimelinePoint,
TopItem,
)
from ..services.analytics import (
get_analytics_summary,
get_timeline,
get_top_localities,
get_top_regions,
)
from ..services.events_query import build_events_query
from ..services.filtering import (
consumer_to_read,
create_consumer,
list_consumers,
rotate_consumer_key,
update_consumer,
)
from ..services.filtering import create_consumer
from ..services.jobs import enqueue_job
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -45,19 +65,81 @@ def get_parse_job(job_id: int, db: Session = Depends(get_db)):
return job
@router.get("/events", response_model=list[EventRead])
@router.post("/jobs/{job_id}/retry", response_model=ParseJobRead)
def retry_parse_job(job_id: int, db: Session = Depends(get_db)):
job = db.query(ParseJob).filter(ParseJob.id == job_id).first()
if not job:
raise HTTPException(status_code=404, detail="Job not found")
job.status = "queued"
job.last_error = None
db.commit()
db.refresh(job)
enqueue_job(job.id, job.source_type, job.source_config)
return job
@router.get("/events", response_model=EventListResponse)
def list_events(
limit: int = Query(default=100, ge=1, le=1000),
limit: int = Query(default=50, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
source_type: str | None = None,
region: str | None = None,
topic: str | None = None,
locality: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
search: str | None = None,
db: Session = Depends(get_db),
):
return (
db.query(Event)
.order_by(Event.ingested_at.desc())
.offset(offset)
.limit(limit)
.all()
query = build_events_query(
db,
source_type=source_type,
region=region,
topic=topic,
locality=locality,
date_from=date_from,
date_to=date_to,
search=search,
)
total = query.count()
items = query.offset(offset).limit(limit).all()
return EventListResponse(items=items, total=total)
@router.get("/analytics/summary", response_model=AnalyticsSummary)
def analytics_summary(db: Session = Depends(get_db)):
return get_analytics_summary(db)
@router.get("/analytics/timeline", response_model=list[TimelinePoint])
def analytics_timeline(
days: int = Query(default=30, ge=1, le=365),
db: Session = Depends(get_db),
):
return get_timeline(db, days=days)
@router.get("/analytics/top-localities", response_model=list[TopItem])
def analytics_top_localities(
limit: int = Query(default=10, ge=1, le=100),
db: Session = Depends(get_db),
):
return get_top_localities(db, limit=limit)
@router.get("/analytics/top-regions", response_model=list[TopItem])
def analytics_top_regions(
limit: int = Query(default=10, ge=1, le=100),
db: Session = Depends(get_db),
):
return get_top_regions(db, limit=limit)
@router.get("/consumers", response_model=list[ConsumerRead])
def list_pi_consumers(db: Session = Depends(get_db)):
return [consumer_to_read(c) for c in list_consumers(db)]
@router.post("/consumers", response_model=ConsumerRead, status_code=201)
@@ -69,10 +151,36 @@ def create_pi_consumer(payload: ConsumerCreate, db: Session = Depends(get_db)):
topics=payload.topics,
date_from=payload.date_from,
)
return ConsumerRead(
id=consumer.id,
name=consumer.name,
is_active=consumer.is_active,
created_at=consumer.created_at,
api_key=api_key,
return consumer_to_read(consumer, api_key=api_key)
@router.patch("/consumers/{consumer_id}", response_model=ConsumerRead)
def patch_pi_consumer(
consumer_id: int,
payload: ConsumerUpdate,
db: Session = Depends(get_db),
):
consumer = db.query(Consumer).filter(Consumer.id == consumer_id).first()
if not consumer:
raise HTTPException(status_code=404, detail="Consumer not found")
consumer = update_consumer(
db,
consumer,
name=payload.name,
is_active=payload.is_active,
regions=payload.regions,
topics=payload.topics,
date_from=payload.date_from,
)
return consumer_to_read(consumer)
@router.post("/consumers/{consumer_id}/rotate-key", response_model=ConsumerRead)
def rotate_pi_consumer_key(consumer_id: int, db: Session = Depends(get_db)):
consumer = db.query(Consumer).filter(Consumer.id == consumer_id).first()
if not consumer:
raise HTTPException(status_code=404, detail="Consumer not found")
consumer, api_key = rotate_consumer_key(db, consumer)
return consumer_to_read(consumer, api_key=api_key)
+36
View File
@@ -122,6 +122,14 @@ class ConsumerCreate(BaseModel):
date_from: datetime | None = None
class ConsumerUpdate(BaseModel):
name: str | None = None
is_active: bool | None = None
regions: list[str] | None = None
topics: list[str] | None = None
date_from: datetime | None = None
class ConsumerRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -130,3 +138,31 @@ class ConsumerRead(BaseModel):
is_active: bool
created_at: datetime
api_key: str | None = None
regions: list[str] | None = None
topics: list[str] | None = None
date_from: datetime | None = None
class EventListResponse(BaseModel):
items: list[EventRead]
total: int
class AnalyticsSummary(BaseModel):
total_events: int
events_with_coords: int
events_last_24h: int
total_consumers: int
active_consumers: int
total_jobs: int
pending_jobs: int
class TimelinePoint(BaseModel):
date: str
count: int
class TopItem(BaseModel):
name: str
count: int
@@ -0,0 +1,92 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import Consumer, Event, ParseJob
from ..schemas import AnalyticsSummary, TimelinePoint, TopItem
def get_analytics_summary(db: Session) -> AnalyticsSummary:
now = datetime.now(timezone.utc)
day_ago = now - timedelta(days=1)
total_events = db.query(func.count(Event.id)).scalar() or 0
events_with_coords = (
db.query(func.count(Event.id))
.filter(Event.latitude.isnot(None), Event.longitude.isnot(None))
.scalar()
or 0
)
events_last_24h = (
db.query(func.count(Event.id))
.filter(Event.ingested_at >= day_ago)
.scalar()
or 0
)
total_consumers = db.query(func.count(Consumer.id)).scalar() or 0
active_consumers = (
db.query(func.count(Consumer.id))
.filter(Consumer.is_active.is_(True))
.scalar()
or 0
)
total_jobs = db.query(func.count(ParseJob.id)).scalar() or 0
pending_jobs = (
db.query(func.count(ParseJob.id))
.filter(ParseJob.status.in_(["queued", "running", "pending"]))
.scalar()
or 0
)
return AnalyticsSummary(
total_events=total_events,
events_with_coords=events_with_coords,
events_last_24h=events_last_24h,
total_consumers=total_consumers,
active_consumers=active_consumers,
total_jobs=total_jobs,
pending_jobs=pending_jobs,
)
def get_timeline(db: Session, days: int = 30) -> list[TimelinePoint]:
since = datetime.now(timezone.utc) - timedelta(days=days)
day_col = func.date_trunc("day", Event.ingested_at).label("day")
rows = (
db.query(day_col, func.count(Event.id).label("count"))
.filter(Event.ingested_at >= since)
.group_by(day_col)
.order_by(day_col)
.all()
)
return [
TimelinePoint(date=row.day.date().isoformat(), count=row.count)
for row in rows
]
def get_top_localities(db: Session, limit: int = 10) -> list[TopItem]:
rows = (
db.query(Event.locality, func.count(Event.id).label("count"))
.filter(Event.locality != "", Event.locality.isnot(None))
.group_by(Event.locality)
.order_by(func.count(Event.id).desc())
.limit(limit)
.all()
)
return [TopItem(name=row.locality, count=row.count) for row in rows]
def get_top_regions(db: Session, limit: int = 10) -> list[TopItem]:
rows = (
db.query(Event.region, func.count(Event.id).label("count"))
.filter(Event.region.isnot(None), Event.region != "")
.group_by(Event.region)
.order_by(func.count(Event.id).desc())
.limit(limit)
.all()
)
return [TopItem(name=row.region, count=row.count) for row in rows]
@@ -0,0 +1,42 @@
from datetime import datetime
from sqlalchemy.orm import Query, Session
from ..models import Event
def build_events_query(
db: Session,
*,
source_type: str | None = None,
region: str | None = None,
topic: str | None = None,
locality: str | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
search: str | None = None,
) -> Query:
query = db.query(Event)
if source_type:
query = query.filter(Event.source_type == source_type)
if region:
query = query.filter(Event.region == region)
if topic:
query = query.filter(Event.topic == topic)
if locality:
query = query.filter(Event.locality.ilike(f"%{locality}%"))
if date_from:
query = query.filter(Event.event_date >= date_from)
if date_to:
query = query.filter(Event.event_date <= date_to)
if search:
pattern = f"%{search}%"
query = query.filter(
Event.title.ilike(pattern)
| Event.description.ilike(pattern)
| Event.raw_text.ilike(pattern)
| Event.locality.ilike(pattern)
)
return query.order_by(Event.ingested_at.desc())
@@ -5,6 +5,7 @@ from datetime import datetime
from sqlalchemy.orm import Session
from ..models import Consumer, ConsumerFilter, Event, MapObject
from ..schemas import ConsumerRead
def hash_api_key(api_key: str) -> str:
@@ -15,6 +16,66 @@ def generate_api_key() -> str:
return secrets.token_urlsafe(32)
def consumer_to_read(consumer: Consumer, api_key: str | None = None) -> ConsumerRead:
consumer_filter = consumer.filter
return ConsumerRead(
id=consumer.id,
name=consumer.name,
is_active=consumer.is_active,
created_at=consumer.created_at,
api_key=api_key,
regions=consumer_filter.regions if consumer_filter else None,
topics=consumer_filter.topics if consumer_filter else None,
date_from=consumer_filter.date_from if consumer_filter else None,
)
def list_consumers(db: Session) -> list[Consumer]:
return db.query(Consumer).order_by(Consumer.id).all()
def update_consumer(
db: Session,
consumer: Consumer,
*,
name: str | None = None,
is_active: bool | None = None,
regions: list[str] | None = None,
topics: list[str] | None = None,
date_from: datetime | None = None,
) -> Consumer:
if name is not None:
consumer.name = name
if is_active is not None:
consumer.is_active = is_active
if any(v is not None for v in (regions, topics, date_from)):
consumer_filter = consumer.filter
if consumer_filter is None:
consumer_filter = ConsumerFilter(consumer_id=consumer.id)
db.add(consumer_filter)
consumer.filter = consumer_filter
if regions is not None:
consumer_filter.regions = regions
if topics is not None:
consumer_filter.topics = topics
if date_from is not None:
consumer_filter.date_from = date_from
db.commit()
db.refresh(consumer)
return consumer
def rotate_consumer_key(db: Session, consumer: Consumer) -> tuple[Consumer, str]:
api_key = generate_api_key()
consumer.api_key_hash = hash_api_key(api_key)
db.commit()
db.refresh(consumer)
return consumer, api_key
def create_consumer(
db: Session,
name: str,
+4 -1
View File
@@ -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 -344
View File
@@ -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>
+120
View File
@@ -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 -27
View File
@@ -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>
+2 -1
View File
@@ -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;
+275
View File
@@ -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",
},
},
});