загрузка по одному файлу с XHR-прогрессом, ZIP-размеры, v1.6
This commit is contained in:
+19
-14
@@ -46,23 +46,28 @@ class ContractsApp:
|
||||
return render_template("upload.html")
|
||||
|
||||
def _upload_files(self):
|
||||
"""Только сохранить файлы, без LLM."""
|
||||
"""Сохранить файлы. ?cid=X — добавить к существующему договору."""
|
||||
files = request.files.getlist("files")
|
||||
if not files or not any(f.filename for f in files):
|
||||
return render_template("upload.html", error="Нет файлов")
|
||||
return jsonify({"error": "Нет файлов"}), 400
|
||||
|
||||
conn, err = db.connect()
|
||||
if err:
|
||||
return render_template("upload.html", error=f"БД: {err}")
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
|
||||
("б/н " + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""),
|
||||
)
|
||||
contract_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
cid = request.args.get("cid")
|
||||
|
||||
if cid:
|
||||
contract_id = cid
|
||||
else:
|
||||
conn, err = db.connect()
|
||||
if err:
|
||||
return jsonify({"error": f"БД: {err}"}), 500
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT INTO contracts (number, client) VALUES (%s, %s) RETURNING id",
|
||||
("б/н " + __import__("datetime").datetime.now().strftime("%Y%m%d-%H%M"), ""),
|
||||
)
|
||||
contract_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
for f in files:
|
||||
if not f.filename:
|
||||
|
||||
+89
-19
@@ -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); }
|
||||
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: 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::before { content: "└ "; color: var(--muted); }
|
||||
.num-cell { text-align: right; white-space: nowrap; }
|
||||
.status-ok { color: var(--green); }
|
||||
.status-err { color: var(--destructive); }
|
||||
.summary-row td { background: rgba(34,197,94,.05); font-weight: 600; }
|
||||
.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: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>
|
||||
</head>
|
||||
<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.5</span></span>
|
||||
<span class="title">Сверка договоров <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.6</span></span>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
@@ -62,7 +62,7 @@
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<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>
|
||||
<tbody id="fileTable"></tbody>
|
||||
</table>
|
||||
@@ -85,6 +85,7 @@
|
||||
let fileQueue = [];
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (!bytes || bytes === 0) return '—';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
@@ -107,7 +108,7 @@
|
||||
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 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><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить">×</button></td>' +
|
||||
'</tr>';
|
||||
@@ -118,7 +119,6 @@
|
||||
function removeFile(index) {
|
||||
var f = fileQueue[index];
|
||||
if (!f) return;
|
||||
// Если удаляем 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--) {
|
||||
if (fileQueue[i].zipName === f.name) fileQueue.splice(i, 1);
|
||||
@@ -144,7 +144,6 @@
|
||||
file: f
|
||||
});
|
||||
|
||||
// ZIP: показать содержимое
|
||||
if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) {
|
||||
try {
|
||||
var zip = await JSZip.loadAsync(f);
|
||||
@@ -152,10 +151,11 @@
|
||||
if (!zipEntry.dir) {
|
||||
var ext = relativePath.toLowerCase().split('.').pop();
|
||||
if (['docx','doc','pdf'].indexOf(ext) === -1) return;
|
||||
var zsize = (zipEntry._data && zipEntry._data.uncompressedSize) ? zipEntry._data.uncompressedSize : 0;
|
||||
fileQueue.push({
|
||||
name: relativePath,
|
||||
lastModified: zipEntry.date ? zipEntry.date.getTime() : 0,
|
||||
size: 0,
|
||||
size: zsize,
|
||||
isZipChild: true,
|
||||
zipName: f.name
|
||||
});
|
||||
@@ -171,6 +171,51 @@
|
||||
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() {
|
||||
if (fileQueue.length === 0) return;
|
||||
|
||||
@@ -180,21 +225,39 @@
|
||||
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 formData = new FormData();
|
||||
realFiles.forEach(function(f) { formData.append('files', f.file); });
|
||||
var contractId = null;
|
||||
|
||||
var contractId;
|
||||
try {
|
||||
var uploadResp = await fetch('/', { method: 'POST', body: formData });
|
||||
var uploadJson = await uploadResp.json();
|
||||
if (uploadJson.error) { alert(uploadJson.error); resetBtn(); return; }
|
||||
contractId = uploadJson.contract_id;
|
||||
} catch(e) {
|
||||
alert('Ошибка загрузки: ' + e.message);
|
||||
// Загружаем каждый файл отдельно
|
||||
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);
|
||||
@@ -213,6 +276,13 @@
|
||||
var row = findRowByName(d.name);
|
||||
if (row) {
|
||||
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.innerHTML = '<td>✓ Готово: ' + d.files_processed + ' файлов</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></td>';
|
||||
fileTable.appendChild(summaryRow);
|
||||
|
||||
Reference in New Issue
Block a user