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:
@@ -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>
|
||||
Reference in New Issue
Block a user