454 lines
19 KiB
HTML
454 lines
19 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://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: 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); }
|
||
.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; }
|
||
.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;">v1.11</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 = [];
|
||
|
||
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 {
|
||
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><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.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);
|
||
}
|
||
}
|
||
fileQueue.splice(index, 1);
|
||
renderTable();
|
||
}
|
||
|
||
window.removeFile = removeFile;
|
||
|
||
fileInput.addEventListener('change', async function() {
|
||
var newFiles = Array.from(fileInput.files);
|
||
|
||
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;
|
||
|
||
fileQueue.push({
|
||
name: f.name,
|
||
lastModified: f.lastModified,
|
||
size: f.size,
|
||
file: f
|
||
});
|
||
|
||
if (f.type === 'application/zip' || f.name.toLowerCase().endsWith('.zip')) {
|
||
try {
|
||
var zip = await JSZip.loadAsync(f);
|
||
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,
|
||
isZipChild: true,
|
||
zipName: f.name
|
||
});
|
||
}
|
||
});
|
||
} catch(e) {
|
||
console.log('JSZip error:', e);
|
||
}
|
||
}
|
||
}
|
||
|
||
fileInput.value = '';
|
||
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;
|
||
|
||
var realFiles = fileQueue.filter(function(f) { return !f.isZipChild; });
|
||
if (realFiles.length === 0) 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);
|
||
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с</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>';
|
||
// Сохранить детали в fileQueue и показать ℹ
|
||
var idx = findIndexByName(d.name);
|
||
if (idx >= 0) {
|
||
fileQueue[idx].parseInfo = d;
|
||
var infoBtn = document.getElementById('info_' + 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();
|
||
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++) {
|
||
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 = [
|
||
['Тип', f.file ? f.file.type : (f.name.split('.').pop() || '—')],
|
||
['Размер', formatSize(f.size)],
|
||
['В архиве', f.zipName || '—'],
|
||
['Статус', 'parsed'],
|
||
['Время парсинга', 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>
|