- 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`.
136 lines
11 KiB
JavaScript
136 lines
11 KiB
JavaScript
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>
|
||
);
|
||
}
|