загрузка по одному файлу с XHR-прогрессом, ZIP-размеры, v1.6

This commit is contained in:
2026-06-16 15:08:09 +04:00
parent 435f617a25
commit 64b2295d8c
2 changed files with 108 additions and 33 deletions
+8 -3
View File
@@ -46,14 +46,19 @@ class ContractsApp:
return render_template("upload.html") return render_template("upload.html")
def _upload_files(self): def _upload_files(self):
"""Только сохранить файлы, без LLM.""" """Сохранить файлы. ?cid=X — добавить к существующему договору."""
files = request.files.getlist("files") files = request.files.getlist("files")
if not files or not any(f.filename for f in files): if not files or not any(f.filename for f in files):
return render_template("upload.html", error="Нет файлов") return jsonify({"error": "Нет файлов"}), 400
cid = request.args.get("cid")
if cid:
contract_id = cid
else:
conn, err = db.connect() conn, err = db.connect()
if err: if err:
return render_template("upload.html", error=f"БД: {err}") return jsonify({"error": f"БД: {err}"}), 500
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id", "INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
+87 -17
View File
@@ -32,22 +32,22 @@
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); } 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); } 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); } tr:hover td { background: rgba(243,244,246,.5); }
.name-cell { max-width: 380px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .name-cell { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.zip-child .name-cell { padding-left: 28px; } .zip-child .name-cell { padding-left: 28px; }
.zip-child .name-cell::before { content: "└ "; color: var(--muted); } .zip-child .name-cell::before { content: "└ "; color: var(--muted); }
.num-cell { text-align: right; white-space: nowrap; }
.status-ok { color: var(--green); } .status-ok { color: var(--green); }
.status-err { color: var(--destructive); } .status-err { color: var(--destructive); }
.summary-row td { background: rgba(34,197,94,.05); font-weight: 600; } .summary-row td { background: rgba(34,197,94,.05); font-weight: 600; }
.empty-row td { color: var(--muted); text-align: center; padding: 24px; } .empty-row td { color: var(--muted); text-align: center; padding: 24px; }
.remove-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; } .remove-btn { cursor: pointer; color: var(--muted); background: none; border: none; padding: 2px 4px; font-size: 16px; line-height: 1; }
.remove-btn:hover { color: var(--destructive); } .remove-btn:hover { color: var(--destructive); }
.error-box { margin-top: 12px; padding: 12px; border-radius: 6px; border: 1px solid var(--destructive); background: rgba(239,68,68,.05); color: var(--destructive); }
</style> </style>
</head> </head>
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="{{ url_for('static', filename='nubes-logo.svg') }}" alt="Nubes"> <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.5</span></span> <span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.6</span></span>
</div> </div>
<div class="content"> <div class="content">
@@ -62,7 +62,7 @@
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:80px;">Размер</th><th style="width:100px;">Статус</th><th style="width:30px;"></th></tr> <tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Статус</th><th style="width:30px;"></th></tr>
</thead> </thead>
<tbody id="fileTable"></tbody> <tbody id="fileTable"></tbody>
</table> </table>
@@ -85,6 +85,7 @@
let fileQueue = []; let fileQueue = [];
function formatSize(bytes) { function formatSize(bytes) {
if (!bytes || bytes === 0) return '—';
if (bytes < 1024) return bytes + ' B'; if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB'; return (bytes / 1048576).toFixed(1) + ' MB';
@@ -107,7 +108,7 @@
return '<tr class="' + cls + '" id="row_' + i + '">' + return '<tr class="' + cls + '" id="row_' + i + '">' +
'<td class="name-cell">' + f.name + '</td>' + '<td class="name-cell">' + f.name + '</td>' +
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' + '<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
'<td style="font-size:12px;color:var(--muted);">' + (f.size ? formatSize(f.size) : '—') + '</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"></td>' +
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить">×</button></td>' + '<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить">×</button></td>' +
'</tr>'; '</tr>';
@@ -118,7 +119,6 @@
function removeFile(index) { function removeFile(index) {
var f = fileQueue[index]; var f = fileQueue[index];
if (!f) return; if (!f) return;
// Если удаляем ZIP — удалить и всех его детей
if (!f.isZipChild && f.file && (f.file.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip'))) { if (!f.isZipChild && f.file && (f.file.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip'))) {
for (var i = fileQueue.length - 1; i >= 0; i--) { for (var i = fileQueue.length - 1; i >= 0; i--) {
if (fileQueue[i].zipName === f.name) fileQueue.splice(i, 1); if (fileQueue[i].zipName === f.name) fileQueue.splice(i, 1);
@@ -144,7 +144,6 @@
file: f file: f
}); });
// ZIP: показать содержимое
if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) { if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) {
try { try {
var zip = await JSZip.loadAsync(f); var zip = await JSZip.loadAsync(f);
@@ -152,10 +151,11 @@
if (!zipEntry.dir) { if (!zipEntry.dir) {
var ext = relativePath.toLowerCase().split('.').pop(); var ext = relativePath.toLowerCase().split('.').pop();
if (['docx','doc','pdf'].indexOf(ext) === -1) return; if (['docx','doc','pdf'].indexOf(ext) === -1) return;
var zsize = (zipEntry._data && zipEntry._data.uncompressedSize) ? zipEntry._data.uncompressedSize : 0;
fileQueue.push({ fileQueue.push({
name: relativePath, name: relativePath,
lastModified: zipEntry.date ? zipEntry.date.getTime() : 0, lastModified: zipEntry.date ? zipEntry.date.getTime() : 0,
size: 0, size: zsize,
isZipChild: true, isZipChild: true,
zipName: f.name zipName: f.name
}); });
@@ -171,6 +171,51 @@
renderTable(); renderTable();
}); });
// ── Загрузка по одному файлу ──────────────────────────────
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() { parseBtn.addEventListener('click', async function() {
if (fileQueue.length === 0) return; if (fileQueue.length === 0) return;
@@ -180,21 +225,39 @@
parseBtn.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> Загрузка...'; 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 formData = new FormData(); var contractId = null;
realFiles.forEach(function(f) { formData.append('files', f.file); });
// Загружаем каждый файл отдельно
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>';
}
var contractId;
try { try {
var uploadResp = await fetch('/', { method: 'POST', body: formData }); var resp = await uploadOne(f.file, contractId, row);
var uploadJson = await uploadResp.json(); contractId = resp.contract_id;
if (uploadJson.error) { alert(uploadJson.error); resetBtn(); return; }
contractId = uploadJson.contract_id; if (row) {
row.querySelector('.status-cell').innerHTML = '<span style="color:var(--green);">✓ загружен</span>';
}
} catch(e) { } catch(e) {
alert('Ошибка загрузки: ' + e.message); if (row) {
row.querySelector('.status-cell').innerHTML = '<span class="status-err">✗ ' + e.message + '</span>';
}
resetBtn();
return;
}
}
if (!contractId) {
resetBtn(); resetBtn();
return; 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> Парсинг...'; 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); var es = new EventSource('/parse/' + contractId);
@@ -213,6 +276,13 @@
var row = findRowByName(d.name); var row = findRowByName(d.name);
if (row) { 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с</span>';
// Обновить размер если был неизвестен
if (d.bytes > 0) {
var sizeCell = row.querySelector('.num-cell');
if (sizeCell && sizeCell.textContent.trim() === '—') {
sizeCell.textContent = formatSize(d.bytes);
}
}
} }
} }
@@ -246,7 +316,7 @@
summaryRow.className = 'summary-row'; summaryRow.className = 'summary-row';
summaryRow.innerHTML = '<td>✓ Готово: ' + d.files_processed + ' файлов</td>' + summaryRow.innerHTML = '<td>✓ Готово: ' + d.files_processed + ' файлов</td>' +
'<td></td>' + '<td></td>' +
'<td>' + formatSize(d.total_bytes) + '</td>' + '<td class="num-cell">' + formatSize(d.total_bytes) + '</td>' +
'<td><strong>' + d.total_time_s + 'с</strong></td>' + '<td><strong>' + d.total_time_s + 'с</strong></td>' +
'<td></td>'; '<td></td>';
fileTable.appendChild(summaryRow); fileTable.appendChild(summaryRow);