feat: implement stop alias management for Telegram import

- Added TelegramStopAliasService for handling stop aliases in the database.
- Created API endpoints for managing stop aliases (list, create, update, delete).
- Introduced TelegramStopAliases component in the frontend for user interaction with stop aliases.
- Updated TelegramImportService to utilize stop aliases for message processing.
- Enhanced telegramCancellationParser to resolve stop names using aliases.
- Added migration script to create telegram_stop_aliases table and seed initial data.
- Updated documentation to include details about stop alias functionality.
This commit is contained in:
2026-10-03 01:31:11 +03:00
parent f7f0b039dd
commit 666a0b268f
12 changed files with 439 additions and 25 deletions

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { ExternalLink, RefreshCw, Send, X } from 'lucide-react';
import { telegramImportApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
import TelegramStopAliases from './TelegramStopAliases';
const connectionLabels = {
disabled: 'Импорт выключен', configuration_required: 'Нужна настройка аккаунта',
@@ -80,6 +81,7 @@ export default function TelegramImportPanel({ routes, onChanged }) {
</button>
</div>
{error && <p role="alert" className="text-sm text-red-700 mt-3">{error}</p>}
<TelegramStopAliases onChanged={load} />
{data && (
<div className="mt-4 border-t pt-3">
<div className="flex items-center justify-between gap-2">
@@ -114,11 +116,27 @@ 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 initialRouteId = row.parsed?.plans?.length === 1 ? row.parsed.plans[0].routeId : '';
const draftFor = routeId => {
const route = routes.find(r => Number(r.id) === Number(routeId));
return row.drafts?.find(d => d.routeId !== null && Number(d.routeId) === Number(routeId))
|| row.drafts?.find(d => d.routeId === null && (!route || String(d.routeNumber).toLowerCase() === String(route.route_number).toLowerCase()));
};
const initialDraft = draftFor(initialRouteId);
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)
action: 'publish', service_date: row.parsed?.serviceDate || publicationDate, route_id: initialRouteId,
title: initialDraft?.title || 'Отмена отдельных рейсов', message: initialDraft?.message || row.raw_text.slice(0, 5000)
});
const change = e => setForm({ ...form, [e.target.name]: e.target.value });
const [editedText, setEditedText] = useState(false);
const change = e => {
const { name, value } = e.target;
if (name === 'title' || name === 'message') setEditedText(true);
const draft = name === 'route_id' && !editedText ? draftFor(value) : null;
const message = name === 'service_date' && !editedText && /^\d{4}-\d{2}-\d{2}$/.test(value)
? form.message.replace(/^\d{2}\.\d{2}\.\d{4}:/, `${value.split('-').reverse().join('.')}:`) : form.message;
const draftMessage = draft?.message.replace(/^\d{2}\.\d{2}\.\d{4}:/, `${form.service_date.split('-').reverse().join('.')}:`);
setForm({ ...form, message, [name]: value, ...(draft ? { title: draft.title, message: draftMessage } : {}) });
};
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>

View File

@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react';
import { BookOpen, Pencil, Trash2, X } from 'lucide-react';
import { stopsApi, telegramImportApi } from '../services/api';
import { useCity } from '../contexts/CityContext';
const emptyForm = { alias: '', stop_id: '' };
export default function TelegramStopAliases({ onChanged }) {
const { activeCity } = useCity();
const [open, setOpen] = useState(false);
const [aliases, setAliases] = useState([]);
const [stops, setStops] = useState([]);
const [loading, setLoading] = useState(false);
const [busy, setBusy] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(emptyForm);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
useEffect(() => {
setEditingId(null); setForm(emptyForm); setNotice(''); setError('');
setAliases([]); setStops([]);
if (!open) return;
let current = true;
setLoading(true);
Promise.all([telegramImportApi.getStopAliases(), stopsApi.getAll()])
.then(([dictionary, catalog]) => { if (current) { setAliases(dictionary.data); setStops(catalog.data); } })
.catch(err => { if (current) setError(err.response?.data?.error || 'Не удалось загрузить словарь остановок.'); })
.finally(() => { if (current) setLoading(false); });
return () => { current = false; };
}, [open, activeCity?.id]);
const refresh = async () => { setAliases((await telegramImportApi.getStopAliases()).data); onChanged(); };
const save = async event => {
event.preventDefault(); setBusy(true); setError(''); setNotice('');
try {
if (editingId === null) await telegramImportApi.createStopAlias(form);
else await telegramImportApi.updateStopAlias(editingId, form);
setEditingId(null); setForm(emptyForm); await refresh();
setNotice('Соответствие сохранено. При следующем чтении канала сообщения на проверке за последние три дня будут разобраны заново.');
} catch (err) { setError(err.response?.data?.error || 'Не удалось сохранить соответствие.'); }
finally { setBusy(false); }
};
const remove = async id => {
setBusy(true); setError(''); setNotice('');
try {
await telegramImportApi.deleteStopAlias(id);
if (editingId === id) { setEditingId(null); setForm(emptyForm); }
await refresh(); setNotice('Соответствие удалено.');
} catch (err) { setError(err.response?.data?.error || 'Не удалось удалить соответствие.'); }
finally { setBusy(false); }
};
return <div className="mt-4 border-t pt-3">
<button type="button" className="text-sm text-primary-600 inline-flex items-center gap-2 hover:underline" aria-expanded={open} onClick={() => setOpen(!open)} disabled={busy}>
<BookOpen className="h-4 w-4" />{open ? 'Скрыть словарь остановок' : 'Словарь названий остановок'}
</button>
{open && <div className="mt-3 space-y-3">
<p className="text-sm text-gray-600">Свяжите название из канала с остановкой {activeCity?.name ? `города ${activeCity.name}` : 'выбранного города'}. Например: ЗОДЧЕГО → Зодчего Иоанна. Регистр, пробелы и знаки препинания не учитываются.</p>
{error && <p role="alert" className="text-sm text-red-700">{error}</p>}
{notice && <p role="status" className="text-sm text-green-700">{notice}</p>}
{loading ? <p className="text-sm text-gray-500">Загрузка словаря…</p> : <>
<form onSubmit={save} className="rounded-lg bg-gray-50 p-3 space-y-3">
<div className="flex justify-between items-center"><h3 className="text-sm font-medium">{editingId === null ? 'Новое соответствие' : 'Изменить соответствие'}</h3>
{editingId !== null && <button type="button" aria-label="Отменить изменение соответствия" onClick={() => { setEditingId(null); setForm(emptyForm); }} disabled={busy}><X className="h-4 w-4" /></button>}
</div>
<div className="grid sm:grid-cols-2 gap-3">
<label className="text-sm">Название в канале<input className="input mt-1 w-full" placeholder="ЗОДЧЕГО" value={form.alias} maxLength={200} onChange={e => setForm({ ...form, alias: e.target.value })} required disabled={busy} /></label>
<label className="text-sm">Остановка в админке<select className="input mt-1 w-full" value={form.stop_id} onChange={e => setForm({ ...form, stop_id: e.target.value })} required disabled={busy}>
<option value="">Выберите остановку</option>
{form.stop_id && !stops.some(stop => Number(stop.id) === Number(form.stop_id)) && <option value={form.stop_id} disabled>Остановка отключена — выберите другую</option>}
{stops.map(stop => <option key={stop.id} value={stop.id}>{stop.name}{stop.address ? ` · ${stop.address}` : ''} · #{stop.id}</option>)}
</select></label>
</div>
<button type="submit" className="btn-primary text-sm" disabled={busy || !stops.length}>{editingId === null ? 'Добавить соответствие' : 'Сохранить соответствие'}</button>
</form>
<div className="space-y-2">
{aliases.map(alias => <div key={alias.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-gray-200 p-3">
<div className="min-w-0 break-words text-sm"><strong>{alias.alias}</strong> <span className="text-gray-400">→</span> {alias.stop_name}
{!alias.stop_active && <span className="text-xs text-amber-700 ml-2">Остановка отключена</span>}
<p className="text-xs text-gray-500">{alias.stop_address ? `${alias.stop_address} · ` : ''}Остановка #{alias.stop_id}</p>
</div>
<div className="flex gap-3 shrink-0">
<button type="button" className="text-primary-600 text-sm inline-flex items-center gap-1" disabled={busy} onClick={() => { setEditingId(alias.id); setForm({ alias: alias.alias, stop_id: String(alias.stop_id) }); setNotice(''); setError(''); }}><Pencil className="h-3 w-3" />Изменить</button>
<button type="button" className="text-red-600 text-sm inline-flex items-center gap-1" disabled={busy} onClick={() => remove(alias.id)}><Trash2 className="h-3 w-3" />Удалить</button>
</div>
</div>)}
{!aliases.length && <p className="text-sm text-gray-500">Добавьте первое соответствие. Для нескольких вариантов одного названия можно создать отдельные записи.</p>}
</div>
<p className="text-xs text-gray-500">Изменения применяются к новым сообщениям и сообщениям на проверке. Опубликованные уведомления сохраняют свой текст.</p>
</>}
</div>}
</div>;
}

View File

@@ -191,7 +191,11 @@ export const holidaysApi = {
export const telegramImportApi = {
getAll: () => api.get('/telegram-import'),
sync: () => api.post('/telegram-import/sync'),
review: (id, data) => api.post(`/telegram-import/${id}/review`, data)
review: (id, data) => api.post(`/telegram-import/${id}/review`, data),
getStopAliases: () => api.get('/telegram-import/stop-aliases'),
createStopAlias: data => api.post('/telegram-import/stop-aliases', data),
updateStopAlias: (id, data) => api.put(`/telegram-import/stop-aliases/${id}`, data),
deleteStopAlias: id => api.delete(`/telegram-import/stop-aliases/${id}`)
};
export const alertsApi = {