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:
@@ -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>
|
||||
|
||||
94
frontend/src/components/TelegramStopAliases.jsx
Normal file
94
frontend/src/components/TelegramStopAliases.jsx
Normal 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>;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user