Files
contracts-app/site/templates/upload.html
T

471 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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.8</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);
// Чанковая конвертация в base64 — apply не тянет >125K аргументов
var b64Chunks = [];
var B64_CHUNK = 0x8000; // 32768 bytes
for (var bi = 0; bi < bytes.length; bi += B64_CHUNK) {
b64Chunks.push(String.fromCharCode.apply(null, bytes.subarray(bi, bi + B64_CHUNK)));
}
var b64 = btoa(b64Chunks.join(''));
var checksum = SparkMD5.ArrayBuffer.hash(buffer);
var CHUNK = 60 * 1024; // ближе к лимиту 64KB → меньше чанков
var totalChunks = Math.ceil(b64.length / CHUNK);
var uploadId = 'u' + Date.now().toString(36) + Math.random().toString(36).substr(2, 7);
var done = 0;
console.log('uploadFileChunked: ' + file.name + ' b64=' + b64.length + ' chunks=' + totalChunks + ' uploadId=' + uploadId);
function fetchWithRetry(url, body, retries) {
retries = retries || 3;
return fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
}).catch(function(e) {
if (retries > 0) {
console.log('retry ' + url + ' (' + retries + ' left): ' + e.message);
return new Promise(function(r) { setTimeout(r, 1000); }).then(function() {
return fetchWithRetry(url, body, retries - 1);
});
}
throw e;
});
}
function sendChunk(i) {
if (i >= totalChunks) {
console.log('finalize: ' + uploadId);
fetchWithRetry('/v2/finalize', {upload_id: uploadId, filename: file.name, total: totalChunks, checksum: checksum})
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
if (data.ok) resolve({contract_id: data.contract_id});
else reject(new Error(data.error));
}).catch(function(e) { reject(e); });
return;
}
var piece = b64.substring(i * CHUNK, (i + 1) * CHUNK);
console.log('chunk ' + i + '/' + totalChunks + ' send ' + piece.length + ' bytes');
fetchWithRetry('/v2/chunk', {upload_id: uploadId, index: i, total: totalChunks, data: piece})
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}).then(function(data) {
if (!data.ok) { reject(new Error(data.error)); return; }
done++;
if (onProgress) onProgress({pct: Math.round(done / totalChunks * 100), done: done, total: totalChunks});
setTimeout(function() { sendChunk(i + 1); }, 3000);
}).catch(function(e) { reject(e); });
}
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(info) {
fileQueue[rowIdx].status = '<span style="color:var(--muted);">↑ чанк ' + info.done + '/' + info.total + ' (' + info.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>