Files
ridebus-admin/frontend/src/components/TelegramImportPanel.jsx
Zakhar Timoshenko f7f0b039dd Add Telegram cancellation import functionality with tests and documentation
- Implemented Telegram cancellation parsing logic in `telegramCancellationParser.js`.
- Created unit tests for cancellation parsing in `telegramCancellationParser.test.js`.
- Developed integration tests for Telegram import service in `telegramImport.integration.test.js`.
- Added API tests for Telegram import endpoints in `telegramImportApi.test.js`.
- Introduced service tests for Telegram import logic in `telegramImportService.test.js`.
- Documented the Telegram cancellation import process in `telegram-cancellation-import.md`.
- Built a React component for managing Telegram imports in `TelegramImportPanel.jsx`.
2026-10-03 00:45:41 +03:00

136 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { ExternalLink, RefreshCw, Send, X } from 'lucide-react';
import { telegramImportApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
const connectionLabels = {
disabled: 'Импорт выключен', configuration_required: 'Нужна настройка аккаунта',
waiting: 'Ожидание подключения', connecting: 'Подключение к Telegram', ready: 'Канал подключён', error: 'Ошибка подключения'
};
const errorLabels = {
SESSION_MISSING: 'Не найден файл авторизации. Выполните авторизацию аккаунта на сервере.',
AUTH_REQUIRED: 'Требуется повторная авторизация Telegram-аккаунта.',
AUTH_KEY_UNREGISTERED: 'Авторизация отозвана. Подключите аккаунт повторно.',
SESSION_REVOKED: 'Авторизация отозвана. Подключите аккаунт повторно.',
SESSION_FILE_PERMISSIONS: 'Файл авторизации должен быть доступен только владельцу.',
SESSION_FILE_IN_CHECKOUT: 'Перенесите файл авторизации за пределы проекта.',
USER_ACCOUNT_REQUIRED: 'Нужен отдельный пользовательский аккаунт Telegram.',
CHANNEL_PRIVATE: 'Канал недоступен этому аккаунту.', CHANNEL_INVALID: 'Проверьте имя канала в настройках сервера.',
USERNAME_NOT_OCCUPIED: 'Канал с таким именем не найден.',
FLOOD_WAIT: 'Telegram ограничил запросы. Импорт продолжится после паузы.',
HISTORY_LIMIT: 'В канале слишком много сообщений за последние три дня. Нужна проверка импорта.',
IMPORT_FAILED: 'Проверьте соединение с Telegram и миграцию базы данных.'
};
const formatTime = value => value ? new Date(value).toLocaleString('ru-RU', { timeZone: 'Europe/Minsk' }) : 'ещё не выполнялось';
export default function TelegramImportPanel({ routes, onChanged }) {
const { activeCity } = useCity();
const [data, setData] = useState(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [editing, setEditing] = useState(null);
const [showHistory, setShowHistory] = useState(false);
const load = async () => {
try { setData((await telegramImportApi.getAll()).data); setError(''); }
catch (err) { setError(err.response?.data?.error || 'Не удалось загрузить импорт Telegram.'); }
};
useEffect(() => {
load();
const timer = setInterval(load, 30_000);
return () => clearInterval(timer);
}, []);
const sync = async () => {
setBusy(true);
try { await telegramImportApi.sync(); await load(); onChanged(); }
catch (err) { setError(err.response?.data?.error || 'Не удалось прочитать канал.'); }
finally { setBusy(false); }
};
const review = async (row, values) => {
setBusy(true);
try {
await telegramImportApi.review(row.id, { ...values, content_hash: row.content_hash });
setEditing(null); await load(); onChanged();
} catch (err) { setError(err.response?.data?.error || 'Не удалось сохранить решение.'); }
finally { setBusy(false); }
};
const status = data?.status;
const configuredCity = status?.citySlug === activeCity?.slug;
const pending = data?.messages.filter(m => m.status === 'review') || [];
const rows = showHistory ? data?.messages || [] : pending;
return (
<section className="card mb-6" aria-label="Отмены из Telegram">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><Send className="h-4 w-4 text-primary-600" />Отмены из Telegram</h2>
<p className="text-sm text-gray-600 mt-1">
{status ? configuredCity ? connectionLabels[status.connection] : 'Для выбранного города источник не настроен' : 'Загрузка источника…'}
{status && <> · @{status.channel}</>}
</p>
{status?.connection === 'disabled' && <p className="text-sm text-gray-500 mt-1">Подключите отдельный аккаунт на сервере. Отмены будут поступать автоматически.</p>}
{status?.enabled && <p className="text-xs text-gray-500 mt-1">Последняя проверка: {formatTime(status.lastSyncAt)} · {status.autoPublish ? 'Однозначные отмены публикуются автоматически' : 'Публикация после проверки'}</p>}
{status?.lastError && <p className="text-sm text-amber-700 mt-2">{errorLabels[status.lastError] || 'Не удалось подключиться к Telegram.'}</p>}
{status?.retryAt && <p className="text-xs text-gray-500 mt-1">Следующая попытка не раньше: {formatTime(status.retryAt)}</p>}
<p className="text-xs text-gray-500 mt-2">Срок действия — до полуночи дня отмены по Минску. АВ/ВОКЗАЛ распознаётся как Автовокзал.</p>
</div>
<button type="button" className="btn-secondary flex items-center justify-center shrink-0" onClick={sync} disabled={busy || !configuredCity || !status?.enabled || status?.missing.length > 0 || status?.syncing}>
<RefreshCw className={`h-4 w-4 mr-2 ${busy || status?.syncing ? 'animate-spin' : ''}`} />Проверить канал
</button>
</div>
{error && <p role="alert" className="text-sm text-red-700 mt-3">{error}</p>}
{data && (
<div className="mt-4 border-t pt-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm text-gray-600">На проверке: <strong>{pending.length}</strong></p>
<button className="text-sm text-primary-600 hover:underline" onClick={() => setShowHistory(!showHistory)}>{showHistory ? 'Только на проверке' : 'Показать историю'}</button>
</div>
{rows.map(row => (
<div key={row.id} className="mt-3 rounded-lg border border-gray-200 p-3">
<div className="flex flex-wrap justify-between gap-2 text-xs text-gray-500">
<span>{formatTime(row.published_at)} · {{ review: 'На проверке', published: 'Опубликовано', dismissed: 'Отклонено', expired: 'День отмены завершён' }[row.status]}</span>
<a href={row.source_url} target="_blank" rel="noreferrer" className="text-primary-600 inline-flex items-center gap-1">Оригинал<ExternalLink className="h-3 w-3" /></a>
</div>
<p className="whitespace-pre-wrap break-words text-sm text-gray-700 mt-2">{row.raw_text || 'Вложение без текста — откройте оригинал.'}</p>
{row.status === 'review' && <>
{row.reasons.map(reason => <p key={reason} className="text-xs text-amber-700 mt-2">{reason}</p>)}
<div className="flex flex-wrap gap-2 mt-3">
<button className="btn-primary text-sm" disabled={busy} onClick={() => setEditing(row)}>Проверить и опубликовать</button>
<button className="btn-secondary text-sm" disabled={busy} onClick={() => review(row, { action: 'dismiss' })}>Отклонить</button>
</div>
</>}
</div>
))}
{!rows.length && <p className="text-sm text-gray-500 mt-3">{showHistory ? 'Сообщений об отменах пока нет.' : 'Нет сообщений, требующих проверки.'}</p>}
</div>
)}
{editing && <TelegramReviewForm key={editing.id} row={editing} routes={routes} busy={busy} onCancel={() => setEditing(null)} onSubmit={values => review(editing, values)} />}
</section>
);
}
function TelegramReviewForm({ row, routes, busy, onSubmit, onCancel }) {
const publicationDate = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Minsk', year: 'numeric', month: '2-digit', day: '2-digit'
}).format(new Date(row.published_at));
const [form, setForm] = useState({
action: 'publish', service_date: row.parsed?.serviceDate || publicationDate, route_id: row.parsed?.plans?.length === 1 ? row.parsed.plans[0].routeId : '',
title: 'Отмена отдельных рейсов', message: row.raw_text.slice(0, 5000)
});
const change = e => setForm({ ...form, [e.target.name]: e.target.value });
return (
<form className="mt-4 rounded-lg bg-gray-50 p-4 space-y-3" onSubmit={e => { e.preventDefault(); onSubmit(form); }}>
<div className="flex items-center justify-between"><h3 className="font-medium">Проверка отмены</h3><button type="button" onClick={onCancel} aria-label="Закрыть проверку" disabled={busy}><X className="h-5 w-5" /></button></div>
<div className="grid sm:grid-cols-2 gap-3">
<label className="text-sm">Маршрут<select className="input mt-1 w-full" name="route_id" value={form.route_id} onChange={change} required><option value="">Выберите направление</option>{routes.filter(r => r.is_active).map(r => <option key={r.id} value={r.id}>№{r.route_number} · {r.name}</option>)}</select></label>
<label className="text-sm">День отмены<input className="input mt-1 w-full" type="date" name="service_date" value={form.service_date} onChange={change} required /></label>
</div>
<label className="text-sm block">Заголовок<input className="input mt-1 w-full" name="title" value={form.title} maxLength={200} onChange={change} required /></label>
<label className="text-sm block">Текст уведомления<textarea className="input mt-1 w-full" rows={5} name="message" value={form.message} maxLength={5000} onChange={change} required /></label>
<p className="text-xs text-gray-500">Уточните остановки и времена. Уведомление будет действовать до 00:00 следующего дня по Минску. Ссылка на оригинал добавится автоматически.</p>
<div className="flex flex-wrap gap-2"><button className="btn-primary" disabled={busy}>Опубликовать отмену</button><button type="button" className="btn-secondary" disabled={busy} onClick={onCancel}>Отмена</button></div>
</form>
);
}