feat: /upload эндпоинт, кнопка Загрузить, прогресс-бары, скорость, версия v1.0.0
Deploy loadtest / validate (push) Waiting to run
Deploy loadtest / validate (push) Waiting to run
This commit is contained in:
+160
-9
@@ -1,18 +1,19 @@
|
||||
/**
|
||||
* app.js — UI выбора файлов из локали.
|
||||
* app.js — UI выбора и загрузки файлов.
|
||||
*
|
||||
* Выбираем файлы через <input type="file" multiple>,
|
||||
* показываем в таблице: имя, размер, кнопка удаления.
|
||||
*
|
||||
* Никакой загрузки на сервер — чисто клиент.
|
||||
* показываем в таблице: имя, размер, прогресс, статус.
|
||||
* Кнопка «Загрузить» — XHR на /upload, контент не сохраняется.
|
||||
*/
|
||||
|
||||
var files = []; // [{ name, size, lastModified }]
|
||||
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);
|
||||
@@ -28,6 +29,14 @@ function formatSize(bytes) {
|
||||
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++) {
|
||||
@@ -35,7 +44,12 @@ function addFiles(fileList) {
|
||||
files.push({
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
lastModified: f.lastModified
|
||||
lastModified: f.lastModified,
|
||||
file: f, // сохраняем File-объект для XHR
|
||||
status: '', // '' | 'uploading' | 'ok' | 'err'
|
||||
progress: 0, // 0-100
|
||||
speed: null, // bytes/sec
|
||||
elapsed: null // ms
|
||||
});
|
||||
}
|
||||
renderTable();
|
||||
@@ -43,6 +57,7 @@ function addFiles(fileList) {
|
||||
|
||||
/** removeFile(i) — удалить файл по индексу */
|
||||
function removeFile(i) {
|
||||
if (uploading) return;
|
||||
files.splice(i, 1);
|
||||
renderTable();
|
||||
}
|
||||
@@ -50,9 +65,10 @@ function removeFile(i) {
|
||||
/** renderTable() — перерисовать таблицу и сводку */
|
||||
function renderTable() {
|
||||
if (files.length === 0) {
|
||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="3">Нет файлов — выберите файлы</td></tr>';
|
||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="5">Нет файлов — выберите файлы</td></tr>';
|
||||
fileSummary.textContent = 'Нет файлов';
|
||||
clearBtn.disabled = true;
|
||||
uploadBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,22 +77,157 @@ function renderTable() {
|
||||
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><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить">✕</button></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 = false;
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user