- Добавлена полная интеграция с 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 интеграции
54 lines
18 KiB
JSON
54 lines
18 KiB
JSON
{
|
||
"nodes": [
|
||
{
|
||
"parameters": {
|
||
"jsCode": "// ============================================================================\n// n8n Code Node: Обработка данных о рейсах → Base64 HTML\n// ============================================================================\n// Вход: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]\n// Выход: base64 HTML\n// ============================================================================\n\nconst inputItems = $input.all();\n\n// ================== FALLBACK ==================\nif (!inputItems || inputItems.length === 0) {\n const html = '<!DOCTYPE html><html><body><h1>Ошибка: данные не получены</h1></body></html>';\n const htmlBase64 = Buffer.from(html, 'utf8').toString('base64');\n \n return [{\n json: {\n html_base64: htmlBase64,\n html: html,\n flights_count: 0,\n error: 'Нет входных данных'\n }\n }];\n}\n\n// ================== ИЗВЛЕЧЕНИЕ ДАННЫХ ==================\n// Структура: [{ data: [{ body: { flights: [...] }}, { body: { data: [...] }}] }]\nlet flightAwareData = [];\nlet flightRadar24Data = [];\n\ntry {\n const firstItem = inputItems[0];\n if (firstItem && firstItem.json && firstItem.json.data && Array.isArray(firstItem.json.data)) {\n // Первый элемент массива data - FlightAware\n if (firstItem.json.data[0] && firstItem.json.data[0].body && firstItem.json.data[0].body.flights) {\n flightAwareData = Array.isArray(firstItem.json.data[0].body.flights) \n ? firstItem.json.data[0].body.flights \n : [];\n }\n \n // Второй элемент массива data - FlightRadar24\n if (firstItem.json.data[1] && firstItem.json.data[1].body && firstItem.json.data[1].body.data) {\n flightRadar24Data = Array.isArray(firstItem.json.data[1].body.data) \n ? firstItem.json.data[1].body.data \n : [];\n }\n }\n} catch (e) {\n console.log('⚠️ Ошибка извлечения данных:', e.message);\n}\n\n// ================== УТИЛИТЫ ==================\nconst safeStr = v => (v == null ? '' : String(v));\nconst safeDate = v => {\n if (!v) return '—';\n try {\n const d = new Date(v);\n return isNaN(d.getTime()) ? '—' : d.toLocaleString('ru-RU', {\n timeZone: 'UTC',\n year: 'numeric',\n month: '2-digit',\n day: '2-digit',\n hour: '2-digit',\n minute: '2-digit'\n });\n } catch {\n return '—';\n }\n};\n\nconst formatDuration = s => !s ? '—' : `${Math.floor(s / 3600)}ч ${Math.floor((s % 3600) / 60)}м`;\nconst formatDistance = km => !km ? '—' : `${Number(km).toFixed(2)} км`;\n\n// ================== MERGE ПО REGISTRATION ==================\nconst flightsMap = new Map();\n\n// Добавляем данные из FlightAware\nflightAwareData.forEach(f => {\n const reg = safeStr(f.registration).trim();\n if (!reg) return;\n if (!flightsMap.has(reg)) {\n flightsMap.set(reg, {\n registration: reg,\n flightNumber: safeStr(f.flight_number),\n ident: safeStr(f.ident),\n identIata: safeStr(f.ident_iata),\n aircraftType: safeStr(f.aircraft_type),\n fa: f,\n fr: null\n });\n } else {\n flightsMap.get(reg).fa = f;\n }\n});\n\n// Добавляем данные из FlightRadar24\nflightRadar24Data.forEach(f => {\n const reg = safeStr(f.reg).trim();\n if (!reg) return;\n if (!flightsMap.has(reg)) {\n flightsMap.set(reg, {\n registration: reg,\n flightNumber: safeStr(f.flight),\n ident: safeStr(f.callsign),\n identIata: safeStr(f.flight),\n aircraftType: safeStr(f.type),\n fa: null,\n fr: f\n });\n } else {\n flightsMap.get(reg).fr = f;\n }\n});\n\nconst flights = Array.from(flightsMap.values());\n\n// ================== HTML GENERATION ==================\nconst generateFlightCard = f => {\n const fa = f.fa;\n const fr = f.fr;\n \n let card = `\n<div class=\"flight-card\">\n <div class=\"flight-header\">\n <h2>Рейс ${f.flightNumber || f.ident || '—'}</h2>\n <span class=\"registration\">${f.registration}</span>\n </div>\n <div class=\"flight-info\">\n <div class=\"info-row\">\n <span class=\"label\">Тип самолёта:</span>\n <span class=\"value\">${f.aircraftType || '—'}</span>\n </div>\n <div class=\"info-row\">\n <span class=\"label\">Идентификатор:</span>\n <span class=\"value\">${f.ident || '—'} (${f.identIata || '—'})</span>\n </div>\n </div>`;\n\n // Данные из FlightAware\n if (fa) {\n card += `\n <div class=\"source-section\">\n <div class=\"source-header\">\n <span class=\"source-badge source-flightaware\">FlightAware</span>\n </div>\n <div class=\"source-content\">\n <div class=\"route-info\">\n <div class=\"route-item\">\n <span class=\"route-label\">Откуда:</span>\n <span class=\"route-value\">${safeStr(fa.origin?.name || fa.origin?.code_iata || '—')} (${safeStr(fa.origin?.code_iata || '—')})</span>\n </div>\n <div class=\"route-item\">\n <span class=\"route-label\">Куда:</span>\n <span class=\"route-value\">${safeStr(fa.destination?.name || fa.destination?.code_iata || '—')} (${safeStr(fa.destination?.code_iata || '—')})</span>\n </div>\n </div>\n <div class=\"timeline\">\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Плановый вылет:</span>\n <span class=\"timeline-value\">${safeDate(fa.scheduled_out)}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Фактический вылет:</span>\n <span class=\"timeline-value\">${safeDate(fa.actual_out)}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Взлёт:</span>\n <span class=\"timeline-value\">${safeDate(fa.actual_off)} ${fa.actual_runway_off ? `(ВПП ${fa.actual_runway_off})` : ''}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Посадка:</span>\n <span class=\"timeline-value\">${safeDate(fa.actual_on)} ${fa.actual_runway_on ? `(ВПП ${fa.actual_runway_on})` : ''}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Фактический прилёт:</span>\n <span class=\"timeline-value\">${safeDate(fa.actual_in)}</span>\n </div>\n </div>\n <div class=\"status-info\">\n <div class=\"status-item\">\n <span class=\"status-label\">Статус:</span>\n <span class=\"status-value\">${safeStr(fa.status || '—')}</span>\n </div>\n ${fa.departure_delay !== null && fa.departure_delay !== undefined ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Задержка вылета:</span>\n <span class=\"status-value ${fa.departure_delay < 0 ? 'delay-negative' : 'delay-positive'}\">${fa.departure_delay > 0 ? '+' : ''}${Math.floor(fa.departure_delay / 60)} мин</span>\n </div>\n ` : ''}\n ${fa.arrival_delay !== null && fa.arrival_delay !== undefined ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Задержка прилёта:</span>\n <span class=\"status-value ${fa.arrival_delay < 0 ? 'delay-negative' : 'delay-positive'}\">${fa.arrival_delay > 0 ? '+' : ''}${Math.floor(fa.arrival_delay / 60)} мин</span>\n </div>\n ` : ''}\n ${fa.gate_origin ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Гейт вылета:</span>\n <span class=\"status-value\">${fa.gate_origin}</span>\n </div>\n ` : ''}\n ${fa.gate_destination ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Гейт прилёта:</span>\n <span class=\"status-value\">${fa.gate_destination}</span>\n </div>\n ` : ''}\n ${fa.baggage_claim ? `\n <div class=\"status-item\">\n <span class=\"status-label\">Выдача багажа:</span>\n <span class=\"status-value\">${fa.baggage_claim}</span>\n </div>\n ` : ''}\n </div>\n </div>\n </div>`;\n } else {\n card += `\n <div class=\"source-section\">\n <div class=\"source-header\">\n <span class=\"source-badge source-flightaware\">FlightAware</span>\n <span class=\"source-missing\">Данные не получены</span>\n </div>\n </div>`;\n }\n\n // Данные из FlightRadar24\n if (fr) {\n card += `\n <div class=\"source-section\">\n <div class=\"source-header\">\n <span class=\"source-badge source-flightradar24\">FlightRadar24</span>\n </div>\n <div class=\"source-content\">\n <div class=\"route-info\">\n <div class=\"route-item\">\n <span class=\"route-label\">Откуда:</span>\n <span class=\"route-value\">${safeStr(fr.orig_iata || '—')} (${safeStr(fr.orig_icao || '—')})</span>\n </div>\n <div class=\"route-item\">\n <span class=\"route-label\">Куда:</span>\n <span class=\"route-value\">${safeStr(fr.dest_iata || '—')} (${safeStr(fr.dest_icao || '—')})</span>\n </div>\n </div>\n <div class=\"timeline\">\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Взлёт:</span>\n <span class=\"timeline-value\">${safeDate(fr.datetime_takeoff)} ${fr.runway_takeoff ? `(ВПП ${fr.runway_takeoff})` : ''}</span>\n </div>\n <div class=\"timeline-item\">\n <span class=\"timeline-label\">Посадка:</span>\n <span class=\"timeline-value\">${safeDate(fr.datetime_landed)} ${fr.runway_landed ? `(ВПП ${fr.runway_landed})` : ''}</span>\n </div>\n </div>\n <div class=\"status-info\">\n <div class=\"status-item\">\n <span class=\"status-label\">Время полёта:</span>\n <span class=\"status-value\">${formatDuration(fr.flight_time)}</span>\n </div>\n <div class=\"status-item\">\n <span class=\"status-label\">Фактическое расстояние:</span>\n <span class=\"status-value\">${formatDistance(fr.actual_distance)}</span>\n </div>\n <div class=\"status-item\">\n <span class=\"status-label\">Кратчайшее расстояние:</span>\n <span class=\"status-value\">${formatDistance(fr.circle_distance)}</span>\n </div>\n <div class=\"status-item\">\n <span class=\"status-label\">Статус полёта:</span>\n <span class=\"status-value\">${fr.flight_ended ? 'Завершён' : 'В процессе'}</span>\n </div>\n </div>\n </div>\n </div>`;\n } else {\n card += `\n <div class=\"source-section\">\n <div class=\"source-header\">\n <span class=\"source-badge source-flightradar24\">FlightRadar24</span>\n <span class=\"source-missing\">Данные не получены</span>\n </div>\n </div>`;\n }\n\n card += `</div>`;\n return card;\n};\n\n// Генерация полного HTML\nconst now = new Date();\nconst reportDate = now.toLocaleString('ru-RU', {\n year: 'numeric',\n month: 'long',\n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit'\n});\n\nconst html = `<!DOCTYPE html>\n<html lang=\"ru\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Отчёт о рейсах</title>\n <style>\n * { margin: 0; padding: 0; box-sizing: border-box; }\n body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: #333; background: #f5f5f5; padding: 20px; }\n .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); }\n .header { border-bottom: 3px solid #2563eb; padding-bottom: 20px; margin-bottom: 30px; }\n .header h1 { color: #1e40af; font-size: 28px; margin-bottom: 10px; }\n .header-meta { color: #666; font-size: 14px; }\n .sources-info { display: flex; gap: 15px; margin-top: 10px; flex-wrap: wrap; }\n .source-tag { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }\n .source-tag.available { background: #d1fae5; color: #065f46; }\n .source-tag.unavailable { background: #fee2e2; color: #991b1b; }\n .flight-card { border: 1px solid #e5e7eb; border-radius: 8px; margin-bottom: 25px; overflow: hidden; background: white; }\n .flight-header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; display: flex; justify-content: space-between; align-items: center; }\n .flight-header h2 { font-size: 24px; margin: 0; }\n .registration { background: rgba(255,255,255,0.2); padding: 6px 12px; border-radius: 4px; font-weight: 600; font-size: 14px; }\n .flight-info { padding: 15px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; }\n .info-row { display: flex; margin-bottom: 8px; }\n .info-row .label { font-weight: 600; color: #4b5563; width: 150px; flex-shrink: 0; }\n .info-row .value { color: #111827; }\n .source-section { border-top: 1px solid #e5e7eb; padding: 20px; }\n .source-section:first-of-type { border-top: none; }\n .source-header { display: flex; align-items: center; gap: 10px; margin-bottom: 15px; }\n .source-badge { display: inline-block; padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 600; color: white; }\n .source-badge.source-flightaware { background: #3b82f6; }\n .source-badge.source-flightradar24 { background: #10b981; }\n .source-missing { color: #ef4444; font-size: 13px; font-style: italic; }\n .source-content { margin-left: 0; }\n .route-info { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 20px; padding: 15px; background: #f9fafb; border-radius: 6px; }\n .route-item { display: flex; flex-direction: column; }\n .route-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }\n .route-value { font-size: 16px; font-weight: 600; color: #111827; }\n .timeline { margin-bottom: 20px; }\n .timeline-item { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }\n .timeline-item:last-child { border-bottom: none; }\n .timeline-label { font-weight: 500; color: #4b5563; width: 180px; flex-shrink: 0; }\n .timeline-value { color: #111827; text-align: right; }\n .status-info { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; padding: 15px; background: #f9fafb; border-radius: 6px; }\n .status-item { display: flex; flex-direction: column; }\n .status-label { font-size: 12px; color: #6b7280; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.5px; }\n .status-value { font-size: 14px; font-weight: 600; color: #111827; }\n .delay-negative { color: #10b981; }\n .delay-positive { color: #ef4444; }\n .no-data { text-align: center; padding: 60px 20px; color: #6b7280; font-size: 18px; }\n @media print { body { background: white; padding: 0; } .container { box-shadow: none; padding: 20px; } .flight-card { page-break-inside: avoid; margin-bottom: 20px; } }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <div class=\"header\">\n <h1>Отчёт о рейсах</h1>\n <div class=\"header-meta\">\n <div>Дата формирования: ${reportDate}</div>\n <div class=\"sources-info\">\n <span class=\"source-tag ${flightAwareData.length > 0 ? 'available' : 'unavailable'}\">\n FlightAware: ${flightAwareData.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}\n </span>\n <span class=\"source-tag ${flightRadar24Data.length > 0 ? 'available' : 'unavailable'}\">\n FlightRadar24: ${flightRadar24Data.length > 0 ? '✓ Данные получены' : '✗ Данные отсутствуют'}\n </span>\n </div>\n </div>\n </div>\n <div class=\"flights-container\">\n ${flights.length ? flights.map(generateFlightCard).join('') : '<div class=\"no-data\">Данные о рейсах не найдены</div>'}\n </div>\n </div>\n</body>\n</html>`;\n\n// ================== HTML → BASE64 ==================\nconst htmlBase64 = Buffer.from(html, 'utf8').toString('base64');\n\n// ================== RETURN ==================\nreturn [{\n json: {\n html_base64: htmlBase64,\n html: html,\n flights_count: flights.length,\n sources: {\n flightaware: { available: flightAwareData.length > 0, count: flightAwareData.length },\n flightradar24: { available: flightRadar24Data.length > 0, count: flightRadar24Data.length }\n },\n generated_at: now.toISOString()\n }\n}];\n"
|
||
},
|
||
"type": "n8n-nodes-base.code",
|
||
"typeVersion": 2,
|
||
"position": [
|
||
384,
|
||
128
|
||
],
|
||
"id": "a44a16f4-0ae7-4947-8f2b-3d70a6e6cfe0",
|
||
"name": "причесываем данные"
|
||
},
|
||
{
|
||
"parameters": {
|
||
"method": "POST",
|
||
"url": "http://147.45.146.17:3000/pdf?token=9ahhnpjkchxtcho9",
|
||
"sendBody": true,
|
||
"specifyBody": "json",
|
||
"jsonBody": "={\n \"url\": \"data:text/html;base64, {{ $json.html_base64 }}\",\n \"options\": {\n \"format\": \"A4\",\n \"printBackground\": true,\n \"margin\": {\n \"top\": \"20mm\",\n \"right\": \"15mm\",\n \"bottom\": \"20mm\",\n \"left\": \"20mm\"\n }\n }\n}",
|
||
"options": {}
|
||
},
|
||
"type": "n8n-nodes-base.httpRequest",
|
||
"typeVersion": 4.3,
|
||
"position": [
|
||
624,
|
||
128
|
||
],
|
||
"id": "fbbf533a-b3b8-4aba-8a38-106545a5a9e2",
|
||
"name": "HTTP Request: Browserless PDF"
|
||
}
|
||
],
|
||
"connections": {
|
||
"причесываем данные": {
|
||
"main": [
|
||
[
|
||
{
|
||
"node": "HTTP Request: Browserless PDF",
|
||
"type": "main",
|
||
"index": 0
|
||
}
|
||
]
|
||
]
|
||
}
|
||
},
|
||
"pinData": {},
|
||
"meta": {
|
||
"templateCredsSetupCompleted": true,
|
||
"instanceId": "ad5e78bf27056f72474a633d21d938f3223861ac866f2ebe4e46b867e404f489"
|
||
}
|
||
}
|