245 lines
8.0 KiB
JavaScript
245 lines
8.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;">✗</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);" title="' + 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 = 'Загрузить';
|
||
}
|
||
}
|
||
|
||
/** uploadAll() — последовательная загрузка всех файлов */
|
||
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;
|
||
|
||
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 uploadOne(f, i);
|
||
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.message;
|
||
errCount++;
|
||
}
|
||
renderTable();
|
||
}
|
||
|
||
uploading = false;
|
||
renderTable();
|
||
|
||
// Сводка в fileSummary
|
||
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;
|
||
}
|
||
|
||
/** uploadOne(fileEntry, idx) — XHR одного файла, возвращает {bytes, elapsed_ms} */
|
||
function uploadOne(f, idx) {
|
||
return new Promise(function (resolve, reject) {
|
||
var xhr = new XMLHttpRequest();
|
||
var fd = new FormData();
|
||
fd.append('file', f.file, f.name);
|
||
|
||
var t0 = Date.now();
|
||
|
||
xhr.open('POST', '/upload');
|
||
|
||
xhr.upload.onprogress = function (e) {
|
||
if (e.lengthComputable) {
|
||
f.progress = Math.round(e.loaded / e.total * 100);
|
||
renderTable();
|
||
}
|
||
};
|
||
|
||
xhr.onload = function () {
|
||
try {
|
||
var r = JSON.parse(xhr.responseText);
|
||
if (r.ok) {
|
||
resolve({ bytes: r.bytes, elapsed_ms: r.elapsed_ms });
|
||
} else {
|
||
reject(new Error(r.error || 'Ошибка сервера'));
|
||
}
|
||
} catch (e) {
|
||
reject(new Error('Некорректный ответ'));
|
||
}
|
||
};
|
||
|
||
xhr.onerror = function () {
|
||
var elapsed = Date.now() - t0;
|
||
reject(new Error('Сеть (' + (elapsed / 1000).toFixed(0) + 'с)'));
|
||
};
|
||
|
||
xhr.ontimeout = function () {
|
||
reject(new Error('Таймаут (' + (xhr.timeout / 1000) + 'с)'));
|
||
};
|
||
|
||
xhr.timeout = 300000; // 5 минут на файл
|
||
xhr.send(fd);
|
||
});
|
||
}
|
||
|
||
/** 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();
|