- Добавлена полная интеграция с Telegram Mini App (динамическая загрузка SDK) - Отдельный компактный дизайн для Telegram Mini App - Добавлен loader при инициализации (предотвращает мелькание SMS-авторизации) - Улучшена навигация: кнопки "Назад" и "К списку заявок" теперь сохраняют авторизацию - Telegram Mini App: кнопка "Выход" просто закрывает приложение - Telegram Mini App: заявки "В работе" скрыты из списка - Веб-версия: для заявок "В работе" добавлена кнопка "Просмотреть в Telegram" (ссылка на @klientprav_bot) - Telegram Mini App: кнопки действий в черновиках расположены вертикально - Веб-версия: убрано отображение номера телефона в приветствии - Исправлена проблема с возвратом к списку черновиков (не требует повторной SMS-авторизации) - Заблокировано удаление и редактирование заявок со статусом "В работе" - Добавлена документация по Telegram Mini App интеграции
631 lines
26 KiB
JavaScript
631 lines
26 KiB
JavaScript
// ============================================================================
|
||
// n8n Code Node: Обработка данных о рейсах → Base64 HTML
|
||
// ============================================================================
|
||
// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]
|
||
// Выход: base64 HTML
|
||
// ============================================================================
|
||
|
||
const inputItems = $input.all();
|
||
|
||
// ================== FALLBACK ==================
|
||
if (!inputItems || inputItems.length === 0) {
|
||
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
|
||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||
|
||
return [{
|
||
json: {
|
||
html_base64: htmlBase64,
|
||
html: html,
|
||
flights_count: 0,
|
||
error: 'Нет входных данных'
|
||
}
|
||
}];
|
||
}
|
||
|
||
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
|
||
// Новая структура: [{ data: [{ body: { flights: [...] }}, { error: {...} }, { flight_number, ... }] }]
|
||
let flightAwareData = [];
|
||
let flightRadar24Data = [];
|
||
let requestData = null; // Данные из ноды "запрос рейса"
|
||
let flightRadar24Error = null; // Ошибка от FlightRadar24
|
||
|
||
try {
|
||
const firstItem = inputItems[0];
|
||
if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {
|
||
// Первый элемент массива data - FlightAware
|
||
if (firstItem.json.data[0] && firstItem.json.data[0].body) {
|
||
if (firstItem.json.data[0].body.flights) {
|
||
flightAwareData = Array.isArray(firstItem.json.data[0].body.flights)
|
||
? firstItem.json.data[0].body.flights
|
||
: [];
|
||
}
|
||
}
|
||
|
||
// Второй элемент массива data - FlightRadar24 (может быть ошибка)
|
||
if (firstItem.json.data[1]) {
|
||
// Проверяем, есть ли ошибка
|
||
if (firstItem.json.data[1].error) {
|
||
flightRadar24Error = firstItem.json.data[1].error;
|
||
console.log('⚠️ Ошибка FlightRadar24:', flightRadar24Error.message);
|
||
flightRadar24Data = [];
|
||
} else if (firstItem.json.data[1].body && firstItem.json.data[1].body.data) {
|
||
flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data)
|
||
? firstItem.json.data[1].body.data
|
||
: [];
|
||
}
|
||
}
|
||
|
||
// Третий элемент массива data - данные из ноды "запрос рейса"
|
||
if (firstItem.json.data[2] && firstItem.json.data[2].flight_number) {
|
||
requestData = {
|
||
flight_number: firstItem.json.data[2].flight_number,
|
||
departure_date_local: firstItem.json.data[2].departure_date_local || null,
|
||
arrival_date_local: firstItem.json.data[2].arrival_date_local || null
|
||
};
|
||
console.log('✅ Данные запроса получены:', requestData);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.log('⚠️ Ошибка извлечения данных:', e.message);
|
||
}
|
||
|
||
// ================== УТИЛИТЫ ==================
|
||
const safeStr = v => (v == null ? '' : String(v));
|
||
const safeDate = v => {
|
||
if (!v) return '—';
|
||
try {
|
||
const d = new Date(v);
|
||
return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {
|
||
timeZone: 'UTC',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
} catch {
|
||
return '—';
|
||
}
|
||
};
|
||
|
||
const formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;
|
||
const formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;
|
||
|
||
// ================== MERGE ПО REGISTRATION ==================
|
||
const flightsMap = new Map();
|
||
|
||
// Добавляем данные из FlightAware
|
||
flightAwareData.forEach(f => {
|
||
const reg = safeStr(f.registration).trim();
|
||
if (!reg) return;
|
||
if (!flightsMap.has(reg)) {
|
||
flightsMap.set(reg, {
|
||
registration: reg,
|
||
flightNumber: safeStr(f.flight_number),
|
||
ident: safeStr(f.ident),
|
||
identIata: safeStr(f.ident_iata),
|
||
aircraftType: safeStr(f.aircraft_type),
|
||
fa: f,
|
||
fr: null
|
||
});
|
||
} else {
|
||
flightsMap.get(reg).fa = f;
|
||
}
|
||
});
|
||
|
||
// Добавляем данные из FlightRadar24
|
||
flightRadar24Data.forEach(f => {
|
||
const reg = safeStr(f.reg).trim();
|
||
if (!reg) return;
|
||
if (!flightsMap.has(reg)) {
|
||
flightsMap.set(reg, {
|
||
registration: reg,
|
||
flightNumber: safeStr(f.flight),
|
||
ident: safeStr(f.callsign),
|
||
identIata: safeStr(f.flight),
|
||
aircraftType: safeStr(f.type),
|
||
fa: null,
|
||
fr: f
|
||
});
|
||
} else {
|
||
flightsMap.get(reg).fr = f;
|
||
}
|
||
});
|
||
|
||
// ================== ДОБАВЛЕНИЕ ЗАПРОШЕННЫХ РЕЙСОВ БЕЗ ДАННЫХ ==================
|
||
// Если есть информация о запрошенных рейсах, но нет данных - добавляем их
|
||
// Пытаемся извлечь из предыдущих нод (HTTP Request) или получить из входных данных
|
||
const allInputItems = $input.all();
|
||
const firstItemForRequest = inputItems[0]; // Используем уже определённую переменную из блока выше
|
||
|
||
// Ищем информацию о запрошенных рейсах
|
||
let requestedFlightNumbers = new Set();
|
||
|
||
// ВАРИАНТ 1: Получение данных из ноды "запрос рейса"
|
||
// Используем данные, извлечённые выше из data[2]
|
||
let requestFlightNumber = null;
|
||
let requestDepartureDate = null;
|
||
let requestArrivalDate = null;
|
||
|
||
if (requestData) {
|
||
requestFlightNumber = requestData.flight_number;
|
||
requestDepartureDate = requestData.departure_date_local;
|
||
requestArrivalDate = requestData.arrival_date_local;
|
||
|
||
if (requestFlightNumber) {
|
||
requestedFlightNumbers.add(String(requestFlightNumber));
|
||
}
|
||
}
|
||
|
||
// Дополнительно ищем в других местах (fallback)
|
||
allInputItems.forEach(item => {
|
||
if (item.json) {
|
||
// Прямые поля из ноды "запрос рейса"
|
||
if (item.json.flight_number && (item.json.departure_date_local || item.json.arrival_date_local)) {
|
||
if (!requestFlightNumber) {
|
||
requestFlightNumber = item.json.flight_number || item.json.ident || item.json.flight;
|
||
requestDepartureDate = item.json.departure_date_local || null;
|
||
requestArrivalDate = item.json.arrival_date_local || null;
|
||
|
||
if (requestFlightNumber) {
|
||
requestedFlightNumbers.add(String(requestFlightNumber));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Данные, переданные из предыдущей ноды
|
||
if (item.json.request_flight_number) {
|
||
if (!requestFlightNumber) {
|
||
requestFlightNumber = item.json.request_flight_number;
|
||
requestDepartureDate = item.json.request_departure_date || null;
|
||
requestArrivalDate = item.json.request_arrival_date || null;
|
||
|
||
if (requestFlightNumber) {
|
||
requestedFlightNumbers.add(String(requestFlightNumber));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// ВАРИАНТ 2: Прямая передача из предыдущей ноды
|
||
if (firstItemForRequest && firstItemForRequest.json) {
|
||
// Массив запрошенных рейсов
|
||
if (firstItemForRequest.json.requested_flights && Array.isArray(firstItemForRequest.json.requested_flights)) {
|
||
firstItemForRequest.json.requested_flights.forEach(flight => {
|
||
const flightNum = typeof flight === 'string' ? flight : (flight.flight_number || flight.ident || flight);
|
||
if (flightNum) {
|
||
requestedFlightNumbers.add(flightNum);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Один рейс
|
||
if (firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight) {
|
||
const flightNum = firstItemForRequest.json.flight_number || firstItemForRequest.json.ident || firstItemForRequest.json.flight;
|
||
requestedFlightNumbers.add(flightNum);
|
||
}
|
||
|
||
// Массив flight_numbers
|
||
if (firstItemForRequest.json.flight_numbers && Array.isArray(firstItemForRequest.json.flight_numbers)) {
|
||
firstItemForRequest.json.flight_numbers.forEach(flightNum => {
|
||
if (flightNum) requestedFlightNumbers.add(String(flightNum));
|
||
});
|
||
}
|
||
}
|
||
|
||
// ВАРИАНТ 3: Извлечение из всех входных элементов
|
||
allInputItems.forEach(item => {
|
||
if (item.json) {
|
||
if (item.json.flight_number) {
|
||
requestedFlightNumbers.add(String(item.json.flight_number));
|
||
}
|
||
if (item.json.ident) {
|
||
requestedFlightNumbers.add(String(item.json.ident));
|
||
}
|
||
if (item.json.flight) {
|
||
requestedFlightNumbers.add(String(item.json.flight));
|
||
}
|
||
}
|
||
});
|
||
|
||
// ВАРИАНТ 2: Извлечение из URL и параметров запросов
|
||
allInputItems.forEach(item => {
|
||
// Из URL запроса
|
||
if (item.json && item.json.url) {
|
||
const url = item.json.url;
|
||
const flightMatch = url.match(/(?:ident|flight_number|flight|callsign)=([^&]+)/i);
|
||
if (flightMatch) {
|
||
requestedFlightNumbers.add(flightMatch[1]);
|
||
}
|
||
}
|
||
|
||
// Из query параметров
|
||
if (item.json && item.json.query) {
|
||
const query = item.json.query;
|
||
const flightNum = query.ident || query.flight_number || query.flight || query.callsign;
|
||
if (flightNum) {
|
||
requestedFlightNumbers.add(flightNum);
|
||
}
|
||
}
|
||
|
||
// Из body запроса
|
||
if (item.json && item.json.body) {
|
||
const body = item.json.body;
|
||
const flightNum = body.ident || body.flight_number || body.flight || body.callsign;
|
||
if (flightNum) {
|
||
requestedFlightNumbers.add(flightNum);
|
||
}
|
||
}
|
||
|
||
// Прямо из json
|
||
if (item.json) {
|
||
const flightNum = item.json.ident || item.json.flight_number || item.json.flight || item.json.callsign;
|
||
if (flightNum) {
|
||
requestedFlightNumbers.add(flightNum);
|
||
}
|
||
}
|
||
});
|
||
|
||
// Добавляем запрошенные рейсы, для которых нет данных
|
||
requestedFlightNumbers.forEach(flightNum => {
|
||
// Проверяем, есть ли уже этот рейс в flightsMap
|
||
let found = false;
|
||
flightsMap.forEach((flight, reg) => {
|
||
if (flight.flightNumber === flightNum || flight.ident === flightNum || flight.identIata === flightNum) {
|
||
found = true;
|
||
}
|
||
});
|
||
|
||
// Если не найден - добавляем как запрошенный без данных
|
||
if (!found) {
|
||
flightsMap.set(`REQUESTED-${flightNum}`, {
|
||
registration: '—',
|
||
flightNumber: flightNum,
|
||
ident: flightNum,
|
||
identIata: flightNum,
|
||
aircraftType: '—',
|
||
fa: null,
|
||
fr: null,
|
||
isRequested: true // Флаг, что это запрошенный рейс без данных
|
||
});
|
||
}
|
||
});
|
||
|
||
const flights = Array.from(flightsMap.values());
|
||
|
||
// ================== HTML GENERATION ==================
|
||
// Делаем flightRadar24Error доступным в функции generateFlightCard
|
||
const generateFlightCard = (f, fr24ErrorParam = null) => {
|
||
const fa = f.fa;
|
||
const fr = f.fr;
|
||
const fr24Error = fr24ErrorParam; // Локальная переменная для использования в функции
|
||
|
||
// Если это запрошенный рейс без данных
|
||
if (f.isRequested && !fa && !fr) {
|
||
// Используем данные, полученные ранее из ноды "запрос рейса"
|
||
let requestInfo = '';
|
||
|
||
// Проверяем, соответствует ли этот рейс запрошенному
|
||
const matchesRequest = requestFlightNumber && (
|
||
String(f.flightNumber) === String(requestFlightNumber) ||
|
||
String(f.ident) === String(requestFlightNumber)
|
||
);
|
||
|
||
if (matchesRequest) {
|
||
if (requestDepartureDate) {
|
||
requestInfo += `<div class="info-row"><span class="label">Дата вылета (запрос):</span><span class="value">${requestDepartureDate}</span></div>`;
|
||
}
|
||
if (requestArrivalDate) {
|
||
requestInfo += `<div class="info-row"><span class="label">Дата прилёта (запрос):</span><span class="value">${requestArrivalDate}</span></div>`;
|
||
}
|
||
}
|
||
|
||
return `
|
||
<div class="flight-card">
|
||
<div class="flight-header">
|
||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||
<span class="registration">Запрошен</span>
|
||
</div>
|
||
<div class="flight-info">
|
||
<div class="info-row">
|
||
<span class="label">Запрошенный рейс:</span>
|
||
<span class="value">${f.flightNumber || f.ident || '—'}</span>
|
||
</div>
|
||
${requestInfo}
|
||
</div>
|
||
<div class="source-section">
|
||
<div class="source-header">
|
||
<span class="source-badge source-flightaware">FlightAware</span>
|
||
<span class="source-missing">Данные не получены</span>
|
||
</div>
|
||
<div class="source-content">
|
||
<div style="padding: 10px; color: #666; font-size: 13px;">
|
||
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="source-section">
|
||
<div class="source-header">
|
||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||
<span class="source-missing">Данные не получены</span>
|
||
</div>
|
||
<div class="source-content">
|
||
<div style="padding: 10px; color: #666; font-size: 13px;">
|
||
По запросу рейса <strong>${f.flightNumber || f.ident || '—'}</strong>${requestDepartureDate ? ` на ${requestDepartureDate}` : ''} данные не найдены.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
let card = `
|
||
<div class="flight-card">
|
||
<div class="flight-header">
|
||
<h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>
|
||
<span class="registration">${f.registration || '—'}</span>
|
||
</div>
|
||
<div class="flight-info">
|
||
<div class="info-row">
|
||
<span class="label">Тип самолёта:</span>
|
||
<span class="value">${f.aircraftType || '—'}</span>
|
||
</div>
|
||
<div class="info-row">
|
||
<span class="label">Идентификатор:</span>
|
||
<span class="value">${f.ident || '—'} (${f.identIata || '—'})</span>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Данные из FlightAware
|
||
if (fa) {
|
||
card += `
|
||
<div class="source-section">
|
||
<div class="source-header">
|
||
<span class="source-badge source-flightaware">FlightAware</span>
|
||
</div>
|
||
<div class="source-content">
|
||
<div class="route-info">
|
||
<div class="route-item">
|
||
<span class="route-label">Откуда:</span>
|
||
<span class="route-value">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>
|
||
</div>
|
||
<div class="route-item">
|
||
<span class="route-label">Куда:</span>
|
||
<span class="route-value">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>
|
||
</div>
|
||
</div>
|
||
<div class="timeline">
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Плановый вылет:</span>
|
||
<span class="timeline-value">${safeDate(fa.scheduled_out)}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Фактический вылет:</span>
|
||
<span class="timeline-value">${safeDate(fa.actual_out)}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Взлёт:</span>
|
||
<span class="timeline-value">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Посадка:</span>
|
||
<span class="timeline-value">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Фактический прилёт:</span>
|
||
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="status-info">
|
||
<div class="status-item">
|
||
<span class="status-label">Статус:</span>
|
||
<span class="status-value">${safeStr(fa.status || '—')}</span>
|
||
</div>
|
||
${fa.departure_delay !== null && fa.departure_delay !== undefined ? `
|
||
<div class="status-item">
|
||
<span class="status-label">Задержка вылета:</span>
|
||
<span class="status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>
|
||
</div>
|
||
` : ''}
|
||
${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `
|
||
<div class="status-item">
|
||
<span class="status-label">Задержка прилёта:</span>
|
||
<span class="status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>
|
||
</div>
|
||
` : ''}
|
||
${fa.gate_origin ? `
|
||
<div class="status-item">
|
||
<span class="status-label">Гейт вылета:</span>
|
||
<span class="status-value">${fa.gate_origin}</span>
|
||
</div>
|
||
` : ''}
|
||
${fa.gate_destination ? `
|
||
<div class="status-item">
|
||
<span class="status-label">Гейт прилёта:</span>
|
||
<span class="status-value">${fa.gate_destination}</span>
|
||
</div>
|
||
` : ''}
|
||
${fa.baggage_claim ? `
|
||
<div class="status-item">
|
||
<span class="status-label">Выдача багажа:</span>
|
||
<span class="status-value">${fa.baggage_claim}</span>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
card += `
|
||
<div class="source-section">
|
||
<div class="source-header">
|
||
<span class="source-badge source-flightaware">FlightAware</span>
|
||
<span class="source-missing">Данные не получены</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
// Данные из FlightRadar24
|
||
if (fr) {
|
||
card += `
|
||
<div class="source-section">
|
||
<div class="source-header">
|
||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||
</div>
|
||
<div class="source-content">
|
||
<div class="route-info">
|
||
<div class="route-item">
|
||
<span class="route-label">Откуда:</span>
|
||
<span class="route-value">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>
|
||
</div>
|
||
<div class="route-item">
|
||
<span class="route-label">Куда:</span>
|
||
<span class="route-value">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>
|
||
</div>
|
||
</div>
|
||
<div class="timeline">
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Взлёт:</span>
|
||
<span class="timeline-value">${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Посадка:</span>
|
||
<span class="timeline-value">${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}</span>
|
||
</div>
|
||
</div>
|
||
<div class="status-info">
|
||
<div class="status-item">
|
||
<span class="status-label">Время полёта:</span>
|
||
<span class="status-value">${formatDuration(fr.flight_time)}</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">Фактическое расстояние:</span>
|
||
<span class="status-value">${formatDistance(fr.actual_distance)}</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">Кратчайшее расстояние:</span>
|
||
<span class="status-value">${formatDistance(fr.circle_distance)}</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">Статус полёта:</span>
|
||
<span class="status-value">${fr.flight_ended ? 'Завершён' : 'В процессе'}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
card += `
|
||
<div class="source-section">
|
||
<div class="source-header">
|
||
<span class="source-badge source-flightradar24">FlightRadar24</span>
|
||
<span class="source-missing">Данные не получены</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
card += `</div>`;
|
||
return card;
|
||
};
|
||
|
||
// Генерация полного HTML
|
||
const now = new Date();
|
||
const reportDate = now.toLocaleString('ru-RU', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Отчёт о рейсах</title>
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.4; color: #333; background: #f5f5f5; padding: 15px; }
|
||
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||
.header { border-bottom: 3px solid #2563eb; padding-bottom: 8px; margin-bottom: 8px; }
|
||
.header h1 { color: #1e40af; font-size: 24px; margin-bottom: 4px; }
|
||
.header-meta { color: #666; font-size: 13px; }
|
||
.sources-info { display: flex; gap: 10px; margin-top: 4px; flex-wrap: wrap; }
|
||
.source-tag { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; font-weight: 500; }
|
||
.source-tag.available { background: #d1fae5; color: #065f46; }
|
||
.source-tag.unavailable { background: #fee2e2; color: #991b1b; }
|
||
.flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 18px; overflow: hidden; background: white; }
|
||
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 14px 18px; display: flex; justify-content: space-between; align-items: center; }
|
||
.flight-header h2 { font-size: 20px; margin: 0; }
|
||
.registration { background: rgba(255,255,255,0.2); padding: 4px 10px; border-radius: 4px; font-weight: 600; font-size: 13px; }
|
||
.flight-info { padding: 12px 18px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
|
||
.info-row { display: flex; margin-bottom: 6px; }
|
||
.info-row:last-child { margin-bottom: 0; }
|
||
.info-row .label { font-weight: 600; color: #4b5563; width: 140px; flex-shrink: 0; font-size: 13px; }
|
||
.info-row .value { color: #111827; font-size: 13px; }
|
||
.source-section { border-top: 1px solid #e5e7eb; padding: 12px 18px; }
|
||
.source-section:first-of-type { border-top: none; }
|
||
.source-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||
.source-badge { display: inline-block; padding: 5px 12px; border-radius: 5px; font-size: 12px; font-weight: 600; color: white; }
|
||
.source-badge.source-flightaware { background: #3b82f6; }
|
||
.source-badge.source-flightradar24 { background: #10b981; }
|
||
.source-missing { color: #ef4444; font-size: 12px; font-style: italic; }
|
||
.source-content { margin-left: 0; }
|
||
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||
.route-item { display: flex; flex-direction: column; }
|
||
.route-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.route-value { font-size: 14px; font-weight: 600; color: #111827; }
|
||
.timeline { margin-bottom: 12px; }
|
||
.timeline-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #e5e7eb; }
|
||
.timeline-item:last-child { border-bottom: none; }
|
||
.timeline-label { font-weight: 500; color: #4b5563; width: 160px; flex-shrink: 0; font-size: 12px; }
|
||
.timeline-value { color: #111827; text-align: right; font-size: 12px; }
|
||
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; padding: 12px; background: #f9fafb; border-radius: 6px; }
|
||
.status-item { display: flex; flex-direction: column; }
|
||
.status-label { font-size: 11px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.status-value { font-size: 13px; font-weight: 600; color: #111827; }
|
||
.delay-negative { color: #10b981; }
|
||
.delay-positive { color: #ef4444; }
|
||
.no-data { text-align: center; padding: 40px 20px; color: #6b7280; font-size: 16px; }
|
||
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 15px; } .flight-card { page-break-inside: avoid; margin-bottom: 15px; } }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<div class="header">
|
||
<h1>Отчёт о рейсах</h1>
|
||
<div class="header-meta">
|
||
<div>Дата формирования: ${reportDate}</div>
|
||
<div class="sources-info">
|
||
<span class="source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}">
|
||
FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||
</span>
|
||
<span class="source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}">
|
||
FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flights-container">
|
||
${flights.length ? flights.map(f => generateFlightCard(f, flightRadar24Error)).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
// ================== HTML → BASE64 ==================
|
||
const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');
|
||
|
||
// ================== RETURN ==================
|
||
return [{
|
||
json: {
|
||
html_base64: htmlBase64,
|
||
html: html,
|
||
flights_count: flights.length,
|
||
sources: {
|
||
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
|
||
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
|
||
},
|
||
generated_at: now.toISOString()
|
||
}
|
||
}];
|