458 lines
20 KiB
HTML
458 lines
20 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Сверка договоров</title>
|
||
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script>
|
||
<script src="https://unpkg.com/lucide@latest"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--brand-primary: #2563eb; --brand-primary-dark: #1d4ed8;
|
||
--brand-gray: #d1d5db; --brand-grey-light: #f3f4f6;
|
||
--background: #ffffff; --foreground: #1a1a1a;
|
||
--muted: #6b7280; --destructive: #ef4444; --green: #22c55e;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: system-ui, sans-serif; font-size: 14px; color: var(--foreground); background: var(--brand-grey-light); min-height: 100vh; }
|
||
.topbar { background: var(--background); border-bottom: 1px solid var(--brand-gray); padding: 0 24px; height: 56px; display: flex; align-items: center; gap: 16px; }
|
||
.topbar img { height: 28px; }
|
||
.topbar .title { font-weight: 600; font-size: 16px; }
|
||
.content { max-width: 900px; margin: 32px auto; padding: 0 16px; }
|
||
.card { background: var(--background); border-radius: 12px; border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.05); overflow: hidden; margin-bottom: 16px; }
|
||
.card-header { background: var(--brand-grey-light); padding: 12px 16px; font-weight: 600; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
||
.card-body { padding: 16px; }
|
||
.btn { height: 36px; border-radius: 6px; gap: 6px; padding: 0 14px; font-size: 14px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; border: 1px solid var(--brand-gray); background: var(--background); }
|
||
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
|
||
.btn-primary:hover { background: var(--brand-primary-dark); }
|
||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow: hidden; margin-top: 12px; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
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: 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; }
|
||
.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); }
|
||
.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-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; }
|
||
.modal-header { padding: 14px 16px; border-bottom: 1px solid var(--brand-gray); display: flex; align-items: center; gap: 8px; font-weight: 600; }
|
||
.modal-body { padding: 16px; }
|
||
.modal-body .kv { display: flex; margin-bottom: 6px; font-size: 13px; }
|
||
.modal-body .kv .k { color: var(--muted); width: 110px; flex-shrink: 0; }
|
||
.modal-body .kv .v { word-break: break-all; }
|
||
.modal-body pre { background: var(--brand-grey-light); padding: 10px; border-radius: 6px; font-size: 12px; max-height: 200px; overflow-y: auto; white-space: pre-wrap; margin-top: 4px; }
|
||
</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;">v2.0</span></span>
|
||
</div>
|
||
|
||
<div class="content">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<i data-lucide="folder-open" style="width:18px;height:18px;"></i>
|
||
Загрузка договоров / допников
|
||
</div>
|
||
<div class="card-body">
|
||
<input type="file" id="fileInput" accept=".docx,.doc,.pdf,.zip" multiple style="margin-bottom:12px;width:100%;">
|
||
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr><th>Имя</th><th style="width:130px;">Изменён</th><th style="width:90px;">Размер</th><th style="width:115px;">Статус</th><th style="width:55px;"></th></tr>
|
||
</thead>
|
||
<tbody id="fileTable"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<button class="btn btn-primary" id="parseBtn" style="width:100%;justify-content:center;margin-top:12px;" disabled>
|
||
<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="modalOverlay" onclick="closeModal(event)">
|
||
<div class="modal" onclick="event.stopPropagation()">
|
||
<div class="modal-header">
|
||
<i data-lucide="file-text" style="width:18px;height:18px;"></i>
|
||
<span id="modalTitle">Информация о файле</span>
|
||
<button class="remove-btn" onclick="closeModal()" style="margin-left:auto;font-size:18px;">×</button>
|
||
</div>
|
||
<div class="modal-body" id="modalBody"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
lucide.createIcons();
|
||
|
||
const fileInput = document.getElementById('fileInput');
|
||
const parseBtn = document.getElementById('parseBtn');
|
||
const fileTable = document.getElementById('fileTable');
|
||
|
||
let fileQueue = [];
|
||
let contractId = null;
|
||
|
||
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';
|
||
}
|
||
|
||
function formatDate(ts) {
|
||
if (!ts) return '—';
|
||
var d = new Date(ts);
|
||
return d.toLocaleDateString('ru-RU') + ' ' + d.toLocaleTimeString('ru-RU', {hour:'2-digit',minute:'2-digit'});
|
||
}
|
||
|
||
function renderTable() {
|
||
if (fileQueue.length === 0) {
|
||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="5">Нет файлов — выберите .docx / .pdf / .zip</td></tr>';
|
||
parseBtn.disabled = true;
|
||
} else {
|
||
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">' + (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>';
|
||
}).join('');
|
||
}
|
||
}
|
||
|
||
function removeFile(index) {
|
||
var f = fileQueue[index];
|
||
if (!f) return;
|
||
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;
|
||
|
||
// ── Чанковая загрузка v2 (30KB + MD5) ────────────────────
|
||
|
||
function uploadFileChunked(file, cid, onProgress) {
|
||
return new Promise(function(resolve, reject) {
|
||
var reader = new FileReader();
|
||
reader.onload = function() {
|
||
var buffer = reader.result;
|
||
var bytes = new Uint8Array(buffer);
|
||
var b64 = btoa(String.fromCharCode.apply(null, bytes));
|
||
var checksum = SparkMD5.ArrayBuffer.hash(buffer);
|
||
var CHUNK = 30 * 1024;
|
||
var totalChunks = Math.ceil(b64.length / CHUNK);
|
||
var uploadId = 'u' + Date.now().toString(36) + Math.random().toString(36).substr(2, 7);
|
||
var done = 0;
|
||
|
||
function sendChunk(i) {
|
||
if (i >= totalChunks) {
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/v2/finalize');
|
||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||
xhr.timeout = 15000;
|
||
xhr.onload = function() {
|
||
try {
|
||
var r = JSON.parse(xhr.responseText);
|
||
if (r.ok) resolve({contract_id: r.contract_id});
|
||
else reject(new Error(r.error));
|
||
} catch(e) { reject(new Error('Bad JSON')); }
|
||
};
|
||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
||
xhr.send(JSON.stringify({upload_id: uploadId, filename: file.name, total: totalChunks, checksum: checksum}));
|
||
return;
|
||
}
|
||
var piece = b64.substring(i * CHUNK, (i + 1) * CHUNK);
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', '/v2/chunk');
|
||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||
xhr.timeout = 10000;
|
||
xhr.onload = function() {
|
||
if (xhr.status === 200) {
|
||
try {
|
||
var r = JSON.parse(xhr.responseText);
|
||
if (!r.ok) { reject(new Error(r.error)); return; }
|
||
done++;
|
||
if (onProgress) onProgress(Math.round(done / totalChunks * 100));
|
||
setTimeout(function() { sendChunk(i + 1); }, 50);
|
||
} 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.send(JSON.stringify({upload_id: uploadId, index: i, total: totalChunks, data: piece}));
|
||
}
|
||
sendChunk(0);
|
||
};
|
||
reader.onerror = function() { reject(new Error('Read error')); };
|
||
reader.readAsArrayBuffer(file);
|
||
});
|
||
}
|
||
|
||
// ── Старая загрузка (одним POST) ─────────────────────────────
|
||
|
||
// ── Выбор файла → сразу загрузка ────────────────────────────
|
||
|
||
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 && !q.isZipChild; })) continue;
|
||
|
||
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 startTime = Date.now();
|
||
|
||
try {
|
||
var resp = await uploadFileChunked(f, contractId, function(pct) {
|
||
fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ ' + pct + '%</span>';
|
||
renderTable();
|
||
});
|
||
// uploadFileChunked10k: прогресс по чанкам
|
||
// но этот же resp приходит только с последнего чанка
|
||
if (resp && resp.contract_id) {
|
||
contractId = resp.contract_id;
|
||
}
|
||
// Если файл был один и он маленький (один чанк), contract_id уже есть
|
||
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + elapsed + 'с</span>';
|
||
fileQueue[rowIdx].uploaded = true;
|
||
renderTable();
|
||
|
||
if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) {
|
||
await showZipContents(f);
|
||
}
|
||
} catch(err) {
|
||
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
|
||
renderTable();
|
||
}
|
||
}
|
||
|
||
fileInput.value = '';
|
||
fileInput.disabled = false;
|
||
parseBtn.disabled = (contractId === null);
|
||
parseBtn.innerHTML = '<i data-lucide="play" style="width:16px;height:16px;"></i> Парсинг';
|
||
lucide.createIcons();
|
||
});
|
||
|
||
async function showZipContents(zipFile) {
|
||
try {
|
||
var zip = await JSZip.loadAsync(zipFile);
|
||
zip.forEach(function(relativePath, zipEntry) {
|
||
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: zsize,
|
||
status: '',
|
||
isZipChild: true,
|
||
zipName: zipFile.name
|
||
});
|
||
}
|
||
});
|
||
renderTable();
|
||
} catch(e) {
|
||
console.log('JSZip error:', e);
|
||
}
|
||
}
|
||
|
||
// ── Парсинг (SSE) ────────────────────────────────────────────
|
||
|
||
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 es = new EventSource('/parse/' + contractId);
|
||
var timerInterval = setInterval(function() {
|
||
var now = Date.now();
|
||
document.querySelectorAll('.proc-timer').forEach(function(el) {
|
||
var start = parseInt(el.dataset.start);
|
||
if (start) el.textContent = '⏳ ' + ((now - start) / 1000).toFixed(1) + 'с';
|
||
});
|
||
}, 500);
|
||
|
||
es.onmessage = function(e) {
|
||
var d = JSON.parse(e.data);
|
||
|
||
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.0с</span>';
|
||
if (d.bytes > 0) {
|
||
var sizeCell = row.querySelector('.num-cell');
|
||
if (sizeCell && sizeCell.textContent.trim() === '—') {
|
||
sizeCell.textContent = formatSize(d.bytes);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
else if (d.type === 'file_done') {
|
||
var row = findRowByName(d.name);
|
||
if (row) {
|
||
row.querySelector('.status-cell').innerHTML = '<span class="status-ok">✓ ' + d.time_s + 'с</span>';
|
||
var idx = findIndexByName(d.name);
|
||
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);
|
||
if (row) {
|
||
row.querySelector('.status-cell').innerHTML = '<span class="status-err">✗ ' + d.error + '</span>';
|
||
}
|
||
}
|
||
|
||
else if (d.type === 'zip_expanded') {
|
||
var row = findRowByName(d.name);
|
||
if (row) {
|
||
row.querySelector('.status-cell').innerHTML = '<span style="color:var(--muted);">↗ ' + d.count + ' файлов</span>';
|
||
}
|
||
}
|
||
|
||
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();
|
||
|
||
var summaryRow = document.createElement('tr');
|
||
summaryRow.className = 'summary-row';
|
||
summaryRow.innerHTML = '<td>✓ Готово: ' + d.files_processed + ' файлов</td>' +
|
||
'<td></td>' +
|
||
'<td class="num-cell">' + formatSize(d.total_bytes) + '</td>' +
|
||
'<td><strong>' + d.total_time_s + 'с</strong></td>' +
|
||
'<td></td>';
|
||
fileTable.appendChild(summaryRow);
|
||
}
|
||
};
|
||
|
||
es.addEventListener('error', function() {
|
||
clearInterval(timerInterval);
|
||
es.close();
|
||
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++) {
|
||
if (fileQueue[i].name === name) {
|
||
return document.getElementById('row_' + i);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function findIndexByName(name) {
|
||
for (var i = 0; i < fileQueue.length; i++) {
|
||
if (fileQueue[i].name === name) return i;
|
||
}
|
||
return -1;
|
||
}
|
||
});
|
||
|
||
// ── Модальное окно ──────────────────────────────────────────
|
||
|
||
function showInfo(index) {
|
||
var f = fileQueue[index];
|
||
if (!f || !f.parseInfo) return;
|
||
var d = f.parseInfo;
|
||
|
||
document.getElementById('modalTitle').textContent = f.name;
|
||
|
||
var rows = [
|
||
['Размер', formatSize(f.size)],
|
||
['В архиве', f.zipName || '—'],
|
||
['Время парсинга', d.time_s + 'с'],
|
||
['Элементов всего', d.elements],
|
||
['Параграфов', d.paragraphs],
|
||
['Таблиц', d.tables],
|
||
['Строк таблиц', d.table_rows],
|
||
['Текст (символов)', d.text_len],
|
||
];
|
||
|
||
var html = rows.map(function(r) {
|
||
return '<div class="kv"><span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span></div>';
|
||
}).join('');
|
||
|
||
if (d.errors && d.errors.length > 0) {
|
||
html += '<div style="margin-top:8px;color:var(--destructive);font-weight:600;">Ошибки парсера:</div>';
|
||
html += '<pre>' + d.errors.join('\n') + '</pre>';
|
||
}
|
||
|
||
if (d.text_preview) {
|
||
html += '<div style="margin-top:10px;font-weight:600;font-size:13px;">Превью текста:</div>';
|
||
html += '<pre>' + d.text_preview + '</pre>';
|
||
}
|
||
|
||
if (d.table_headers && d.table_headers.length > 0) {
|
||
html += '<div style="margin-top:10px;font-weight:600;font-size:13px;">Первые строки таблиц:</div>';
|
||
d.table_headers.forEach(function(hdr, ti) {
|
||
html += '<div style="margin-top:4px;font-size:11px;color:var(--muted);">Таблица ' + (ti+1) + ':</div>';
|
||
html += '<pre>' + hdr.map(function(c) { return c || '(пусто)'; }).join(' | ') + '</pre>';
|
||
});
|
||
}
|
||
|
||
document.getElementById('modalBody').innerHTML = html;
|
||
document.getElementById('modalOverlay').classList.add('open');
|
||
}
|
||
|
||
function closeModal(e) {
|
||
if (e && e.target !== document.getElementById('modalOverlay')) return;
|
||
document.getElementById('modalOverlay').classList.remove('open');
|
||
}
|
||
|
||
window.showInfo = showInfo;
|
||
window.closeModal = closeModal;
|
||
|
||
var style = document.createElement('style');
|
||
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
|
||
document.head.appendChild(style);
|
||
</script>
|
||
</body>
|
||
</html>
|