- Добавлена полная интеграция с 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 интеграции
371 lines
15 KiB
JavaScript
371 lines
15 KiB
JavaScript
// ============================================================================
|
||
// n8n Code Node: Отчёт о рейсах (HTML → Binary + Base64 PDF)
|
||
// ============================================================================
|
||
// Упрощённая версия с возвратом binary HTML и подготовкой для PDF конвертации
|
||
// ============================================================================
|
||
|
||
const inputItems = $input.all();
|
||
|
||
// ================== FALLBACK ==================
|
||
if (!inputItems || inputItems.length === 0) {
|
||
const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';
|
||
return [{
|
||
binary: {
|
||
data: Buffer.from(html, 'utf8'),
|
||
mimeType: 'text/html',
|
||
fileName: 'flights-report.html'
|
||
},
|
||
json: {
|
||
html: html,
|
||
flights_count: 0,
|
||
error: 'Нет входных данных'
|
||
}
|
||
}];
|
||
}
|
||
|
||
// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================
|
||
let flightAwareData = [];
|
||
let flightRadar24Data = [];
|
||
|
||
try {
|
||
const fa = inputItems[0]?.json?.body?.flights;
|
||
if (Array.isArray(fa)) flightAwareData = fa;
|
||
} catch (e) {
|
||
console.log('⚠️ Ошибка извлечения FlightAware:', e.message);
|
||
}
|
||
|
||
try {
|
||
const fr = inputItems[1]?.json?.body?.data;
|
||
if (Array.isArray(fr)) flightRadar24Data = fr;
|
||
} catch (e) {
|
||
console.log('⚠️ Ошибка извлечения FlightRadar24:', 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();
|
||
|
||
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;
|
||
}
|
||
});
|
||
|
||
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;
|
||
}
|
||
});
|
||
|
||
const flights = Array.from(flightsMap.values());
|
||
|
||
// ================== HTML GENERATION ==================
|
||
const generateFlightCard = f => {
|
||
const fa = f.fa;
|
||
const fr = f.fr;
|
||
|
||
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>`;
|
||
|
||
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.actual_out)}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Прилёт:</span>
|
||
<span class="timeline-value">${safeDate(fa.actual_in)}</span>
|
||
</div>
|
||
<div class="timeline-item">
|
||
<span class="timeline-label">Статус:</span>
|
||
<span class="timeline-value">${safeStr(fa.status || '—')}</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>`;
|
||
}
|
||
|
||
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="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>
|
||
</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;
|
||
};
|
||
|
||
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, Arial, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; padding: 20px; }
|
||
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||
.header { border-bottom: 3px solid #2563eb; padding-bottom: 20px; margin-bottom: 30px; }
|
||
.header h1 { color: #1e40af; font-size: 28px; margin-bottom: 10px; }
|
||
.header-meta { color: #666; font-size: 14px; }
|
||
.sources-info { display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap; }
|
||
.source-tag { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; 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: 25px; overflow: hidden; background: white; }
|
||
.flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; }
|
||
.flight-header h2 { font-size: 24px; margin: 0; }
|
||
.registration { background: rgba(255,255,255,0.2); padding: 6px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }
|
||
.flight-info { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }
|
||
.info-row { display: flex; margin-bottom: 8px; }
|
||
.info-row .label { font-weight: 600; color: #4b5563; width: 150px; flex-shrink: 0; }
|
||
.info-row .value { color: #111827; }
|
||
.source-section { border-top: 1px solid #e5e7eb; padding: 20px; }
|
||
.source-section:first-of-type { border-top: none; }
|
||
.source-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }
|
||
.source-badge { display: inline-block; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; color: white; }
|
||
.source-badge.source-flightaware { background: #3b82f6; }
|
||
.source-badge.source-flightradar24 { background: #10b981; }
|
||
.source-missing { color: #ef4444; font-size: 13px; font-style: italic; }
|
||
.source-content { margin-left: 0; }
|
||
.route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; padding: 15px; background: #f9fafb; border-radius: 6px; }
|
||
.route-item { display: flex; flex-direction: column; }
|
||
.route-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.route-value { font-size: 16px; font-weight: 600; color: #111827; }
|
||
.timeline { margin-bottom: 20px; }
|
||
.timeline-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
|
||
.timeline-item:last-child { border-bottom: none; }
|
||
.timeline-label { font-weight: 500; color: #4b5563; width: 180px; flex-shrink: 0; }
|
||
.timeline-value { color: #111827; text-align: right; }
|
||
.status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; padding: 15px; background: #f9fafb; border-radius: 6px; }
|
||
.status-item { display: flex; flex-direction: column; }
|
||
.status-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||
.status-value { font-size: 14px; font-weight: 600; color: #111827; }
|
||
.no-data { text-align: center; padding: 60px 20px; color: #6b7280; font-size: 18px; }
|
||
@media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 20px; } .flight-card { page-break-inside: avoid; margin-bottom: 20px; } }
|
||
</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(generateFlightCard).join('') : '<div class="no-data">Данные о рейсах не найдены</div>'}
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>`;
|
||
|
||
// ================== ПОДГОТОВКА ДАННЫХ ДЛЯ PDF КОНВЕРТАЦИИ ==================
|
||
// Настройки сервиса (замените на ваши)
|
||
const PDF_SERVICE_URL = 'https://api.htmlpdfapi.com/v1/pdf';
|
||
const PDF_API_KEY = 'YOUR_API_KEY'; // ⚠️ ЗАМЕНИТЕ на ваш API ключ
|
||
|
||
// ================== RETURN ==================
|
||
return [{
|
||
// Binary HTML файл (для использования в Convert to File ноде или сохранения)
|
||
binary: {
|
||
data: Buffer.from(html, 'utf8'),
|
||
mimeType: 'text/html',
|
||
fileName: `flights-report-${now.toISOString().split('T')[0]}.html`
|
||
},
|
||
|
||
// JSON данные
|
||
json: {
|
||
// HTML строка (для конвертации в PDF через HTTP Request)
|
||
html: html,
|
||
|
||
// Метаданные
|
||
flights_count: flights.length,
|
||
generated_at: now.toISOString(),
|
||
sources: {
|
||
flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },
|
||
flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }
|
||
},
|
||
|
||
// Данные для конвертации в base64 PDF (используйте в следующей HTTP Request ноде)
|
||
pdf_request: {
|
||
method: 'POST',
|
||
url: PDF_SERVICE_URL,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||
},
|
||
body: JSON.stringify({
|
||
html: html,
|
||
options: {
|
||
format: 'A4',
|
||
printBackground: true,
|
||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||
},
|
||
base64: true
|
||
})
|
||
},
|
||
|
||
// Удобные поля для HTTP Request ноды
|
||
pdf_request_method: 'POST',
|
||
pdf_request_url: PDF_SERVICE_URL,
|
||
pdf_request_headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${PDF_API_KEY}`
|
||
},
|
||
pdf_request_body: JSON.stringify({
|
||
html: html,
|
||
options: {
|
||
format: 'A4',
|
||
printBackground: true,
|
||
margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
|
||
},
|
||
base64: true
|
||
})
|
||
}
|
||
}];
|
||
|
||
// ============================================================================
|
||
// ИСПОЛЬЗОВАНИЕ:
|
||
// ============================================================================
|
||
// 1. Binary HTML можно использовать в ноде "Convert to File" или сохранить
|
||
// 2. JSON.html можно использовать для конвертации в PDF через HTTP Request
|
||
// 3. JSON.pdf_request_* поля готовы для использования в HTTP Request ноде
|
||
// 4. После HTTP Request используйте N8N_EXTRACT_BASE64_FROM_RESPONSE.js
|
||
// для извлечения base64 PDF из ответа
|
||
// ============================================================================
|