Files
sber-med-wordpress/assets/js/med-diag.js
T

402 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// Безопасный разбор ответа: если сервер вернул не 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>';
if (data.video_url) {
html += '<a class="med-btn-download" href="' + downloadUrl(data.video_url) + '">🎬 Скачать видео</a>';
}
html += '<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;
}
})();