399 lines
18 KiB
JavaScript
399 lines
18 KiB
JavaScript
/* Med Diagnostics — логика сервиса (адаптирована для WordPress) */
|
||
(function () {
|
||
'use strict';
|
||
|
||
var API_URL = (window.MED_DIAG && MED_DIAG.apiUrl) || 'https://med.my-dpr.ru';
|
||
var PROXY_URL = (window.MED_DIAG && MED_DIAG.proxyUrl) || '';
|
||
var MODE = (window.MED_DIAG && MED_DIAG.mode) || 'proxy';
|
||
var USE_PROXY = (MODE === 'proxy') && !!PROXY_URL;
|
||
|
||
// Абсолютный URL: если ссылка относительная — добавляем домен API
|
||
function absUrl(url) {
|
||
if (!url) return url;
|
||
return url.indexOf('http') === 0 ? url : API_URL + url;
|
||
}
|
||
|
||
// URL для скачивания: в режиме прокси — через этот же WordPress (PHP → внутренний IP)
|
||
function downloadUrl(path) {
|
||
if (!path) return path;
|
||
if (path.indexOf('http') === 0) return path;
|
||
return USE_PROXY ? PROXY_URL + path : API_URL + path;
|
||
}
|
||
|
||
// Экранирование текста перед вставкой в innerHTML: сообщение об ошибке
|
||
// может прийти из ответа сервера (HTML-страница nginx/PHP и т.п.).
|
||
function escapeHtml(text) {
|
||
return String(text === undefined || text === null ? '' : text)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// Безопасный разбор ответа: если сервер вернул не JSON (например,
|
||
// HTML-страницу ошибки), не падаем с "Unexpected token '<'".
|
||
async function parseJson(response) {
|
||
var ct = (response.headers.get('content-type') || '').toLowerCase();
|
||
if (ct.indexOf('application/json') !== -1) {
|
||
return await response.json();
|
||
}
|
||
// HTML-ответ превращаем в короткое текстовое сообщение
|
||
var text = (await response.text()).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
if (text.length > 300) text = text.slice(0, 300) + '…';
|
||
return { error: text || ('Ошибка сервера (HTTP ' + response.status + ')') };
|
||
}
|
||
|
||
var uploadArea = document.getElementById('uploadArea');
|
||
var fileInput = document.getElementById('fileInput');
|
||
var analyzeBtn = document.getElementById('analyzeBtn');
|
||
var progressContainer = document.getElementById('progressContainer');
|
||
var progressFill = document.getElementById('progressFill');
|
||
var progressText = document.getElementById('progressText');
|
||
var resultCard = document.getElementById('resultCard');
|
||
var resultContent = document.getElementById('resultContent');
|
||
|
||
// Блок загрузки может быть закомментирован (осталось только сканирование с камеры),
|
||
// поэтому НЕ выходим раньше времени — инициализируем сканирование всегда.
|
||
var hasUpload = !!(uploadArea && fileInput && analyzeBtn);
|
||
|
||
var selectedFile = null;
|
||
|
||
if (hasUpload) {
|
||
uploadArea.addEventListener('click', function () { fileInput.click(); });
|
||
uploadArea.addEventListener('dragover', function (e) {
|
||
e.preventDefault();
|
||
uploadArea.classList.add('dragover');
|
||
});
|
||
uploadArea.addEventListener('dragleave', function () { uploadArea.classList.remove('dragover'); });
|
||
uploadArea.addEventListener('drop', function (e) {
|
||
e.preventDefault();
|
||
uploadArea.classList.remove('dragover');
|
||
if (e.dataTransfer.files.length > 0) {
|
||
handleFile(e.dataTransfer.files[0]);
|
||
}
|
||
});
|
||
fileInput.addEventListener('change', function (e) {
|
||
if (e.target.files.length > 0) handleFile(e.target.files[0]);
|
||
});
|
||
|
||
function handleFile(file) {
|
||
selectedFile = file;
|
||
uploadArea.querySelector('p strong').textContent = '📁 ' + file.name + ' (' + (file.size / 1024 / 1024).toFixed(1) + ' МБ)';
|
||
uploadArea.querySelector('.med-hint').textContent = 'Готово к анализу';
|
||
analyzeBtn.disabled = false;
|
||
}
|
||
|
||
analyzeBtn.addEventListener('click', function () {
|
||
if (!selectedFile) return;
|
||
sendVideo(selectedFile, selectedFile.name);
|
||
});
|
||
}
|
||
|
||
async function sendVideo(blob, filename) {
|
||
if (analyzeBtn) analyzeBtn.disabled = true;
|
||
if (progressContainer) progressContainer.style.display = 'block';
|
||
if (resultCard) resultCard.style.display = 'none';
|
||
setProgress(5, 'Загрузка видео...');
|
||
|
||
var formData = new FormData();
|
||
formData.append('video', blob, filename);
|
||
|
||
// В режиме прокси шлём на этот же WordPress (PHP перешлёт на внутренний IP бэкенда),
|
||
// иначе — напрямую на публичный URL API.
|
||
var analyzeUrl = USE_PROXY ? PROXY_URL + '/analyze' : API_URL + '/analyze';
|
||
|
||
// Автоповтор при обрыве соединения (нестабильная мобильная сеть)
|
||
var MAX_ATTEMPTS = 3;
|
||
for (var attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||
try {
|
||
var response = await fetch(analyzeUrl, {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (!response.ok) {
|
||
var err = await parseJson(response);
|
||
var e = new Error(err.error || 'Ошибка анализа');
|
||
e.videoUrl = err.video_url;
|
||
throw e;
|
||
}
|
||
|
||
setProgress(90, 'Формирование результата...');
|
||
var data = await parseJson(response);
|
||
|
||
if (data.error) {
|
||
showError(data.error);
|
||
return;
|
||
}
|
||
|
||
setProgress(100, 'Готово!');
|
||
document.getElementById('scanLoader').classList.remove('open');
|
||
setTimeout(function () {
|
||
if (progressContainer) progressContainer.style.display = 'none';
|
||
renderResult(data);
|
||
}, 300);
|
||
return;
|
||
} catch (err) {
|
||
// TypeError "Load failed" = обрыв соединения — пробуем ещё раз
|
||
if (err instanceof TypeError && attempt < MAX_ATTEMPTS) {
|
||
setProgress(5, 'Соединение прервано, повторная попытка (' + attempt + '/' + (MAX_ATTEMPTS - 1) + ')...');
|
||
await new Promise(function (r) { setTimeout(r, 1500); });
|
||
continue;
|
||
}
|
||
if (err instanceof TypeError) {
|
||
showError('Соединение прервано при загрузке видео. Проверьте интернет и попробуйте ещё раз.');
|
||
} else {
|
||
showError(err.message, err.videoUrl);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
function setProgress(pct, text) {
|
||
if (progressFill) progressFill.style.width = pct + '%';
|
||
if (progressText) progressText.textContent = text;
|
||
}
|
||
|
||
function showError(msg, videoUrl) {
|
||
document.getElementById('scanLoader').classList.remove('open');
|
||
if (progressContainer) progressContainer.style.display = 'none';
|
||
if (resultCard) resultCard.style.display = 'block';
|
||
var html = '<div class="med-error-box"><strong>Ошибка:</strong> ' + escapeHtml(msg) + '</div>';
|
||
if (videoUrl) {
|
||
html += '<div style="text-align:center; margin-top:15px">' +
|
||
'<a class="med-btn-download" href="' + downloadUrl(videoUrl) + '">🎬 Скачать записанное видео</a>' +
|
||
'</div>';
|
||
}
|
||
if (resultContent) resultContent.innerHTML = html;
|
||
if (analyzeBtn) analyzeBtn.disabled = false;
|
||
}
|
||
|
||
function renderResult(data) {
|
||
document.getElementById('scanLoader').classList.remove('open');
|
||
if (resultCard) resultCard.style.display = 'block';
|
||
|
||
var html = '';
|
||
|
||
// Кнопки скачивания (вверху блока результатов)
|
||
if (data.share_id) {
|
||
html += '<div class="med-download-buttons">' +
|
||
'<a class="med-btn-download" href="' + downloadUrl('/download/' + data.share_id + '/pdf') + '">📄 Скачать PDF</a>' +
|
||
'<a class="med-btn-download" href="' + downloadUrl('/download/' + data.share_id + '/docx') + '">📝 Скачать DOCX</a>' +
|
||
'<a class="med-btn-download" href="https://max.ru/miac_dnr_bot" target="_blank" rel="noopener">🏥 Запись на диспанцеризацию</a>' +
|
||
'</div>';
|
||
}
|
||
|
||
// Сводка
|
||
html += '<div class="med-summary-box">' + data.summary + '</div>';
|
||
|
||
// Предупреждения
|
||
if (data.warnings && data.warnings.length > 0) {
|
||
html += '<div class="med-warnings-box"><h3>⚠️ Требует внимания</h3><ul>';
|
||
data.warnings.forEach(function (w) { html += '<li>' + w + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
|
||
// Таблица показателей
|
||
html += '<h3>Показатели</h3>';
|
||
html += '<table class="med-metrics-table"><thead><tr><th>Показатель</th><th>Значение</th><th>Оценка</th></tr></thead><tbody>';
|
||
data.metrics.forEach(function (m) {
|
||
var statusText = {
|
||
'ok': 'В норме',
|
||
'warning': 'Обратить внимание',
|
||
'danger': 'Отклонение',
|
||
'info': 'Информация'
|
||
}[m.status] || m.status;
|
||
html += '<tr>' +
|
||
'<td>' + m.name + '</td>' +
|
||
'<td><strong>' + m.value + '</strong>' + (m.comment ? '<br><small style="color:#888">' + m.comment + '</small>' : '') + '</td>' +
|
||
'<td><span class="med-status-badge med-status-' + m.status + '">' + statusText + '</span></td>' +
|
||
'</tr>';
|
||
});
|
||
html += '</tbody></table>';
|
||
|
||
// Рекомендации
|
||
if (data.advice && data.advice.length > 0) {
|
||
html += '<div class="med-advice-box"><h3>💡 Рекомендации</h3><ul>';
|
||
data.advice.forEach(function (a) { html += '<li>' + a + '</li>'; });
|
||
html += '</ul></div>';
|
||
}
|
||
|
||
html += '<div class="med-disclaimer">⚠️ Результат получен автоматически по анализу видео и не является медицинским диагнозом. Для точной оценки обратитесь к врачу.</div>';
|
||
|
||
// Кнопка "Поделиться"
|
||
if (data.share_url) {
|
||
html += '<div style="text-align:center; margin-top:20px">' +
|
||
'<button class="med-btn" onclick="window.MED_DIAG_SHARE && MED_DIAG_SHARE(\'' + data.share_url + '\', this)">🔗 Поделиться результатом</button>' +
|
||
'</div>';
|
||
}
|
||
|
||
if (resultContent) resultContent.innerHTML = html;
|
||
if (resultCard) resultCard.scrollIntoView({ behavior: 'smooth' });
|
||
}
|
||
|
||
// Поделиться (глобальная функция для inline-onclick)
|
||
window.MED_DIAG_SHARE = function (url, btn) {
|
||
navigator.clipboard.writeText(url).then(function () {
|
||
var oldText = btn.textContent;
|
||
btn.textContent = '✅ Ссылка скопирована!';
|
||
setTimeout(function () { btn.textContent = oldText; }, 2000);
|
||
});
|
||
};
|
||
|
||
// ─── Сканирование с камеры ───
|
||
var scanSection = document.getElementById('scanSection');
|
||
var scanBtn = document.getElementById('scanBtn');
|
||
var scanModal = document.getElementById('scanModal');
|
||
var scanVideo = document.getElementById('scanVideo');
|
||
var scanProgressText = document.getElementById('scanProgressText');
|
||
var scanProgressFill = document.getElementById('scanProgressFill');
|
||
var scanCancelBtn = document.getElementById('scanCancelBtn');
|
||
|
||
var SCAN_FRAMES = 310; // чуть больше 300, чтобы в файле гарантированно было >= 300 кадров
|
||
var SCAN_FPS = 30;
|
||
var FRAME_INTERVAL = 1000 / SCAN_FPS;
|
||
|
||
var mediaStream = null;
|
||
var mediaRecorder = null;
|
||
var scanCanvas = null;
|
||
var scanCtx = null;
|
||
var scanFrameCount = 0;
|
||
var scanRafId = null;
|
||
var scanLastFrameTime = 0;
|
||
var scanChunks = [];
|
||
var scanActive = false;
|
||
|
||
// Показываем кнопку сканирования на всех устройствах
|
||
if (scanSection) {
|
||
scanSection.style.display = 'block';
|
||
}
|
||
|
||
if (scanBtn) scanBtn.addEventListener('click', startScan);
|
||
if (scanCancelBtn) scanCancelBtn.addEventListener('click', cancelScan);
|
||
|
||
async function startScan() {
|
||
if (scanActive) return;
|
||
scanActive = true;
|
||
scanBtn.disabled = true;
|
||
|
||
// Включаем фронтальную камеру
|
||
try {
|
||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||
video: {
|
||
facingMode: 'user',
|
||
width: { ideal: 1280 },
|
||
height: { ideal: 1280 }
|
||
},
|
||
audio: false
|
||
});
|
||
} catch (err) {
|
||
scanActive = false;
|
||
scanBtn.disabled = false;
|
||
showError('Не удалось получить доступ к камере: ' + (err.message || 'разрешите доступ'));
|
||
return;
|
||
}
|
||
|
||
scanModal.classList.add('open');
|
||
scanVideo.srcObject = mediaStream;
|
||
try { await scanVideo.play(); } catch (e) {}
|
||
|
||
scanChunks = [];
|
||
scanFrameCount = 0;
|
||
scanLastFrameTime = 0;
|
||
updateScanProgress(0);
|
||
|
||
// Canvas 1080×1080 — квадрат, лицо целиком влезает в portrait и landscape.
|
||
// Серверное extract_frames масштабирует до 1280×720 для SDK.
|
||
scanCanvas = document.createElement('canvas');
|
||
scanCanvas.width = 1080;
|
||
scanCanvas.height = 1080;
|
||
scanCtx = scanCanvas.getContext('2d');
|
||
|
||
var captureStream;
|
||
try {
|
||
captureStream = scanCanvas.captureStream(SCAN_FPS);
|
||
} catch (e) {
|
||
// Fallback: записываем напрямую с камеры
|
||
captureStream = mediaStream;
|
||
}
|
||
|
||
var mimeType = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4']
|
||
.find(function (t) { return MediaRecorder.isTypeSupported(t); }) || '';
|
||
mediaRecorder = new MediaRecorder(captureStream, {
|
||
mimeType: mimeType || undefined,
|
||
videoBitsPerSecond: 1500000 // 1.5 Мбит/с — маленький файл, качество достаточное
|
||
});
|
||
mediaRecorder.ondataavailable = function (e) { if (e.data.size > 0) scanChunks.push(e.data); };
|
||
mediaRecorder.onstop = function () {
|
||
var blob = new Blob(scanChunks, { type: mediaRecorder.mimeType || 'video/webm' });
|
||
var ext = blob.type.indexOf('mp4') !== -1 ? 'mp4' : 'webm';
|
||
scanModal.classList.remove('open');
|
||
stopCamera();
|
||
scanActive = false;
|
||
scanBtn.disabled = false;
|
||
// Показываем лоадер обработки
|
||
document.getElementById('scanLoader').classList.add('open');
|
||
sendVideo(blob, 'scan.' + ext);
|
||
};
|
||
mediaRecorder.start();
|
||
|
||
// Рисуем кадры с ограничением ~30 fps, считаем до 300
|
||
var drawFrame = function (ts) {
|
||
if (!scanActive) return;
|
||
if (scanFrameCount >= SCAN_FRAMES) {
|
||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||
mediaRecorder.stop();
|
||
}
|
||
return;
|
||
}
|
||
if (ts - scanLastFrameTime >= FRAME_INTERVAL) {
|
||
// Квадратный center-crop с запасом 10%: берём чуть меньший
|
||
// квадрат, чтобы голова гарантированно помещалась целиком
|
||
// (иначе SDK не находит лицо из-за обрезанной головы).
|
||
var vw = scanVideo.videoWidth;
|
||
var vh = scanVideo.videoHeight;
|
||
if (vw && vh) {
|
||
var side = Math.min(vw, vh) * 0.9;
|
||
var sx = (vw - side) / 2;
|
||
var sy = (vh - side) / 2;
|
||
scanCtx.drawImage(scanVideo, sx, sy, side, side, 0, 0, 1080, 1080);
|
||
} else {
|
||
scanCtx.drawImage(scanVideo, 0, 0, 1080, 1080);
|
||
}
|
||
scanFrameCount++;
|
||
scanLastFrameTime = ts;
|
||
updateScanProgress(scanFrameCount);
|
||
}
|
||
scanRafId = requestAnimationFrame(drawFrame);
|
||
};
|
||
scanRafId = requestAnimationFrame(drawFrame);
|
||
}
|
||
|
||
function updateScanProgress(count) {
|
||
var pct = Math.min(100, Math.round(count / SCAN_FRAMES * 100));
|
||
scanProgressText.textContent = 'Процесс сканирования: ' + pct + '%';
|
||
scanProgressFill.style.width = pct + '%';
|
||
}
|
||
|
||
function stopCamera() {
|
||
if (mediaStream) {
|
||
mediaStream.getTracks().forEach(function (t) { t.stop(); });
|
||
mediaStream = null;
|
||
}
|
||
if (scanRafId) cancelAnimationFrame(scanRafId);
|
||
scanRafId = null;
|
||
scanVideo.srcObject = null;
|
||
}
|
||
|
||
function cancelScan() {
|
||
scanActive = false;
|
||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||
mediaRecorder.onstop = null;
|
||
mediaRecorder.stop();
|
||
}
|
||
stopCamera();
|
||
scanModal.classList.remove('open');
|
||
scanBtn.disabled = false;
|
||
}
|
||
})(); |