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>
274 lines
7.3 KiB
Vue
274 lines
7.3 KiB
Vue
<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>
|