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
+24 -4
View File
@@ -58,7 +58,19 @@ ls -la data/telegram.session
docker compose up --build docker compose up --build
``` ```
4. Откройте карту: [http://localhost:8080](http://localhost:8080) 4. Откройте UI: [http://localhost:8080](http://localhost:8080)
## Admin UI
Веб-интерфейс ЦА доступен по тем же адресу. Навигация в шапке:
| Раздел | Путь | Описание |
|--------|------|----------|
| **Карта** | `/` | Интерактивная карта с объектами, таймлайном и CRUD. Поддерживает `?eventId=` для перехода к событию |
| **Парсеры** | `/parsers` | Создание заданий Telegram-парсинга, таблица статусов с автообновлением (5 с), повтор failed-заданий |
| **События** | `/events` | Фильтрация, пагинация, просмотр деталей, ссылка «На карте» для событий с координатами |
| **Аналитика** | `/analytics` | KPI-карточки, график динамики ingest за 30 дней, топ населённых пунктов и регионов |
| **ПИ** | `/consumers` | CRUD подписчиков distribution API, ротация ключей, тест среза через `/api/v1/events` |
## Сохранение Telegram-сессии ## Сохранение Telegram-сессии
@@ -66,11 +78,11 @@ docker compose up --build
- Оригинал: `SocialParser/data/telegram.session` - Оригинал: `SocialParser/data/telegram.session`
- В репозитории MapMil: `data/telegram.session` — симлинк для локальной разработки - В репозитории MapMil: `data/telegram.session` — симлинк для локальной разработки
- В Docker `cp-workers`: файл монтируется напрямую как `../SocialParser/data/telegram.session:/data/telegram.session:ro` - В Docker `cp-workers`: каталог `../SocialParser/data` монтируется как `/data` (read-write; Telethon обновляет SQLite-сессию)
- Путь в контейнере: `/data/telegram.session` - Путь в контейнере: `/data/telegram.session`
- Переменные из `.env`: `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_SESSION_PATH=/data/telegram.session` - Переменные из `.env`: `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_SESSION_PATH=/data/telegram.session`
> Симлинк не работает внутри Docker — compose монтирует исходный файл из SocialParser. > Симлинк не работает внутри Docker — compose монтирует каталог `SocialParser/data` целиком.
Файл сессии и `.env` добавлены в `.gitignore` и не коммитятся. Файл сессии и `.env` добавлены в `.gitignore` и не коммитятся.
@@ -90,8 +102,16 @@ docker compose up --build
|-------|------|----------| |-------|------|----------|
| POST | `/admin/jobs` | Создать задание парсинга (ставится в Redis) | | POST | `/admin/jobs` | Создать задание парсинга (ставится в Redis) |
| GET | `/admin/jobs` | Список заданий | | GET | `/admin/jobs` | Список заданий |
| GET | `/admin/events` | Все события | | POST | `/admin/jobs/{id}/retry` | Повторить failed-задание |
| GET | `/admin/events` | События с фильтрами (`{ items, total }`) |
| GET | `/admin/analytics/summary` | KPI-сводка |
| GET | `/admin/analytics/timeline?days=` | Динамика ingest по дням |
| GET | `/admin/analytics/top-localities?limit=` | Топ населённых пунктов |
| GET | `/admin/analytics/top-regions?limit=` | Топ регионов |
| GET | `/admin/consumers` | Список подписчиков ПИ |
| POST | `/admin/consumers` | Создать подписчика ПИ | | POST | `/admin/consumers` | Создать подписчика ПИ |
| PATCH | `/admin/consumers/{id}` | Обновить подписчика |
| POST | `/admin/consumers/{id}/rotate-key` | Сменить API-ключ |
Пример задания Telegram: Пример задания Telegram:
+124 -16
View File
@@ -1,16 +1,36 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..models import Event, ParseJob from ..models import Consumer, Event, ParseJob
from ..schemas import ( from ..schemas import (
AnalyticsSummary,
ConsumerCreate, ConsumerCreate,
ConsumerRead, ConsumerRead,
ConsumerUpdate,
EventListResponse,
EventRead, EventRead,
ParseJobCreate, ParseJobCreate,
ParseJobRead, 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 from ..services.jobs import enqueue_job
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -45,19 +65,81 @@ def get_parse_job(job_id: int, db: Session = Depends(get_db)):
return job 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( 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), 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), db: Session = Depends(get_db),
): ):
return ( query = build_events_query(
db.query(Event) db,
.order_by(Event.ingested_at.desc()) source_type=source_type,
.offset(offset) region=region,
.limit(limit) topic=topic,
.all() 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) @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, topics=payload.topics,
date_from=payload.date_from, date_from=payload.date_from,
) )
return ConsumerRead( return consumer_to_read(consumer, api_key=api_key)
id=consumer.id,
name=consumer.name,
is_active=consumer.is_active, @router.patch("/consumers/{consumer_id}", response_model=ConsumerRead)
created_at=consumer.created_at, def patch_pi_consumer(
api_key=api_key, 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 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): class ConsumerRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -130,3 +138,31 @@ class ConsumerRead(BaseModel):
is_active: bool is_active: bool
created_at: datetime created_at: datetime
api_key: str | None = None 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 sqlalchemy.orm import Session
from ..models import Consumer, ConsumerFilter, Event, MapObject from ..models import Consumer, ConsumerFilter, Event, MapObject
from ..schemas import ConsumerRead
def hash_api_key(api_key: str) -> str: def hash_api_key(api_key: str) -> str:
@@ -15,6 +16,66 @@ def generate_api_key() -> str:
return secrets.token_urlsafe(32) 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( def create_consumer(
db: Session, db: Session,
name: str, name: str,
+4 -1
View File
@@ -9,8 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"chart.js": "^4.4.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"vue": "^3.5.13" "vue": "^3.5.13",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.15", "@types/leaflet": "^1.9.15",
+1 -344
View File
@@ -1,349 +1,6 @@
<script setup lang="ts"> <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> </script>
<template> <template>
<div class="app" @click="closeContextMenu"> <router-view />
<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>
</template> </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"; 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[]> { export function fetchObjects(): Promise<MapObject[]> {
return request<MapObject[]>("/objects"); 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() { function syncMarkers() {
if (!map || !markersLayer) return; if (!map || !markersLayer) return;
@@ -175,6 +187,8 @@ onMounted(() => {
markersLayer = L.layerGroup().addTo(map); markersLayer = L.layerGroup().addTo(map);
mapContainer.value.addEventListener("contextmenu", handleMapContextMenu); mapContainer.value.addEventListener("contextmenu", handleMapContextMenu);
syncMarkers(); syncMarkers();
panToSelected();
setTimeout(() => map?.invalidateSize(), 0);
}); });
onUnmounted(() => { onUnmounted(() => {
@@ -187,7 +201,10 @@ onUnmounted(() => {
watch( watch(
() => [props.objects, props.selectedId] as const, () => [props.objects, props.selectedId] as const,
() => syncMarkers(), () => {
syncMarkers();
panToSelected();
},
{ deep: true }, { deep: true },
); );
</script> </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 { createApp } from "vue";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router";
import "./style.css"; 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 { textarea {
font: inherit; 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; latitude: number;
longitude: number; longitude: number;
created_at: string; created_at: string;
event_id?: number | null;
} }
export interface MapObjectCreate { 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: { server: {
proxy: { proxy: {
"/api": "http://localhost:8000", "/api": "http://localhost:8000",
"/admin": "http://localhost:8000",
}, },
}, },
}); });
+2 -2
View File
@@ -52,8 +52,8 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
INTERNAL_TOKEN: dev-internal-token INTERNAL_TOKEN: dev-internal-token
volumes: volumes:
# Mount original session from SocialParser (preserved, read-only) # Mount SocialParser data dir (session + SQLite journal); same file as on host
- ../SocialParser/data/telegram.session:/data/telegram.session:ro - ../SocialParser/data:/data
depends_on: depends_on:
- redis - redis
- ca-api - ca-api