загрузка по одному файлу с XHR-прогрессом, ZIP-размеры, v1.6
This commit is contained in:
+8
-3
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user