277 lines
9.0 KiB
JavaScript
277 lines
9.0 KiB
JavaScript
/**
|
||
* app.js — UI выбора и загрузки файлов.
|
||
*
|
||
* Выбираем файлы через <input type="file" multiple>,
|
||
* показываем в таблице: имя, размер, прогресс, статус.
|
||
* Кнопка «Загрузить» — XHR на /upload, контент не сохраняется.
|
||
*/
|
||
|
||
var files = []; // [{ name, size, lastModified, file, status, progress, speed, elapsed }]
|
||
var uploading = false; // флаг: идёт загрузка
|
||
|
||
var fileInput = document.getElementById('fileInput');
|
||
var fileTable = document.getElementById('fileTable');
|
||
var fileSummary = document.getElementById('fileSummary');
|
||
var clearBtn = document.getElementById('clearBtn');
|
||
var uploadBtn = document.getElementById('uploadBtn');
|
||
|
||
fileInput.addEventListener('change', function () {
|
||
addFiles(fileInput.files);
|
||
fileInput.value = '';
|
||
});
|
||
|
||
/** formatSize(bytes) → "1.2 KB", "3.4 MB", "900 B" */
|
||
function formatSize(bytes) {
|
||
if (!bytes || bytes === 0) return '—';
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||
return (bytes / 1073741824).toFixed(2) + ' GB';
|
||
}
|
||
|
||
/** formatSpeed(bytesPerSec) → "1.2 MB/s" */
|
||
function formatSpeed(bps) {
|
||
if (!bps || bps === 0) return '—';
|
||
if (bps < 1024) return bps + ' B/s';
|
||
if (bps < 1048576) return (bps / 1024).toFixed(1) + ' KB/s';
|
||
return (bps / 1048576).toFixed(1) + ' MB/s';
|
||
}
|
||
|
||
/** addFiles(fileList) — добавить файлы в массив и перерисовать */
|
||
function addFiles(fileList) {
|
||
for (var i = 0; i < fileList.length; i++) {
|
||
var f = fileList[i];
|
||
files.push({
|
||
name: f.name,
|
||
size: f.size,
|
||
lastModified: f.lastModified,
|
||
file: f, // сохраняем File-объект для XHR
|
||
status: '', // '' | 'uploading' | 'ok' | 'err'
|
||
progress: 0, // 0-100
|
||
speed: null, // bytes/sec
|
||
elapsed: null // ms
|
||
});
|
||
}
|
||
renderTable();
|
||
}
|
||
|
||
/** removeFile(i) — удалить файл по индексу */
|
||
function removeFile(i) {
|
||
if (uploading) return;
|
||
files.splice(i, 1);
|
||
renderTable();
|
||
}
|
||
|
||
/** renderTable() — перерисовать таблицу и сводку */
|
||
function renderTable() {
|
||
if (files.length === 0) {
|
||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="5">Нет файлов — выберите файлы</td></tr>';
|
||
fileSummary.textContent = 'Нет файлов';
|
||
clearBtn.disabled = true;
|
||
uploadBtn.disabled = true;
|
||
return;
|
||
}
|
||
|
||
var totalSize = 0;
|
||
var rows = [];
|
||
for (var i = 0; i < files.length; i++) {
|
||
var f = files[i];
|
||
totalSize += f.size;
|
||
|
||
// Прогресс-бар
|
||
var progressHTML = '';
|
||
if (f.status === 'uploading') {
|
||
progressHTML = '<div style="display:flex;align-items:center;gap:4px;">' +
|
||
'<div style="flex:1;height:6px;background:#e0e0e0;border-radius:3px;overflow:hidden;">' +
|
||
'<div style="height:100%;width:' + f.progress + '%;background:var(--brand);border-radius:3px;transition:width .1s;"></div>' +
|
||
'</div>' +
|
||
'<span style="font-size:11px;min-width:32px;">' + f.progress + '%</span>' +
|
||
'</div>';
|
||
} else if (f.status === 'ok') {
|
||
progressHTML = '<span style="color:var(--green);font-size:12px;">✓ ' + formatSpeed(f.speed) + '</span>';
|
||
} else if (f.status === 'err') {
|
||
progressHTML = '<span style="color:var(--red);font-size:12px;">✗ ' + escHtml(f.error || '') + '</span>';
|
||
} else {
|
||
progressHTML = '<span style="color:var(--muted);">—</span>';
|
||
}
|
||
|
||
// Статус
|
||
var statusHTML = '';
|
||
if (f.status === 'uploading') {
|
||
statusHTML = '<span style="color:var(--brand);">↑ загрузка...</span>';
|
||
} else if (f.status === 'ok') {
|
||
statusHTML = '<span style="color:var(--green);">✓ ' + (f.elapsed ? (f.elapsed / 1000).toFixed(1) + 'с' : '') + '</span>';
|
||
} else if (f.status === 'err') {
|
||
statusHTML = '<span style="color:var(--red);">✗ ' + escHtml(f.error || '') + '</span>';
|
||
} else {
|
||
statusHTML = '<span style="color:var(--muted);">ожидает</span>';
|
||
}
|
||
|
||
rows.push(
|
||
'<tr>' +
|
||
'<td class="name-cell" title="' + escHtml(f.name) + '">' + escHtml(f.name) + '</td>' +
|
||
'<td class="num-cell">' + formatSize(f.size) + '</td>' +
|
||
'<td>' + progressHTML + '</td>' +
|
||
'<td>' + statusHTML + '</td>' +
|
||
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"' + (uploading ? ' disabled style="opacity:.3;cursor:not-allowed;"' : '') + '>✕</button></td>' +
|
||
'</tr>'
|
||
);
|
||
}
|
||
fileTable.innerHTML = rows.join('');
|
||
|
||
fileSummary.textContent = files.length + ' файл., ' + formatSize(totalSize);
|
||
clearBtn.disabled = uploading;
|
||
uploadBtn.disabled = uploading || files.length === 0;
|
||
|
||
if (uploading) {
|
||
uploadBtn.textContent = '⏳ Загрузка...';
|
||
} else {
|
||
uploadBtn.textContent = 'Загрузить';
|
||
}
|
||
}
|
||
|
||
/** warmup() — прогрев соединения перед загрузкой */
|
||
function warmup() {
|
||
return new Promise(function (resolve) {
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('GET', '/health');
|
||
xhr.timeout = 3000;
|
||
xhr.onload = function () { resolve(true); };
|
||
xhr.onerror = function () { resolve(false); };
|
||
xhr.ontimeout = function () { resolve(false); };
|
||
xhr.send();
|
||
});
|
||
}
|
||
|
||
/** uploadAll() — загрузка всех файлов через один WebSocket */
|
||
async function uploadAll() {
|
||
if (uploading || files.length === 0) return;
|
||
uploading = true;
|
||
uploadBtn.disabled = true;
|
||
clearBtn.disabled = true;
|
||
|
||
var totalBytes = 0;
|
||
var totalElapsed = 0;
|
||
var okCount = 0;
|
||
var errCount = 0;
|
||
|
||
// WebSocket загрузка
|
||
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
var wsUrl = proto + '//' + location.host + '/ws-upload';
|
||
|
||
for (var i = 0; i < files.length; i++) {
|
||
var f = files[i];
|
||
if (f.status === 'ok') continue;
|
||
f.status = 'uploading';
|
||
f.progress = 0;
|
||
renderTable();
|
||
try {
|
||
var result = await uploadFileWS(wsUrl, f);
|
||
if (!result) throw new Error('Empty result');
|
||
f.status = 'ok';
|
||
f.elapsed = result.elapsed_ms;
|
||
f.speed = result.elapsed_ms > 0 ? Math.round(result.bytes / (result.elapsed_ms / 1000)) : null;
|
||
totalBytes += result.bytes;
|
||
totalElapsed += result.elapsed_ms;
|
||
okCount++;
|
||
} catch (e) {
|
||
f.status = 'err';
|
||
f.error = (e && e.message) || String(e) || 'Unknown error';
|
||
errCount++;
|
||
}
|
||
renderTable();
|
||
}
|
||
|
||
uploading = false;
|
||
renderTable();
|
||
|
||
var summary = files.length + ' файл.';
|
||
if (okCount > 0) {
|
||
var avgSpeed = totalElapsed > 0 ? Math.round(totalBytes / (totalElapsed / 1000)) : 0;
|
||
summary += ' | ✓ ' + okCount + ' / ✗ ' + errCount;
|
||
summary += ' | ' + formatSize(totalBytes) + ' за ' + (totalElapsed / 1000).toFixed(1) + 'с';
|
||
summary += ' | ~' + formatSpeed(avgSpeed);
|
||
}
|
||
fileSummary.textContent = summary;
|
||
}
|
||
|
||
function uploadFileWS(url, f) {
|
||
return new Promise(function (resolve, reject) {
|
||
var ws = new WebSocket(url);
|
||
var CHUNK = 64 * 1024;
|
||
var offset = 0;
|
||
var startTime = Date.now();
|
||
var lastProgress = 0;
|
||
var done = false;
|
||
|
||
ws.binaryType = 'arraybuffer';
|
||
|
||
function finish(err, data) {
|
||
if (done) return;
|
||
done = true;
|
||
try { ws.close(); } catch (e) {}
|
||
if (err) reject(err);
|
||
else resolve(data);
|
||
}
|
||
|
||
setTimeout(function () { finish(new Error('Timeout')); }, 300000);
|
||
|
||
ws.onopen = function () {
|
||
var reader = new FileReader();
|
||
reader.onload = function (e) {
|
||
ws.send(e.target.result);
|
||
offset += CHUNK;
|
||
if (offset < f.size) {
|
||
var pct = Math.round(offset / f.size * 100);
|
||
if (pct - lastProgress >= 10) { lastProgress = pct; f.progress = pct; renderTable(); }
|
||
readNext();
|
||
} else {
|
||
ws.send('DONE');
|
||
}
|
||
};
|
||
function readNext() {
|
||
var end = Math.min(offset + CHUNK, f.size);
|
||
reader.readAsArrayBuffer(f.file.slice(offset, end));
|
||
}
|
||
readNext();
|
||
};
|
||
|
||
ws.onmessage = function (e) {
|
||
try {
|
||
var r = JSON.parse(e.data);
|
||
if (r && r.ok) {
|
||
f.progress = 100;
|
||
renderTable();
|
||
finish(null, { bytes: r.bytes, elapsed_ms: Date.now() - startTime });
|
||
}
|
||
} catch (err) {}
|
||
};
|
||
|
||
ws.onerror = function () { finish(new Error('Connection failed')); };
|
||
ws.onclose = function () { if (offset < f.size) finish(new Error('Closed early')); };
|
||
});
|
||
}
|
||
|
||
function uploadFileHTTP() {}
|
||
|
||
// WebSocket upload больше не используется; оставлен для справки
|
||
function uploadFileWS() {}
|
||
|
||
/** clearAll() — очистить всё */
|
||
function clearAll() {
|
||
if (uploading) return;
|
||
files = [];
|
||
renderTable();
|
||
}
|
||
|
||
/** escHtml(s) — экранирование HTML */
|
||
function escHtml(s) {
|
||
if (s == null) return '';
|
||
var d = document.createElement('div');
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
// Первичный рендер
|
||
renderTable();
|