загрузка при выборе файла: прогресс, таймер, блокировка input, v1.13

This commit is contained in:
2026-06-17 12:42:07 +04:00
parent f66d188551
commit 31fec1ca84
+112 -121
View File
@@ -32,7 +32,7 @@
th { background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 10px; border-right: 1px solid var(--brand-gray); text-align: left; font-weight: 600; font-size: 11px; color: var(--muted); }
td { padding: 6px 10px; border-right: 1px solid var(--brand-gray); border-bottom: 1px solid var(--brand-gray); }
tr:hover td { background: rgba(243,244,246,.5); }
.name-cell { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zip-child .name-cell { padding-left: 28px; }
.zip-child .name-cell::before { content: "└ "; color: var(--muted); }
.num-cell { text-align: right; white-space: nowrap; }
@@ -45,7 +45,6 @@
.info-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 14px; display: none; }
.info-btn:hover { color: var(--brand-primary); }
.info-btn.visible { display: inline; }
/* Modal */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,.4); z-index: 100; justify-content: center; align-items: center; }
.modal-overlay.open { display: flex; }
.modal { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 4px 24px rgba(0,0,0,.15); max-width: 520px; width: 90%; max-height: 80vh; overflow-y: auto; }
@@ -60,7 +59,7 @@
<body>
<div class="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes">
<span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.12</span></span>
<span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.13</span></span>
</div>
<div class="content">
@@ -88,7 +87,6 @@
</div>
</div>
<!-- Модальное окно Инфо -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
@@ -108,6 +106,7 @@
const fileTable = document.getElementById('fileTable');
let fileQueue = [];
let contractId = null;
function formatSize(bytes) {
if (!bytes || bytes === 0) return '—';
@@ -127,14 +126,13 @@
fileTable.innerHTML = '<tr class="empty-row"><td colspan="5">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
parseBtn.disabled = true;
} else {
parseBtn.disabled = false;
fileTable.innerHTML = fileQueue.map(function(f, i) {
var cls = f.isZipChild ? 'zip-child' : '';
return '<tr class="' + cls + '" id="row_' + i + '">' +
'<td class="name-cell">' + f.name + '</td>' +
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
'<td class="status-cell"></td>' +
'<td class="status-cell">' + (f.status || '') + '</td>' +
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить">×</button>' +
'<button class="info-btn" id="info_' + i + '" onclick="showInfo(' + i + ')" title="Инфо"></button></td>' +
'</tr>';
@@ -145,34 +143,123 @@
function removeFile(index) {
var f = fileQueue[index];
if (!f) return;
if (!f.isZipChild && f.file && (f.file.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip'))) {
if (!f.isZipChild && (f.name||'').toLowerCase().endsWith('.zip')) {
for (var i = fileQueue.length - 1; i >= 0; i--) {
if (fileQueue[i].zipName === f.name) fileQueue.splice(i, 1);
}
}
fileQueue.splice(index, 1);
if (fileQueue.filter(function(x){return !x.isZipChild;}).length === 0) contractId = null;
renderTable();
}
window.removeFile = removeFile;
// ── Загрузка файла (XHR с прогрессом) ──────────────────────
function uploadFile(file, cid) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
var url = '/' + (cid ? '?cid=' + cid : '');
xhr.open('POST', url);
xhr.timeout = 120000;
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
var pct = Math.round(e.loaded / e.total * 100);
resolve({_progress: true, pct: pct});
}
};
xhr.onload = function() {
if (xhr.status === 200) {
try { resolve(JSON.parse(xhr.responseText)); }
catch(e) { reject(new Error('Bad JSON')); }
} else {
reject(new Error('HTTP ' + xhr.status));
}
};
xhr.onerror = function() { reject(new Error('Сеть')); };
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
var fd = new FormData();
fd.append('files', file);
xhr.send(fd);
});
}
// ── Выбор файла → сразу загрузка ────────────────────────────
fileInput.addEventListener('change', async function() {
var newFiles = Array.from(fileInput.files);
if (newFiles.length === 0) return;
fileInput.disabled = true;
parseBtn.disabled = true;
parseBtn.innerHTML = '<span style="display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;"></span> Загрузка...';
for (var i = 0; i < newFiles.length; i++) {
var f = newFiles[i];
if (fileQueue.find(function(q) { return q.name === f.name && q.size === f.size; })) continue;
if (fileQueue.find(function(q) { return q.name === f.name && q.size === f.size && !q.isZipChild; })) continue;
fileQueue.push({
name: f.name,
lastModified: f.lastModified,
size: f.size,
file: f
// Добавить строку с прогрессом
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '<span style="color:var(--muted);">↑ 0%</span>' };
fileQueue.push(entry);
var rowIdx = fileQueue.length - 1;
renderTable();
var row = document.getElementById('row_' + rowIdx);
var startTime = Date.now();
// Загружаем
var lastPct = 0;
var done = false;
uploadFile(f, contractId).then(
function(res) {
if (done) return;
if (res._progress) {
var pct = res.pct;
if (pct !== lastPct) {
lastPct = pct;
fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ ' + pct + '%</span>';
renderTable();
}
return; // ждём следующий прогресс или завершение
}
// Завершено
done = true;
contractId = res.contract_id;
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + elapsed + 'с</span>';
fileQueue[rowIdx].uploaded = true;
renderTable();
// ZIP: показать содержимое
if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) {
showZipContents(f);
}
},
function(err) {
if (done) return;
done = true;
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
renderTable();
}
);
}
fileInput.value = '';
fileInput.disabled = false;
parseBtn.disabled = false;
parseBtn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг';
lucide.createIcons();
});
if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) {
async function showZipContents(zipFile) {
try {
var zip = await JSZip.loadAsync(f);
var zip = await JSZip.loadAsync(zipFile);
zip.forEach(function(relativePath, zipEntry) {
if (!zipEntry.dir) {
var ext = relativePath.toLowerCase().split('.').pop();
@@ -182,108 +269,24 @@
name: relativePath,
lastModified: zipEntry.date ? zipEntry.date.getTime() : 0,
size: zsize,
status: '',
isZipChild: true,
zipName: f.name
zipName: zipFile.name
});
}
});
renderTable();
} catch(e) {
console.log('JSZip error:', e);
}
}
}
fileInput.value = '';
renderTable();
});
// ── Парсинг (SSE) ────────────────────────────────────────────
// ── Загрузка по одному файлу ──────────────────────────────
function uploadOne(file, cid, row) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
var url = '/' + (cid ? '?cid=' + cid : '');
xhr.open('POST', url);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable && row) {
var pct = Math.round(e.loaded / e.total * 100);
row.querySelector('.status-cell').innerHTML = '<span style="color:var(--muted);">↑ ' + pct + '%</span>';
}
};
xhr.onload = function() {
if (xhr.status === 200) {
try {
resolve(JSON.parse(xhr.responseText));
} catch(e) {
reject(new Error('Bad JSON'));
}
} else {
reject(new Error('HTTP ' + xhr.status));
}
};
xhr.onerror = function() {
reject(new Error('Сеть'));
};
xhr.ontimeout = function() {
reject(new Error('Таймаут'));
};
xhr.timeout = 60000;
var fd = new FormData();
fd.append('files', file);
xhr.send(fd);
});
}
// ── Парсинг ────────────────────────────────────────────────
parseBtn.addEventListener('click', async function() {
if (fileQueue.length === 0) return;
var realFiles = fileQueue.filter(function(f) { return !f.isZipChild; });
if (realFiles.length === 0) return;
parseBtn.addEventListener('click', function() {
if (!contractId) return;
parseBtn.disabled = true;
parseBtn.innerHTML = '<span style="display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;"></span> Загрузка...';
var contractId = null;
// Загружаем каждый файл отдельно
for (var i = 0; i < realFiles.length; i++) {
var f = realFiles[i];
var row = findRowByName(f.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span style="color:var(--muted);">↑ 0%</span>';
}
try {
var resp = await uploadOne(f.file, contractId, row);
contractId = resp.contract_id;
if (row) {
row.querySelector('.status-cell').innerHTML = '<span style="color:var(--green);">✓ загружен</span>';
}
} catch(e) {
if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="status-err">✗ ' + e.message + '</span>';
}
resetBtn();
return;
}
}
if (!contractId) {
resetBtn();
return;
}
// Фаза парсинга — SSE
parseBtn.innerHTML = '<span style="display:inline-block;width:16px;height:16px;border:2px solid rgba(255,255,255,.3);border-top-color:#fff;border-radius:50%;animation:spin .6s linear infinite;"></span> Парсинг...';
var es = new EventSource('/parse/' + contractId);
@@ -301,8 +304,7 @@
if (d.type === 'file_start') {
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="proc-timer" data-start="' + Date.now() + '">⏳ 0с</span>';
// Обновить размер если был неизвестен
row.querySelector('.status-cell').innerHTML = '<span class="proc-timer" data-start="' + Date.now() + '">⏳ 0.0с</span>';
if (d.bytes > 0) {
var sizeCell = row.querySelector('.num-cell');
if (sizeCell && sizeCell.textContent.trim() === '—') {
@@ -316,15 +318,12 @@
var row = findRowByName(d.name);
if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="status-ok">✓ ' + d.time_s + 'с</span>';
// Сохранить детали в fileQueue и показать ℹ
var idx = findIndexByName(d.name);
if (idx >= 0) {
fileQueue[idx].parseInfo = d;
var infoBtn = document.getElementById('info_' + idx);
if (idx >= 0) { fileQueue[idx].parseInfo = d; }
var infoBtn = document.getElementById('info_' + (idx >= 0 ? idx : ''));
if (infoBtn) infoBtn.classList.add('visible');
}
}
}
else if (d.type === 'file_error') {
var row = findRowByName(d.name);
@@ -343,8 +342,6 @@
else if (d.type === 'summary') {
clearInterval(timerInterval);
es.close();
// Идемпотентность: больше нельзя запустить
parseBtn.disabled = true;
parseBtn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
lucide.createIcons();
@@ -363,14 +360,10 @@
es.addEventListener('error', function() {
clearInterval(timerInterval);
es.close();
resetBtn();
});
function resetBtn() {
parseBtn.disabled = false;
parseBtn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг';
lucide.createIcons();
}
});
function findRowByName(name) {
for (var i = 0; i < fileQueue.length; i++) {
@@ -399,10 +392,8 @@
document.getElementById('modalTitle').textContent = f.name;
var rows = [
['Тип', f.file ? f.file.type : (f.name.split('.').pop() || '—')],
['Размер', formatSize(f.size)],
['В архиве', f.zipName || '—'],
['Статус', 'parsed'],
['Время парсинга', d.time_s + 'с'],
['Элементов всего', d.elements],
['Параграфов', d.paragraphs],