Add Profile/Channel entities with CRUD and remove legacy parser builder.
Introduce reusable ParserProfile and ParseChannel, pair jobs with enqueue flatten, Events admin CRUD, and drop inline/legacy parser-builder UI and aliases. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,9 +4,11 @@ import type {
|
||||
Consumer,
|
||||
ConsumerCreate,
|
||||
ConsumerUpdate,
|
||||
EventCreate,
|
||||
EventFilters,
|
||||
EventListResponse,
|
||||
EventRecord,
|
||||
EventUpdate,
|
||||
ParseJob,
|
||||
ParseJobCreate,
|
||||
ParseJobUpdate,
|
||||
@@ -69,6 +71,26 @@ export function fetchEvents(filters: EventFilters = {}): Promise<EventListRespon
|
||||
);
|
||||
}
|
||||
|
||||
export function createEvent(payload: EventCreate): Promise<EventRecord> {
|
||||
return request<EventRecord>(
|
||||
"/events",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateEvent(id: number, payload: EventUpdate): Promise<EventRecord> {
|
||||
return request<EventRecord>(
|
||||
`/events/${id}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteEvent(id: number): Promise<void> {
|
||||
return request<void>(`/events/${id}`, { method: "DELETE" }, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function fetchConsumers(): Promise<Consumer[]> {
|
||||
return request<Consumer[]>("/consumers", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ADMIN_BASE, request } from "./client";
|
||||
import type {
|
||||
HeuristicProfile,
|
||||
ParseChannel,
|
||||
ParseChannelCreate,
|
||||
ParseChannelUpdate,
|
||||
ParserProfile,
|
||||
ParserProfileCreate,
|
||||
ParserProfileUpdate,
|
||||
TargetField,
|
||||
} from "../types/admin";
|
||||
|
||||
export type { HeuristicProfile, TargetField } from "../types/admin";
|
||||
|
||||
export function fetchChannels(): Promise<ParseChannel[]> {
|
||||
return request<ParseChannel[]>("/parse-channels", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function createChannel(payload: ParseChannelCreate): Promise<ParseChannel> {
|
||||
return request<ParseChannel>(
|
||||
"/parse-channels",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateChannel(id: number, payload: ParseChannelUpdate): Promise<ParseChannel> {
|
||||
return request<ParseChannel>(
|
||||
`/parse-channels/${id}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteChannel(id: number): Promise<void> {
|
||||
return request<void>(`/parse-channels/${id}`, { method: "DELETE" }, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function fetchProfiles(): Promise<ParserProfile[]> {
|
||||
return request<ParserProfile[]>("/parser-profiles", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function createProfile(payload: ParserProfileCreate): Promise<ParserProfile> {
|
||||
return request<ParserProfile>(
|
||||
"/parser-profiles",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateProfile(id: number, payload: ParserProfileUpdate): Promise<ParserProfile> {
|
||||
return request<ParserProfile>(
|
||||
`/parser-profiles/${id}`,
|
||||
{ method: "PATCH", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteProfile(id: number): Promise<void> {
|
||||
return request<void>(`/parser-profiles/${id}`, { method: "DELETE" }, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function fetchTargetFields(): Promise<{ fields: TargetField[] }> {
|
||||
return request<{ fields: TargetField[] }>("/parser-profiles/target-fields", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function generateParserProfile(sample_post: string): Promise<{ profile: HeuristicProfile }> {
|
||||
return request<{ profile: HeuristicProfile }>(
|
||||
"/parser-profiles/generate",
|
||||
{ method: "POST", body: JSON.stringify({ sample_post }) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function previewParserProfile(
|
||||
sample_post: string,
|
||||
heuristic_profile: HeuristicProfile | Record<string, unknown>,
|
||||
): Promise<{ fields: Record<string, string> }> {
|
||||
return request<{ fields: Record<string, string> }>(
|
||||
"/parser-profiles/preview",
|
||||
{ method: "POST", body: JSON.stringify({ sample_post, heuristic_profile }) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ADMIN_BASE, request } from "./client";
|
||||
import type { ParseJob } from "../types/admin";
|
||||
|
||||
export type TargetField = {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type HeuristicProfile = {
|
||||
version: number;
|
||||
fields: Record<string, Record<string, unknown>>;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export function fetchTargetFields(): Promise<{ fields: TargetField[] }> {
|
||||
return request<{ fields: TargetField[] }>("/parser-builder/target-fields", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function generateParserProfile(sample_post: string): Promise<{ profile: HeuristicProfile }> {
|
||||
return request<{ profile: HeuristicProfile }>(
|
||||
"/parser-builder/generate",
|
||||
{ method: "POST", body: JSON.stringify({ sample_post }) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function previewParserProfile(
|
||||
sample_post: string,
|
||||
profile: HeuristicProfile | Record<string, unknown>,
|
||||
): Promise<{ fields: Record<string, string> }> {
|
||||
return request<{ fields: Record<string, string> }>(
|
||||
"/parser-builder/preview",
|
||||
{ method: "POST", body: JSON.stringify({ sample_post, profile }) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
export function createProfileJob(payload: {
|
||||
channel: string;
|
||||
profile: HeuristicProfile | Record<string, unknown>;
|
||||
sample_post?: string;
|
||||
limit?: number;
|
||||
interval_seconds?: number;
|
||||
is_active?: boolean;
|
||||
}): Promise<ParseJob> {
|
||||
return request<ParseJob>(
|
||||
"/parser-builder/jobs",
|
||||
{ method: "POST", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,9 @@ const route = useRoute();
|
||||
|
||||
const navItems = [
|
||||
{ to: "/", label: "Карта", exact: true },
|
||||
{ to: "/channels", label: "Каналы" },
|
||||
{ to: "/parser-profiles", label: "Профили" },
|
||||
{ to: "/parsers", label: "Парсеры" },
|
||||
{ to: "/parser-builder", label: "Конструктор" },
|
||||
{ to: "/events", label: "События" },
|
||||
{ to: "/analytics", label: "Аналитика" },
|
||||
{ to: "/consumers", label: "ПИ" },
|
||||
|
||||
@@ -2,10 +2,11 @@ import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import AppLayout from "../layouts/AppLayout.vue";
|
||||
import AnalyticsView from "../views/AnalyticsView.vue";
|
||||
import ChannelsView from "../views/ChannelsView.vue";
|
||||
import ConsumersView from "../views/ConsumersView.vue";
|
||||
import EventsView from "../views/EventsView.vue";
|
||||
import MapViewPage from "../views/MapViewPage.vue";
|
||||
import ParserBuilderView from "../views/ParserBuilderView.vue";
|
||||
import ParserProfilesView from "../views/ParserProfilesView.vue";
|
||||
import ParsersView from "../views/ParsersView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
@@ -16,8 +17,9 @@ const router = createRouter({
|
||||
component: AppLayout,
|
||||
children: [
|
||||
{ path: "", name: "map", component: MapViewPage },
|
||||
{ path: "channels", name: "channels", component: ChannelsView },
|
||||
{ path: "parser-profiles", name: "parser-profiles", component: ParserProfilesView },
|
||||
{ path: "parsers", name: "parsers", component: ParsersView },
|
||||
{ path: "parser-builder", name: "parser-builder", component: ParserBuilderView },
|
||||
{ path: "events", name: "events", component: EventsView },
|
||||
{ path: "analytics", name: "analytics", component: AnalyticsView },
|
||||
{ path: "consumers", name: "consumers", component: ConsumersView },
|
||||
|
||||
@@ -125,6 +125,15 @@ textarea {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: #b71c1c;
|
||||
border-color: #ef9a9a;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #ffebee;
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -2,6 +2,8 @@ export interface ParseJob {
|
||||
id: number;
|
||||
source_type: string;
|
||||
source_config: Record<string, unknown>;
|
||||
profile_id?: number | null;
|
||||
channel_id?: number | null;
|
||||
schedule: string | null;
|
||||
interval_seconds: number;
|
||||
is_active: boolean;
|
||||
@@ -9,11 +11,17 @@ export interface ParseJob {
|
||||
last_run_at: string | null;
|
||||
last_error: string | null;
|
||||
created_at: string;
|
||||
profile_name?: string | null;
|
||||
channel_name?: string | null;
|
||||
channel_handle?: string | null;
|
||||
}
|
||||
|
||||
export interface ParseJobCreate {
|
||||
source_type: string;
|
||||
source_config: Record<string, unknown>;
|
||||
source_type?: string;
|
||||
source_config?: Record<string, unknown>;
|
||||
profile_id?: number;
|
||||
channel_id?: number;
|
||||
limit?: number;
|
||||
schedule?: string | null;
|
||||
interval_seconds?: number;
|
||||
is_active?: boolean;
|
||||
@@ -21,10 +29,71 @@ export interface ParseJobCreate {
|
||||
|
||||
export interface ParseJobUpdate {
|
||||
source_config?: Record<string, unknown>;
|
||||
profile_id?: number;
|
||||
channel_id?: number;
|
||||
limit?: number;
|
||||
interval_seconds?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ParserProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
sample_post: string;
|
||||
heuristic_profile: Record<string, unknown> | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ParserProfileCreate {
|
||||
name: string;
|
||||
sample_post?: string;
|
||||
heuristic_profile?: Record<string, unknown> | null;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ParserProfileUpdate {
|
||||
name?: string;
|
||||
sample_post?: string;
|
||||
heuristic_profile?: Record<string, unknown> | null;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ParseChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
source_type: string;
|
||||
channel: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ParseChannelCreate {
|
||||
name: string;
|
||||
source_type?: string;
|
||||
channel: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ParseChannelUpdate {
|
||||
name?: string;
|
||||
source_type?: string;
|
||||
channel?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export type TargetField = {
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type HeuristicProfile = {
|
||||
version: number;
|
||||
fields: Record<string, Record<string, unknown>>;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export interface EventRecord {
|
||||
id: number;
|
||||
source_type: string;
|
||||
@@ -43,6 +112,38 @@ export interface EventRecord {
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface EventCreate {
|
||||
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;
|
||||
region?: string | null;
|
||||
topic?: string | null;
|
||||
tags?: string[] | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface EventUpdate {
|
||||
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;
|
||||
region?: string | null;
|
||||
topic?: string | null;
|
||||
tags?: string[] | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface EventListResponse {
|
||||
items: EventRecord[];
|
||||
total: number;
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import {
|
||||
createChannel,
|
||||
deleteChannel,
|
||||
fetchChannels,
|
||||
updateChannel,
|
||||
} from "../api/entities";
|
||||
import type { ParseChannel } from "../types/admin";
|
||||
|
||||
const channels = ref<ParseChannel[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
const form = ref({
|
||||
name: "",
|
||||
channel: "",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const editModal = ref<{
|
||||
open: boolean;
|
||||
channel: ParseChannel | null;
|
||||
name: string;
|
||||
handle: string;
|
||||
is_active: boolean;
|
||||
}>({
|
||||
open: false,
|
||||
channel: null,
|
||||
name: "",
|
||||
handle: "",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
async function loadChannels() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
channels.value = await fetchChannels();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось загрузить каналы";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!form.value.name.trim() || !form.value.channel.trim()) {
|
||||
error.value = "Укажите название и handle канала";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await createChannel({
|
||||
name: form.value.name.trim(),
|
||||
channel: form.value.channel.trim(),
|
||||
source_type: "telegram",
|
||||
is_active: form.value.is_active,
|
||||
});
|
||||
form.value = { name: "", channel: "", is_active: true };
|
||||
await loadChannels();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось создать канал";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(ch: ParseChannel) {
|
||||
editModal.value = {
|
||||
open: true,
|
||||
channel: ch,
|
||||
name: ch.name,
|
||||
handle: ch.channel,
|
||||
is_active: ch.is_active,
|
||||
};
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
editModal.value.open = false;
|
||||
editModal.value.channel = null;
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!editModal.value.channel) return;
|
||||
if (!editModal.value.name.trim() || !editModal.value.handle.trim()) {
|
||||
error.value = "Укажите название и handle";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await updateChannel(editModal.value.channel.id, {
|
||||
name: editModal.value.name.trim(),
|
||||
channel: editModal.value.handle.trim(),
|
||||
is_active: editModal.value.is_active,
|
||||
});
|
||||
closeEdit();
|
||||
await loadChannels();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось сохранить";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(ch: ParseChannel) {
|
||||
if (!window.confirm(`Удалить канал «${ch.name}» (@${ch.channel})?`)) return;
|
||||
error.value = "";
|
||||
try {
|
||||
await deleteChannel(ch.id);
|
||||
await loadChannels();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось удалить";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
onMounted(loadChannels);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-heading">Каналы</h2>
|
||||
<p class="intro">
|
||||
Каналы для парсинга (Telegram). Связка канал + профиль создаётся на странице
|
||||
<router-link to="/parsers">Парсеры</router-link>.
|
||||
</p>
|
||||
|
||||
<section class="card">
|
||||
<h3>Новый канал</h3>
|
||||
<form class="admin-form" @submit.prevent="handleCreate">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Название
|
||||
<input v-model="form.name" type="text" placeholder="Сводки фронта" required />
|
||||
</label>
|
||||
<label>
|
||||
Handle / id
|
||||
<input v-model="form.channel" type="text" placeholder="example_channel" required />
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.is_active" type="checkbox" />
|
||||
Активен
|
||||
</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-for="ch in channels" :key="ch.id">
|
||||
<td>{{ ch.id }}</td>
|
||||
<td>{{ ch.name }}</td>
|
||||
<td><code>@{{ ch.channel }}</code></td>
|
||||
<td>{{ ch.source_type }}</td>
|
||||
<td>{{ ch.is_active ? "да" : "нет" }}</td>
|
||||
<td>{{ formatDate(ch.created_at) }}</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="btn btn-sm" @click="openEdit(ch)">Изменить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="handleDelete(ch)">
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !channels.length">
|
||||
<td colspan="7" class="muted">Пока нет каналов</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="editModal.open" class="modal-backdrop" @click.self="closeEdit">
|
||||
<div class="modal">
|
||||
<h3>Редактировать канал #{{ editModal.channel?.id }}</h3>
|
||||
<form class="admin-form" @submit.prevent="handleSaveEdit">
|
||||
<label>
|
||||
Название
|
||||
<input v-model="editModal.name" type="text" required />
|
||||
</label>
|
||||
<label>
|
||||
Handle / id
|
||||
<input v-model="editModal.handle" type="text" required />
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input v-model="editModal.is_active" type="checkbox" />
|
||||
Активен
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" @click="closeEdit">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intro {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
width: min(420px, 92vw);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.modal .admin-form label {
|
||||
display: block;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { fetchEvents } from "../api/admin";
|
||||
import { createEvent, deleteEvent, fetchEvents, updateEvent } from "../api/admin";
|
||||
import type { EventFilters, EventRecord } from "../types/admin";
|
||||
|
||||
const router = useRouter();
|
||||
@@ -10,6 +10,7 @@ const items = ref<EventRecord[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
const filters = ref({
|
||||
search: "",
|
||||
@@ -24,8 +25,44 @@ const filters = ref({
|
||||
const page = ref(1);
|
||||
const pageSize = 25;
|
||||
|
||||
const selectedEvent = ref<EventRecord | null>(null);
|
||||
const modalOpen = ref(false);
|
||||
type EventForm = {
|
||||
source_type: string;
|
||||
source_url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
locality: string;
|
||||
region: string;
|
||||
topic: string;
|
||||
raw_text: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
event_date: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
function emptyForm(): EventForm {
|
||||
return {
|
||||
source_type: "manual",
|
||||
source_url: "",
|
||||
title: "",
|
||||
description: "",
|
||||
locality: "",
|
||||
region: "",
|
||||
topic: "",
|
||||
raw_text: "",
|
||||
latitude: "",
|
||||
longitude: "",
|
||||
event_date: "",
|
||||
tags: "",
|
||||
};
|
||||
}
|
||||
|
||||
const createForm = ref(emptyForm());
|
||||
const editModal = ref<{ open: boolean; event: EventRecord | null; form: EventForm }>({
|
||||
open: false,
|
||||
event: null,
|
||||
form: emptyForm(),
|
||||
});
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
|
||||
|
||||
@@ -76,14 +113,128 @@ function resetFilters() {
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
function openModal(event: EventRecord) {
|
||||
selectedEvent.value = event;
|
||||
modalOpen.value = true;
|
||||
function parseOptionalFloat(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const num = Number(trimmed);
|
||||
if (Number.isNaN(num)) throw new Error("Координаты должны быть числами");
|
||||
return num;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen.value = false;
|
||||
selectedEvent.value = null;
|
||||
function parseTags(value: string): string[] | null {
|
||||
const tags = value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
return tags.length ? tags : null;
|
||||
}
|
||||
|
||||
function toDateTimeLocal(value: string | null): string {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function formToPayload(form: EventForm) {
|
||||
const latitude = parseOptionalFloat(form.latitude);
|
||||
const longitude = parseOptionalFloat(form.longitude);
|
||||
if ((latitude == null) !== (longitude == null)) {
|
||||
throw new Error("Укажите обе координаты или ни одной");
|
||||
}
|
||||
const source_url = form.source_url.trim();
|
||||
if (!source_url) throw new Error("Укажите source_url");
|
||||
|
||||
return {
|
||||
source_type: form.source_type.trim() || "manual",
|
||||
source_url,
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim(),
|
||||
locality: form.locality.trim(),
|
||||
region: form.region.trim() || null,
|
||||
topic: form.topic.trim() || null,
|
||||
raw_text: form.raw_text,
|
||||
latitude,
|
||||
longitude,
|
||||
event_date: form.event_date ? new Date(form.event_date).toISOString() : null,
|
||||
tags: parseTags(form.tags),
|
||||
};
|
||||
}
|
||||
|
||||
function eventToForm(event: EventRecord): EventForm {
|
||||
return {
|
||||
source_type: event.source_type || "manual",
|
||||
source_url: event.source_url,
|
||||
title: event.title || "",
|
||||
description: event.description || "",
|
||||
locality: event.locality || "",
|
||||
region: event.region || "",
|
||||
topic: event.topic || "",
|
||||
raw_text: event.raw_text || "",
|
||||
latitude: event.latitude != null ? String(event.latitude) : "",
|
||||
longitude: event.longitude != null ? String(event.longitude) : "",
|
||||
event_date: toDateTimeLocal(event.event_date),
|
||||
tags: event.tags?.join(", ") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await createEvent(formToPayload(createForm.value));
|
||||
createForm.value = emptyForm();
|
||||
createForm.value.source_url = `manual://${crypto.randomUUID()}`;
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось создать событие";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(event: EventRecord) {
|
||||
editModal.value = {
|
||||
open: true,
|
||||
event,
|
||||
form: eventToForm(event),
|
||||
};
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
editModal.value.open = false;
|
||||
editModal.value.event = null;
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!editModal.value.event) return;
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await updateEvent(editModal.value.event.id, formToPayload(editModal.value.form));
|
||||
closeEdit();
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось сохранить событие";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(event: EventRecord) {
|
||||
const label = event.title || event.source_url || `#${event.id}`;
|
||||
if (!window.confirm(`Удалить событие #${event.id} («${label}»)? Связанная точка на карте тоже будет удалена.`)) {
|
||||
return;
|
||||
}
|
||||
error.value = "";
|
||||
try {
|
||||
await deleteEvent(event.id);
|
||||
if (editModal.value.event?.id === event.id) closeEdit();
|
||||
await loadEvents();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось удалить событие";
|
||||
}
|
||||
}
|
||||
|
||||
function goToMap(event: EventRecord) {
|
||||
@@ -103,13 +254,77 @@ function truncate(text: string, max = 80): string {
|
||||
|
||||
watch(page, loadEvents);
|
||||
|
||||
onMounted(loadEvents);
|
||||
onMounted(() => {
|
||||
createForm.value.source_url = `manual://${crypto.randomUUID()}`;
|
||||
loadEvents();
|
||||
});
|
||||
</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="createForm.title" type="text" placeholder="Краткий заголовок" />
|
||||
</label>
|
||||
<label>
|
||||
Источник (type)
|
||||
<input v-model="createForm.source_type" type="text" placeholder="manual" />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
source_url
|
||||
<input v-model="createForm.source_url" type="text" required />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
Описание
|
||||
<textarea v-model="createForm.description" rows="2" />
|
||||
</label>
|
||||
<label>
|
||||
Регион
|
||||
<input v-model="createForm.region" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Тема
|
||||
<input v-model="createForm.topic" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Населённый пункт
|
||||
<input v-model="createForm.locality" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Дата события
|
||||
<input v-model="createForm.event_date" type="datetime-local" />
|
||||
</label>
|
||||
<label>
|
||||
Широта
|
||||
<input v-model="createForm.latitude" type="text" placeholder="48.1234" />
|
||||
</label>
|
||||
<label>
|
||||
Долгота
|
||||
<input v-model="createForm.longitude" type="text" placeholder="37.6543" />
|
||||
</label>
|
||||
<label>
|
||||
Теги (через запятую)
|
||||
<input v-model="createForm.tags" type="text" placeholder="обстрел, нп" />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
Сырой текст
|
||||
<textarea v-model="createForm.raw_text" rows="3" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Создание..." : "Создать событие" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<form class="admin-form filters-form" @submit.prevent="applyFilters">
|
||||
<div class="form-row">
|
||||
@@ -177,7 +392,7 @@ onMounted(loadEvents);
|
||||
<tr v-for="event in items" :key="event.id">
|
||||
<td>{{ event.id }}</td>
|
||||
<td>
|
||||
<button class="link-btn" @click="openModal(event)">
|
||||
<button class="link-btn" type="button" @click="openEdit(event)">
|
||||
{{ truncate(event.title || event.description || "—") }}
|
||||
</button>
|
||||
</td>
|
||||
@@ -191,14 +406,19 @@ onMounted(loadEvents);
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
<td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(event)">Изменить</button>
|
||||
<button
|
||||
v-if="event.latitude != null && event.longitude != null"
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
@click="goToMap(event)"
|
||||
>
|
||||
На карте
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="handleDelete(event)">
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -206,56 +426,129 @@ onMounted(loadEvents);
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
<button class="btn btn-sm" :disabled="page <= 1" @click="page--">← Назад</button>
|
||||
<button class="btn btn-sm" type="button" :disabled="page <= 1" @click="page--">← Назад</button>
|
||||
<span>Стр. {{ page }} / {{ totalPages }}</span>
|
||||
<button class="btn btn-sm" :disabled="page >= totalPages" @click="page++">Вперёд →</button>
|
||||
<button class="btn btn-sm" type="button" :disabled="page >= totalPages" @click="page++">
|
||||
Вперёд →
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="modalOpen && selectedEvent" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div v-if="editModal.open && editModal.event" class="modal-overlay" @click.self="closeEdit">
|
||||
<div class="modal modal-wide">
|
||||
<header class="modal-header">
|
||||
<h3>Событие #{{ selectedEvent.id }}</h3>
|
||||
<button class="btn btn-sm" @click="closeModal">✕</button>
|
||||
<h3>Событие #{{ editModal.event.id }}</h3>
|
||||
<button class="btn btn-sm" type="button" @click="closeEdit">✕</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>
|
||||
<form class="modal-body admin-form" @submit.prevent="handleSaveEdit">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Заголовок
|
||||
<input v-model="editModal.form.title" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Источник (type)
|
||||
<input v-model="editModal.form.source_type" type="text" />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
source_url
|
||||
<input v-model="editModal.form.source_url" type="text" required />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
Описание
|
||||
<textarea v-model="editModal.form.description" rows="2" />
|
||||
</label>
|
||||
<label>
|
||||
Регион
|
||||
<input v-model="editModal.form.region" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Тема
|
||||
<input v-model="editModal.form.topic" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Населённый пункт
|
||||
<input v-model="editModal.form.locality" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Дата события
|
||||
<input v-model="editModal.form.event_date" type="datetime-local" />
|
||||
</label>
|
||||
<label>
|
||||
Широта
|
||||
<input v-model="editModal.form.latitude" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Долгота
|
||||
<input v-model="editModal.form.longitude" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Теги
|
||||
<input v-model="editModal.form.tags" type="text" />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
Сырой текст
|
||||
<textarea v-model="editModal.form.raw_text" rows="4" />
|
||||
</label>
|
||||
<p class="hint span-2">
|
||||
Ingested: {{ formatDate(editModal.event.ingested_at) }}. При координатах создаётся/обновляется
|
||||
точка на карте; без координат связанная точка удаляется.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn" @click="closeEdit">Отмена</button>
|
||||
<button
|
||||
v-if="editModal.event.latitude != null"
|
||||
type="button"
|
||||
class="btn"
|
||||
@click="goToMap(editModal.event)"
|
||||
>
|
||||
На карте
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Сохранение..." : "Сохранить" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-row .span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.form-row textarea {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.modal-wide {
|
||||
width: min(720px, 94vw);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
createProfileJob,
|
||||
fetchTargetFields,
|
||||
generateParserProfile,
|
||||
previewParserProfile,
|
||||
type HeuristicProfile,
|
||||
type TargetField,
|
||||
} from "../api/parserBuilder";
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 900, label: "15 мин" },
|
||||
{ value: 1800, label: "30 мин" },
|
||||
{ value: 3600, label: "1 ч" },
|
||||
{ value: 10800, label: "3 ч" },
|
||||
{ value: 21600, label: "6 ч" },
|
||||
{ value: 86400, label: "24 ч" },
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const samplePost = ref("");
|
||||
const targetFields = ref<TargetField[]>([]);
|
||||
const profile = ref<HeuristicProfile | null>(null);
|
||||
const profileJson = ref("");
|
||||
const previewFields = ref<Record<string, string> | null>(null);
|
||||
const channel = ref("");
|
||||
const limit = ref(100);
|
||||
const intervalSeconds = ref(3600);
|
||||
|
||||
const loadingFields = ref(true);
|
||||
const generating = ref(false);
|
||||
const previewing = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
|
||||
async function loadTargetFields() {
|
||||
loadingFields.value = true;
|
||||
try {
|
||||
const data = await fetchTargetFields();
|
||||
targetFields.value = data.fields;
|
||||
error.value = "";
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось загрузить целевые поля";
|
||||
} finally {
|
||||
loadingFields.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncProfileFromJson(): HeuristicProfile | null {
|
||||
if (!profileJson.value.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(profileJson.value) as HeuristicProfile;
|
||||
profile.value = parsed;
|
||||
error.value = "";
|
||||
return parsed;
|
||||
} catch {
|
||||
error.value = "Некорректный JSON профиля";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!samplePost.value.trim()) {
|
||||
error.value = "Вставьте образец поста";
|
||||
return;
|
||||
}
|
||||
generating.value = true;
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
previewFields.value = null;
|
||||
try {
|
||||
const data = await generateParserProfile(samplePost.value);
|
||||
profile.value = data.profile;
|
||||
profileJson.value = JSON.stringify(data.profile, null, 2);
|
||||
success.value = "Профиль сгенерирован. Preview и runtime идут только по правилам — без LLM.";
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Ошибка генерации";
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreview() {
|
||||
const current = syncProfileFromJson();
|
||||
if (!current) {
|
||||
if (!error.value) error.value = "Сначала сгенерируйте профиль";
|
||||
return;
|
||||
}
|
||||
if (!samplePost.value.trim()) {
|
||||
error.value = "Нужен образец поста для preview";
|
||||
return;
|
||||
}
|
||||
previewing.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const data = await previewParserProfile(samplePost.value, current);
|
||||
previewFields.value = data.fields;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Ошибка preview";
|
||||
} finally {
|
||||
previewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const current = syncProfileFromJson();
|
||||
if (!current) {
|
||||
if (!error.value) error.value = "Нужен профиль";
|
||||
return;
|
||||
}
|
||||
if (!channel.value.trim()) {
|
||||
error.value = "Укажите канал Telegram";
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
try {
|
||||
const job = await createProfileJob({
|
||||
channel: channel.value.trim(),
|
||||
profile: current,
|
||||
sample_post: samplePost.value.trim() || undefined,
|
||||
limit: limit.value,
|
||||
interval_seconds: intervalSeconds.value,
|
||||
is_active: true,
|
||||
});
|
||||
success.value = `Парсер #${job.id} создан (extract_mode=profile).`;
|
||||
await router.push({ name: "parsers" });
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось создать парсер";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadTargetFields);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-heading">Конструктор парсера</h2>
|
||||
<p class="intro">
|
||||
Вставьте образец структурированного поста → DeepSeek один раз построит статичный профиль правил.
|
||||
Дальше preview и парсинг канала идут только по профилю, без LLM на каждый пост.
|
||||
</p>
|
||||
|
||||
<section class="card">
|
||||
<h3>Образец поста</h3>
|
||||
<textarea
|
||||
v-model="samplePost"
|
||||
class="sample-area"
|
||||
rows="12"
|
||||
placeholder="15.03.2024 Населённый пункт Описание события… 48.123456, 37.654321 #тема"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="generating || !samplePost.trim()"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
{{ generating ? "Генерация..." : "Сгенерировать парсер" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:disabled="previewing || !profileJson.trim()"
|
||||
@click="handlePreview"
|
||||
>
|
||||
{{ previewing ? "Preview..." : "Preview" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid-2">
|
||||
<section class="card">
|
||||
<h3>Целевая таблица <span class="muted">(Event / ingest)</span></h3>
|
||||
<p v-if="loadingFields" class="muted">Загрузка…</p>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Поле</th>
|
||||
<th>Тип</th>
|
||||
<th>Описание</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="field in targetFields" :key="field.name">
|
||||
<td><code>{{ field.name }}</code></td>
|
||||
<td>{{ field.type }}</td>
|
||||
<td>{{ field.description }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Профиль (JSON)</h3>
|
||||
<p v-if="!profileJson" class="muted">Появится после генерации. Можно слегка поправить правила.</p>
|
||||
<textarea
|
||||
v-model="profileJson"
|
||||
class="profile-area"
|
||||
rows="16"
|
||||
:disabled="!profileJson && !profile"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section v-if="previewFields" class="card">
|
||||
<h3>Preview строки</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="key in Object.keys(previewFields)" :key="key">{{ key }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td v-for="(val, key) in previewFields" :key="key" class="preview-cell">
|
||||
{{ val || "—" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Создать Telegram-парсер</h3>
|
||||
<form class="admin-form" @submit.prevent="handleCreate">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Канал
|
||||
<input v-model="channel" type="text" placeholder="example_channel" required />
|
||||
</label>
|
||||
<label>
|
||||
Лимит batch
|
||||
<input v-model.number="limit" type="number" min="1" max="1000" />
|
||||
</label>
|
||||
<label>
|
||||
Интервал
|
||||
<select v-model.number="intervalSeconds">
|
||||
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="saving || !profileJson.trim() || !channel.trim()"
|
||||
>
|
||||
{{ saving ? "Создание..." : "Создать парсер" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
<p v-if="success" class="form-success">{{ success }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intro {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.sample-area,
|
||||
.profile-area {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
resize: vertical;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-cell {
|
||||
max-width: 180px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
color: #2e7d32;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import {
|
||||
createProfile,
|
||||
deleteProfile,
|
||||
fetchProfiles,
|
||||
fetchTargetFields,
|
||||
generateParserProfile,
|
||||
previewParserProfile,
|
||||
updateProfile,
|
||||
type HeuristicProfile,
|
||||
type TargetField,
|
||||
} from "../api/entities";
|
||||
import type { ParserProfile } from "../types/admin";
|
||||
|
||||
const profiles = ref<ParserProfile[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
const samplePost = ref("");
|
||||
const profileName = ref("");
|
||||
const targetFields = ref<TargetField[]>([]);
|
||||
const profileJson = ref("");
|
||||
const previewFields = ref<Record<string, string> | null>(null);
|
||||
const generating = ref(false);
|
||||
const previewing = ref(false);
|
||||
const loadingFields = ref(true);
|
||||
const editingId = ref<number | null>(null);
|
||||
const profileStatus = ref<"draft" | "ready">("draft");
|
||||
|
||||
async function loadProfiles() {
|
||||
loading.value = true;
|
||||
try {
|
||||
profiles.value = await fetchProfiles();
|
||||
error.value = "";
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось загрузить профили";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTargetFields() {
|
||||
loadingFields.value = true;
|
||||
try {
|
||||
const data = await fetchTargetFields();
|
||||
targetFields.value = data.fields;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось загрузить целевые поля";
|
||||
} finally {
|
||||
loadingFields.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function syncProfileFromJson(): HeuristicProfile | null {
|
||||
if (!profileJson.value.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(profileJson.value) as HeuristicProfile;
|
||||
} catch {
|
||||
error.value = "Некорректный JSON профиля";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
if (!samplePost.value.trim()) {
|
||||
error.value = "Вставьте образец поста";
|
||||
return;
|
||||
}
|
||||
generating.value = true;
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
previewFields.value = null;
|
||||
try {
|
||||
const data = await generateParserProfile(samplePost.value);
|
||||
profileJson.value = JSON.stringify(data.profile, null, 2);
|
||||
success.value = "Профиль сгенерирован. Preview и runtime — только по правилам, без LLM.";
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Ошибка генерации";
|
||||
} finally {
|
||||
generating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreview() {
|
||||
const current = syncProfileFromJson();
|
||||
if (!current) {
|
||||
if (!error.value) error.value = "Сначала сгенерируйте или вставьте профиль";
|
||||
return;
|
||||
}
|
||||
if (!samplePost.value.trim()) {
|
||||
error.value = "Нужен образец поста для preview";
|
||||
return;
|
||||
}
|
||||
previewing.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const data = await previewParserProfile(samplePost.value, current);
|
||||
previewFields.value = data.fields;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Ошибка preview";
|
||||
} finally {
|
||||
previewing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
editingId.value = null;
|
||||
profileName.value = "";
|
||||
samplePost.value = "";
|
||||
profileJson.value = "";
|
||||
profileStatus.value = "draft";
|
||||
previewFields.value = null;
|
||||
success.value = "";
|
||||
}
|
||||
|
||||
function openEdit(p: ParserProfile) {
|
||||
editingId.value = p.id;
|
||||
profileName.value = p.name;
|
||||
samplePost.value = p.sample_post || "";
|
||||
profileJson.value = p.heuristic_profile
|
||||
? JSON.stringify(p.heuristic_profile, null, 2)
|
||||
: "";
|
||||
profileStatus.value = p.status === "ready" ? "ready" : "draft";
|
||||
previewFields.value = null;
|
||||
success.value = "";
|
||||
error.value = "";
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!profileName.value.trim()) {
|
||||
error.value = "Укажите название профиля";
|
||||
return;
|
||||
}
|
||||
const current = syncProfileFromJson();
|
||||
if (!current) {
|
||||
if (!error.value) error.value = "Нужен JSON профиля (Generate или вручную)";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
try {
|
||||
if (editingId.value != null) {
|
||||
await updateProfile(editingId.value, {
|
||||
name: profileName.value.trim(),
|
||||
sample_post: samplePost.value,
|
||||
heuristic_profile: current,
|
||||
status: profileStatus.value,
|
||||
});
|
||||
success.value = `Профиль #${editingId.value} обновлён`;
|
||||
} else {
|
||||
const created = await createProfile({
|
||||
name: profileName.value.trim(),
|
||||
sample_post: samplePost.value,
|
||||
heuristic_profile: current,
|
||||
status: profileStatus.value === "draft" ? "draft" : "ready",
|
||||
});
|
||||
success.value = `Профиль #${created.id} сохранён`;
|
||||
editingId.value = created.id;
|
||||
profileStatus.value = created.status === "ready" ? "ready" : "draft";
|
||||
}
|
||||
await loadProfiles();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось сохранить профиль";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(p: ParserProfile) {
|
||||
if (!window.confirm(`Удалить профиль «${p.name}»?`)) return;
|
||||
error.value = "";
|
||||
try {
|
||||
await deleteProfile(p.id);
|
||||
if (editingId.value === p.id) resetEditor();
|
||||
await loadProfiles();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось удалить";
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProfiles();
|
||||
loadTargetFields();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-heading">Профили парсера</h2>
|
||||
<p class="intro">
|
||||
Статичный эвристический профиль: Generate (DeepSeek один раз) → Preview → Сохранить.
|
||||
Дальше профиль подключается к каналу на странице
|
||||
<router-link to="/parsers">Парсеры</router-link>.
|
||||
</p>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h3>{{ editingId != null ? `Редактирование #${editingId}` : "Новый профиль" }}</h3>
|
||||
<button v-if="editingId != null" type="button" class="btn btn-sm" @click="resetEditor">
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Название
|
||||
<input v-model="profileName" type="text" placeholder="Сводка: дата + НП + coords" />
|
||||
</label>
|
||||
<label>
|
||||
Статус
|
||||
<select v-model="profileStatus">
|
||||
<option value="draft">draft</option>
|
||||
<option value="ready">ready</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Образец поста
|
||||
<textarea
|
||||
v-model="samplePost"
|
||||
class="sample-area"
|
||||
rows="10"
|
||||
placeholder="15.03.2024 Населённый пункт Описание… 48.123456, 37.654321"
|
||||
/>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="generating || !samplePost.trim()"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
{{ generating ? "Генерация..." : "Generate" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:disabled="previewing || !profileJson.trim()"
|
||||
@click="handlePreview"
|
||||
>
|
||||
{{ previewing ? "Preview..." : "Preview" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="submitting || !profileJson.trim() || !profileName.trim()"
|
||||
@click="handleSave"
|
||||
>
|
||||
{{ submitting ? "Сохранение..." : editingId != null ? "Обновить" : "Сохранить профиль" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid-2">
|
||||
<section class="card">
|
||||
<h3>Целевые поля <span class="muted">(Event)</span></h3>
|
||||
<p v-if="loadingFields" class="muted">Загрузка…</p>
|
||||
<div v-else class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Поле</th>
|
||||
<th>Тип</th>
|
||||
<th>Описание</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="field in targetFields" :key="field.name">
|
||||
<td><code>{{ field.name }}</code></td>
|
||||
<td>{{ field.type }}</td>
|
||||
<td>{{ field.description }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Профиль (JSON)</h3>
|
||||
<textarea
|
||||
v-model="profileJson"
|
||||
class="profile-area"
|
||||
rows="16"
|
||||
spellcheck="false"
|
||||
placeholder="Появится после Generate"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section v-if="previewFields" class="card">
|
||||
<h3>Preview строки</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="key in Object.keys(previewFields)" :key="key">{{ key }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td v-for="(val, key) in previewFields" :key="key" class="preview-cell">
|
||||
{{ val || "—" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
<p v-if="success" class="form-success">{{ success }}</p>
|
||||
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in profiles" :key="p.id">
|
||||
<td>{{ p.id }}</td>
|
||||
<td>{{ p.name }}</td>
|
||||
<td>{{ p.status }}</td>
|
||||
<td>{{ formatDate(p.created_at) }}</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="btn btn-sm" @click="openEdit(p)">Изменить</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" @click="handleDelete(p)">
|
||||
Удалить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !profiles.length">
|
||||
<td colspan="5" class="muted">Пока нет профилей</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intro {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.card-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sample-area,
|
||||
.profile-area {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
resize: vertical;
|
||||
line-height: 1.45;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-cell {
|
||||
max-width: 180px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
color: #2e7d32;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { createJob, deleteJob, fetchJobs, retryJob, updateJob } from "../api/admin";
|
||||
import type { ParseJob } from "../types/admin";
|
||||
import { fetchChannels, fetchProfiles } from "../api/entities";
|
||||
import type { ParseChannel, ParseJob, ParserProfile } from "../types/admin";
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 900, label: "15 мин" },
|
||||
@@ -12,105 +13,65 @@ const INTERVAL_OPTIONS = [
|
||||
{ value: 86400, label: "24 ч" },
|
||||
];
|
||||
|
||||
const SOURCE_TYPES = [
|
||||
{ value: "telegram", label: "Telegram" },
|
||||
{ value: "crawl4ai", label: "Crawl4AI (web)" },
|
||||
{ value: "viina", label: "VIINA (news NLP)" },
|
||||
] as const;
|
||||
|
||||
const jobs = ref<ParseJob[]>([]);
|
||||
const channels = ref<ParseChannel[]>([]);
|
||||
const profiles = ref<ParserProfile[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
const forcingJobId = ref<number | null>(null);
|
||||
const editingJob = ref<ParseJob | null>(null);
|
||||
|
||||
const pairForm = ref({
|
||||
channel_id: null as number | null,
|
||||
profile_id: null as number | null,
|
||||
limit: 100,
|
||||
interval_seconds: 3600,
|
||||
});
|
||||
|
||||
const editForm = ref({
|
||||
channel: "",
|
||||
limit: 50,
|
||||
urlsText: "",
|
||||
textsText: "",
|
||||
domain_profile: "generic_news",
|
||||
input_mode: "urls" as "urls" | "texts" | "mixed",
|
||||
extract_mode: "heuristic" as "heuristic" | "llm",
|
||||
channel_id: null as number | null,
|
||||
profile_id: null as number | null,
|
||||
limit: 100,
|
||||
interval_seconds: 3600,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const form = ref({
|
||||
source_type: "telegram" as "telegram" | "crawl4ai" | "viina",
|
||||
channel: "",
|
||||
limit: 50,
|
||||
urlsText: "",
|
||||
textsText: "",
|
||||
domain_profile: "generic_news",
|
||||
input_mode: "urls" as "urls" | "texts" | "mixed",
|
||||
extract_mode: "heuristic" as "heuristic" | "llm",
|
||||
interval_seconds: 3600,
|
||||
const readyProfiles = computed(() =>
|
||||
profiles.value.filter((p) => !!p.heuristic_profile),
|
||||
);
|
||||
const activeChannels = computed(() => channels.value.filter((c) => c.is_active));
|
||||
|
||||
const editChannelOptions = computed(() => channels.value);
|
||||
|
||||
const editProfileOptions = computed(() => {
|
||||
const list = [...readyProfiles.value];
|
||||
const id = editForm.value.profile_id;
|
||||
if (id != null && !list.some((p) => p.id === id)) {
|
||||
const found = profiles.value.find((p) => p.id === id);
|
||||
if (found) list.unshift(found);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function channelFromConfig(config: Record<string, unknown>): string {
|
||||
return typeof config.channel === "string" ? config.channel : "";
|
||||
function isPairJob(job: ParseJob | null): boolean {
|
||||
return !!job && job.profile_id != null && job.channel_id != null;
|
||||
}
|
||||
|
||||
function limitFromConfig(config: Record<string, unknown>): number {
|
||||
const limit = config.limit;
|
||||
return typeof limit === "number" ? limit : 50;
|
||||
}
|
||||
|
||||
function urlsFromConfig(config: Record<string, unknown>): string[] {
|
||||
return Array.isArray(config.urls)
|
||||
? config.urls.filter((u): u is string => typeof u === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function textsFromConfig(config: Record<string, unknown>): string[] {
|
||||
return Array.isArray(config.texts)
|
||||
? config.texts.filter((t): t is string => typeof t === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function parseLines(text: string): string[] {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractModeLabel(cfg: Record<string, unknown>): string {
|
||||
if (cfg.extract_mode === "llm") return " [LLM]";
|
||||
if (cfg.extract_mode === "profile") return " [profile]";
|
||||
return "";
|
||||
return typeof limit === "number" ? limit : 100;
|
||||
}
|
||||
|
||||
function sourceSummary(job: ParseJob): string {
|
||||
const cfg = job.source_config;
|
||||
const mode = extractModeLabel(cfg);
|
||||
if (job.source_type === "telegram") {
|
||||
return (channelFromConfig(cfg) || "—") + mode;
|
||||
}
|
||||
const urls = urlsFromConfig(cfg);
|
||||
if (urls.length) return (urls.length === 1 ? urls[0] : `${urls.length} URL`) + mode;
|
||||
const texts = textsFromConfig(cfg);
|
||||
if (texts.length) return `${texts.length} текст(ов)`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
function isProfileJob(job: ParseJob | null): boolean {
|
||||
return !!job && job.source_config.extract_mode === "profile";
|
||||
}
|
||||
|
||||
function profileJson(job: ParseJob | null): string {
|
||||
if (!job) return "";
|
||||
const profile = job.source_config.heuristic_profile;
|
||||
if (!profile || typeof profile !== "object") return "";
|
||||
try {
|
||||
return JSON.stringify(profile, null, 2);
|
||||
} catch {
|
||||
return "";
|
||||
if (isPairJob(job)) {
|
||||
const ch = job.channel_name || job.channel_handle || `#${job.channel_id}`;
|
||||
const pr = job.profile_name || `#${job.profile_id}`;
|
||||
return `${ch} × ${pr}`;
|
||||
}
|
||||
return `${job.source_type} (без связки)`;
|
||||
}
|
||||
|
||||
function formatInterval(seconds: number): string {
|
||||
@@ -121,64 +82,6 @@ function formatInterval(seconds: number): string {
|
||||
return `${Math.round(seconds / 86400)} д`;
|
||||
}
|
||||
|
||||
function buildSourceConfig(
|
||||
sourceType: string,
|
||||
data: {
|
||||
channel: string;
|
||||
limit: number;
|
||||
urlsText: string;
|
||||
textsText: string;
|
||||
domain_profile: string;
|
||||
input_mode: "urls" | "texts" | "mixed";
|
||||
extract_mode: "heuristic" | "llm";
|
||||
},
|
||||
existing?: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (sourceType === "telegram") {
|
||||
// Preserve builder-created profile jobs (edit form has no profile editor)
|
||||
if (existing?.extract_mode === "profile" && existing.heuristic_profile) {
|
||||
return {
|
||||
channel: data.channel.trim(),
|
||||
limit: data.limit,
|
||||
extract_mode: "profile",
|
||||
heuristic_profile: existing.heuristic_profile,
|
||||
sample_post: existing.sample_post ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
channel: data.channel.trim(),
|
||||
limit: data.limit,
|
||||
extract_mode: data.extract_mode,
|
||||
};
|
||||
}
|
||||
if (sourceType === "crawl4ai") {
|
||||
return {
|
||||
urls: parseLines(data.urlsText),
|
||||
domain_profile: data.domain_profile.trim() || "generic_news",
|
||||
extract_mode: data.extract_mode,
|
||||
};
|
||||
}
|
||||
return {
|
||||
urls: parseLines(data.urlsText),
|
||||
texts: parseLines(data.textsText),
|
||||
input_mode: data.input_mode,
|
||||
};
|
||||
}
|
||||
|
||||
const formHint = computed(() => {
|
||||
if (form.value.source_type === "telegram") {
|
||||
return form.value.extract_mode === "llm"
|
||||
? "LLM (DeepSeek): неструктурированные посты канала разбираются в locality/date/coords/description."
|
||||
: "Активные Telegram-парсеры подхватываются real-time listener; batch забирает последние N постов (эвристики).";
|
||||
}
|
||||
if (form.value.source_type === "crawl4ai") {
|
||||
return form.value.extract_mode === "llm"
|
||||
? "Crawl4AI + LLM (DeepSeek): страница → structured event. Нужен DEEPSEEK_API_KEY и cp-workers-web."
|
||||
: "Crawl4AI обходит URL и нормализует страницы эвристиками. Обрабатывает cp-workers-web.";
|
||||
}
|
||||
return "VIINA-адаптер извлекает инциденты из новостных URL/текстов. Обрабатывает cp-workers-nlp.";
|
||||
});
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
jobs.value = await fetchJobs();
|
||||
@@ -190,68 +93,51 @@ async function loadJobs() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (form.value.source_type === "telegram" && !form.value.channel.trim()) {
|
||||
error.value = "Укажите канал Telegram";
|
||||
return;
|
||||
}
|
||||
if (form.value.source_type === "crawl4ai" && !parseLines(form.value.urlsText).length) {
|
||||
error.value = "Укажите хотя бы один URL";
|
||||
return;
|
||||
}
|
||||
if (form.value.source_type === "viina") {
|
||||
const urls = parseLines(form.value.urlsText);
|
||||
const texts = parseLines(form.value.textsText);
|
||||
if (form.value.input_mode === "urls" && !urls.length) {
|
||||
error.value = "Укажите URL для VIINA";
|
||||
return;
|
||||
}
|
||||
if (form.value.input_mode === "texts" && !texts.length) {
|
||||
error.value = "Укажите тексты для VIINA";
|
||||
return;
|
||||
}
|
||||
if (form.value.input_mode === "mixed" && !urls.length && !texts.length) {
|
||||
error.value = "Укажите URL или тексты";
|
||||
return;
|
||||
}
|
||||
async function loadEntities() {
|
||||
try {
|
||||
const [chs, prs] = await Promise.all([fetchChannels(), fetchProfiles()]);
|
||||
channels.value = chs;
|
||||
profiles.value = prs;
|
||||
} catch {
|
||||
/* selects stay empty */
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePair() {
|
||||
if (!pairForm.value.channel_id || !pairForm.value.profile_id) {
|
||||
error.value = "Выберите канал и профиль";
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await createJob({
|
||||
source_type: form.value.source_type,
|
||||
source_config: buildSourceConfig(form.value.source_type, form.value),
|
||||
interval_seconds: form.value.interval_seconds,
|
||||
channel_id: pairForm.value.channel_id,
|
||||
profile_id: pairForm.value.profile_id,
|
||||
limit: pairForm.value.limit,
|
||||
interval_seconds: pairForm.value.interval_seconds,
|
||||
is_active: true,
|
||||
});
|
||||
form.value.channel = "";
|
||||
form.value.urlsText = "";
|
||||
form.value.textsText = "";
|
||||
pairForm.value.channel_id = null;
|
||||
pairForm.value.profile_id = null;
|
||||
await loadJobs();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось создать задание";
|
||||
error.value = err instanceof Error ? err.message : "Не удалось создать связку";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(job: ParseJob) {
|
||||
if (!isPairJob(job)) {
|
||||
error.value = "Редактируются только связки канал+профиль. Остальные задания можно удалить.";
|
||||
return;
|
||||
}
|
||||
editingJob.value = job;
|
||||
editForm.value = {
|
||||
channel: channelFromConfig(job.source_config),
|
||||
channel_id: job.channel_id ?? null,
|
||||
profile_id: job.profile_id ?? null,
|
||||
limit: limitFromConfig(job.source_config),
|
||||
urlsText: urlsFromConfig(job.source_config).join("\n"),
|
||||
textsText: textsFromConfig(job.source_config).join("\n"),
|
||||
domain_profile:
|
||||
typeof job.source_config.domain_profile === "string"
|
||||
? job.source_config.domain_profile
|
||||
: "generic_news",
|
||||
input_mode:
|
||||
job.source_config.input_mode === "texts" || job.source_config.input_mode === "mixed"
|
||||
? job.source_config.input_mode
|
||||
: "urls",
|
||||
extract_mode: job.source_config.extract_mode === "llm" ? "llm" : "heuristic",
|
||||
interval_seconds: job.interval_seconds,
|
||||
is_active: job.is_active,
|
||||
};
|
||||
@@ -262,19 +148,18 @@ function closeEdit() {
|
||||
}
|
||||
|
||||
async function handleSaveEdit() {
|
||||
if (!editingJob.value) return;
|
||||
const sourceType = editingJob.value.source_type;
|
||||
|
||||
if (sourceType === "telegram" && !editForm.value.channel.trim()) {
|
||||
error.value = "Укажите канал Telegram";
|
||||
if (!editingJob.value || !isPairJob(editingJob.value)) return;
|
||||
if (!editForm.value.channel_id || !editForm.value.profile_id) {
|
||||
error.value = "Выберите канал и профиль";
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
await updateJob(editingJob.value.id, {
|
||||
source_config: buildSourceConfig(sourceType, editForm.value, editingJob.value.source_config),
|
||||
channel_id: editForm.value.channel_id,
|
||||
profile_id: editForm.value.profile_id,
|
||||
limit: editForm.value.limit,
|
||||
interval_seconds: editForm.value.interval_seconds,
|
||||
is_active: editForm.value.is_active,
|
||||
});
|
||||
@@ -333,6 +218,7 @@ function formatDate(value: string | null): string {
|
||||
|
||||
onMounted(() => {
|
||||
loadJobs();
|
||||
loadEntities();
|
||||
pollTimer = setInterval(loadJobs, 5000);
|
||||
});
|
||||
|
||||
@@ -346,82 +232,42 @@ onUnmounted(() => {
|
||||
<h2 class="page-heading">Парсеры</h2>
|
||||
|
||||
<section class="card">
|
||||
<h3>Новый парсер</h3>
|
||||
<h3>Новая связка (канал + профиль)</h3>
|
||||
<p class="hint">
|
||||
{{ formHint }}
|
||||
Дубликаты по <code>source_url</code> не записываются.
|
||||
Статичный парсер по образцу поста —
|
||||
<router-link to="/parser-builder">Конструктор</router-link>.
|
||||
Создайте
|
||||
<router-link to="/channels">канал</router-link>
|
||||
и
|
||||
<router-link to="/parser-profiles">профиль</router-link>,
|
||||
затем свяжите их здесь. В Redis уходит плоский
|
||||
<code>extract_mode=profile</code> + <code>heuristic_profile</code>.
|
||||
</p>
|
||||
<form class="admin-form" @submit.prevent="handleSubmit">
|
||||
<form class="admin-form" @submit.prevent="handleCreatePair">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Тип источника
|
||||
<select v-model="form.source_type">
|
||||
<option v-for="opt in SOURCE_TYPES" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
Канал
|
||||
<select v-model.number="pairForm.channel_id" required>
|
||||
<option :value="null" disabled>Выберите…</option>
|
||||
<option v-for="ch in activeChannels" :key="ch.id" :value="ch.id">
|
||||
{{ ch.name }} (@{{ ch.channel }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<template v-if="form.source_type === 'telegram'">
|
||||
<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>
|
||||
<label>
|
||||
Извлечение
|
||||
<select v-model="form.extract_mode">
|
||||
<option value="heuristic">Эвристики (структурированные посты)</option>
|
||||
<option value="llm">LLM DeepSeek (неструктурированные)</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.source_type === 'crawl4ai'">
|
||||
<label class="span-2">
|
||||
URL (по одному в строке)
|
||||
<textarea v-model="form.urlsText" rows="3" placeholder="https://example.com/news/…" required />
|
||||
</label>
|
||||
<label>
|
||||
Domain profile
|
||||
<input v-model="form.domain_profile" type="text" placeholder="generic_news" />
|
||||
</label>
|
||||
<label>
|
||||
Извлечение
|
||||
<select v-model="form.extract_mode">
|
||||
<option value="heuristic">Эвристики (regex)</option>
|
||||
<option value="llm">LLM DeepSeek (Crawl4AI)</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<label>
|
||||
Режим ввода
|
||||
<select v-model="form.input_mode">
|
||||
<option value="urls">URLs</option>
|
||||
<option value="texts">Texts</option>
|
||||
<option value="mixed">Mixed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.input_mode !== 'texts'" class="span-2">
|
||||
URL (по одному в строке)
|
||||
<textarea v-model="form.urlsText" rows="3" placeholder="https://example.com/article…" />
|
||||
</label>
|
||||
<label v-if="form.input_mode !== 'urls'" class="span-2">
|
||||
Тексты (по одному блоку в строке)
|
||||
<textarea v-model="form.textsText" rows="3" placeholder="Текст статьи…" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<label>
|
||||
Профиль
|
||||
<select v-model.number="pairForm.profile_id" required>
|
||||
<option :value="null" disabled>Выберите…</option>
|
||||
<option v-for="p in readyProfiles" :key="p.id" :value="p.id">
|
||||
{{ p.name }} (#{{ p.id }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Лимит batch
|
||||
<input v-model.number="pairForm.limit" type="number" min="1" max="1000" />
|
||||
</label>
|
||||
<label>
|
||||
Интервал
|
||||
<select v-model.number="form.interval_seconds">
|
||||
<select v-model.number="pairForm.interval_seconds">
|
||||
<option v-for="opt in INTERVAL_OPTIONS" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
@@ -429,16 +275,24 @@ onUnmounted(() => {
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting">
|
||||
{{ submitting ? "Создание..." : "Создать парсер" }}
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="submitting || !pairForm.channel_id || !pairForm.profile_id"
|
||||
>
|
||||
{{ submitting ? "Создание..." : "Создать связку" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-if="!activeChannels.length || !readyProfiles.length" class="hint">
|
||||
Нет активных каналов или готовых профилей —
|
||||
сначала добавьте их на страницах Каналы / Профили.
|
||||
</p>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Парсеры <span v-if="loading" class="muted">(загрузка...)</span></h3>
|
||||
<h3>Связки <span v-if="loading" class="muted">(загрузка...)</span></h3>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
@@ -470,16 +324,18 @@ onUnmounted(() => {
|
||||
<td>{{ formatDate(job.last_run_at) }}</td>
|
||||
<td class="error-cell">{{ job.last_error ?? "—" }}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(job)">Изменить</button>
|
||||
<button
|
||||
v-if="isPairJob(job)"
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
@click="openEdit(job)"
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
:disabled="!canForceRun(job) || forcingJobId === job.id"
|
||||
:title="
|
||||
canForceRun(job)
|
||||
? 'Принудительно выполнить запрос постов'
|
||||
: 'Парсер уже в очереди или выполняется'
|
||||
"
|
||||
@click="handleForceRun(job.id)"
|
||||
>
|
||||
{{ forcingJobId === job.id ? "Запуск..." : "Запустить" }}
|
||||
@@ -501,69 +357,29 @@ onUnmounted(() => {
|
||||
|
||||
<div v-if="editingJob" class="modal-backdrop" @click.self="closeEdit">
|
||||
<div class="modal card">
|
||||
<h3>Редактировать парсер #{{ editingJob.id }} ({{ editingJob.source_type }})</h3>
|
||||
<h3>Редактировать связку #{{ editingJob.id }}</h3>
|
||||
<form class="admin-form" @submit.prevent="handleSaveEdit">
|
||||
<div class="form-row">
|
||||
<template v-if="editingJob.source_type === 'telegram'">
|
||||
<label>
|
||||
Канал
|
||||
<input v-model="editForm.channel" type="text" required />
|
||||
</label>
|
||||
<label>
|
||||
Лимит
|
||||
<input v-model.number="editForm.limit" type="number" min="1" max="1000" />
|
||||
</label>
|
||||
<label v-if="!isProfileJob(editingJob)">
|
||||
Извлечение
|
||||
<select v-model="editForm.extract_mode">
|
||||
<option value="heuristic">Эвристики</option>
|
||||
<option value="llm">LLM DeepSeek</option>
|
||||
</select>
|
||||
</label>
|
||||
<p v-else class="hint profile-hint">
|
||||
Режим: <code>profile</code> (статичный профиль из конструктора).
|
||||
Сменить правила — через повторную генерацию в «Конструктор».
|
||||
</p>
|
||||
<label v-if="isProfileJob(editingJob)" class="span-2">
|
||||
Профиль (только просмотр)
|
||||
<textarea class="profile-ro" :value="profileJson(editingJob)" rows="8" readonly />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="editingJob.source_type === 'crawl4ai'">
|
||||
<label class="span-2">
|
||||
URL (по одному в строке)
|
||||
<textarea v-model="editForm.urlsText" rows="3" required />
|
||||
</label>
|
||||
<label>
|
||||
Domain profile
|
||||
<input v-model="editForm.domain_profile" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
Извлечение
|
||||
<select v-model="editForm.extract_mode">
|
||||
<option value="heuristic">Эвристики</option>
|
||||
<option value="llm">LLM DeepSeek</option>
|
||||
</select>
|
||||
</label>
|
||||
</template>
|
||||
<template v-else>
|
||||
<label>
|
||||
Режим ввода
|
||||
<select v-model="editForm.input_mode">
|
||||
<option value="urls">URLs</option>
|
||||
<option value="texts">Texts</option>
|
||||
<option value="mixed">Mixed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="editForm.input_mode !== 'texts'" class="span-2">
|
||||
URL
|
||||
<textarea v-model="editForm.urlsText" rows="3" />
|
||||
</label>
|
||||
<label v-if="editForm.input_mode !== 'urls'" class="span-2">
|
||||
Тексты
|
||||
<textarea v-model="editForm.textsText" rows="3" />
|
||||
</label>
|
||||
</template>
|
||||
<label>
|
||||
Канал
|
||||
<select v-model.number="editForm.channel_id" required>
|
||||
<option v-for="ch in editChannelOptions" :key="ch.id" :value="ch.id">
|
||||
{{ ch.name }} (@{{ ch.channel }}){{ ch.is_active ? "" : " — выкл." }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Профиль
|
||||
<select v-model.number="editForm.profile_id" required>
|
||||
<option v-for="p in editProfileOptions" :key="p.id" :value="p.id">
|
||||
{{ p.name }} (#{{ p.id }})
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Лимит batch
|
||||
<input v-model.number="editForm.limit" type="number" min="1" max="1000" />
|
||||
</label>
|
||||
<label>
|
||||
Интервал
|
||||
<select v-model.number="editForm.interval_seconds">
|
||||
@@ -597,19 +413,6 @@ onUnmounted(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-row .span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.form-row textarea {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.source-cell {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
@@ -625,11 +428,6 @@ onUnmounted(() => {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -643,8 +441,6 @@ onUnmounted(() => {
|
||||
.modal {
|
||||
width: min(560px, 92vw);
|
||||
margin: 0;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
@@ -654,16 +450,12 @@ onUnmounted(() => {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-hint {
|
||||
flex: 1 1 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-ro {
|
||||
width: 100%;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
background: #f8f8f8;
|
||||
color: #444;
|
||||
.error-cell {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.8rem;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user