feat(ui): ZIP-превью — распаковка в браузере, показ содержимого

- JSZip: при выборе ZIP показывает список файлов внутри
- 📦 ZIP-файл → ниже с отступом файлы из архива
- Дубликаты проверяются по всем именам (включая ZIP-содержимое)
- Красная корзина для удаления
This commit is contained in:
2026-06-15 07:34:13 +04:00
parent 9899ede8c6
commit 105459ef1a
+74 -28
View File
@@ -6,6 +6,7 @@
<title>Сверка договоров</title> <title>Сверка договоров</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}"> <link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
<script src="https://unpkg.com/lucide@latest"></script> <script src="https://unpkg.com/lucide@latest"></script>
<script src="https://unpkg.com/jszip@3.10.1/dist/jszip.min.js"></script>
<style> <style>
:root { :root {
--brand-primary: #2563eb; --brand-primary-dark: #1d4ed8; --brand-primary: #2563eb; --brand-primary-dark: #1d4ed8;
@@ -54,6 +55,8 @@
.queue-item .size { color: var(--muted); font-size: 12px; } .queue-item .size { color: var(--muted); font-size: 12px; }
.queue-item .remove { cursor: pointer; color: var(--destructive); padding: 4px; opacity: .7; } .queue-item .remove { cursor: pointer; color: var(--destructive); padding: 4px; opacity: .7; }
.queue-item .remove:hover { opacity: 1; } .queue-item .remove:hover { opacity: 1; }
.queue-zip { font-weight: 600; }
.queue-sub { margin-left: 24px; border-left: 2px solid var(--brand-gray); padding-left: 12px; }
.queue-empty { color: var(--muted); padding: 12px; text-align: center; font-size: 14px; } .queue-empty { color: var(--muted); padding: 12px; text-align: center; font-size: 14px; }
.duplicate-warn { color: #f59e0b; font-size: 12px; margin-left: 8px; } .duplicate-warn { color: #f59e0b; font-size: 12px; margin-left: 8px; }
.table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow: hidden; margin-top: 12px; } .table-wrap { border-radius: 6px; border: 1px solid var(--brand-gray); overflow: hidden; margin-top: 12px; }
@@ -156,38 +159,64 @@
const queueDiv = document.getElementById('queue'); const queueDiv = document.getElementById('queue');
const queueEmpty = document.getElementById('queueEmpty'); const queueEmpty = document.getElementById('queueEmpty');
// fileQueue: [{_type:'file'|'zip', name, size, file, _entries: [...]}]
let fileQueue = []; let fileQueue = [];
// Добавление файлов в очередь при выборе // Все имена файлов (для проверки дубликатов)
visibleInput.addEventListener('change', () => { function allNames() {
const names = [];
fileQueue.forEach(item => {
if (item._type === 'zip' && item._entries) {
item._entries.forEach(e => names.push(e.name));
} else {
names.push(item.name);
}
});
return names;
}
function isDuplicate(name) {
return allNames().includes(name);
}
visibleInput.addEventListener('change', async () => {
let added = 0, skipped = 0; let added = 0, skipped = 0;
for (const f of visibleInput.files) { for (const f of visibleInput.files) {
const dup = fileQueue.find(q => q.name === f.name && q.size === f.size); if (f.name.toLowerCase().endsWith('.zip')) {
if (dup) { // ZIP: читаем содержимое
dup._duplicate = true; try {
skipped++; const zip = await JSZip.loadAsync(f);
const entries = [];
const validExts = ['.docx','.doc','.pdf'];
zip.forEach((path, entry) => {
if (!entry.dir && validExts.some(e => path.toLowerCase().endsWith(e))) {
entries.push({name: path, size: entry._data ? entry._data.uncompressedSize : 0});
}
});
if (entries.length > 0) {
fileQueue.push({_type:'zip', name:f.name, size:f.size, file:f, _entries:entries});
added++;
}
} catch(e) { skipped++; }
} else { } else {
f._duplicate = false; if (isDuplicate(f.name)) { skipped++; continue; }
fileQueue.push(f); fileQueue.push({_type:'file', name:f.name, size:f.size, file:f});
added++; added++;
} }
} }
visibleInput.value = ''; visibleInput.value = '';
if (skipped > 0) { if (skipped > 0) {
queueEmpty.innerHTML = '⚠️ ' + skipped + ' файл(ов) уже в очереди — пропущены'; queueEmpty.innerHTML = '⚠️ ' + skipped + ' файл(ов) уже в очереди — пропущены';
queueEmpty.style.display = 'block'; queueEmpty.style.display = 'block'; queueEmpty.style.color = '#f59e0b';
queueEmpty.style.color = '#f59e0b'; setTimeout(() => { queueEmpty.style.color = 'var(--muted)'; renderQueue(); }, 3000);
setTimeout(() => { queueEmpty.style.color = 'var(--muted)'; if (fileQueue.length === 0) queueEmpty.innerHTML = 'Нет файлов — выберите file.docx / file.pdf / file.zip'; }, 3000);
} }
renderQueue(); renderQueue();
}); });
function removeFile(index) { function removeFile(index) { fileQueue.splice(index, 1); renderQueue(); }
fileQueue.splice(index, 1);
renderQueue();
}
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';
@@ -203,33 +232,50 @@
} else { } else {
queueEmpty.style.display = 'none'; queueEmpty.style.display = 'none';
processBtn.disabled = false; processBtn.disabled = false;
queueDiv.innerHTML = fileQueue.map((f, i) => queueDiv.innerHTML = fileQueue.map((item, i) => {
'<div class="queue-item">' + if (item._type === 'zip') {
'<i data-lucide="file-text" style="width:16px;height:16px;color:var(--muted);"></i>' + let html = '<div class="queue-item queue-zip">' +
'<span class="name">' + f.name + (f._duplicate ? '<span class="duplicate-warn">⚠ дубликат</span>' : '') + '</span>' + '<i data-lucide="archive" style="width:16px;height:16px;color:var(--muted);"></i>' +
'<span class="size">' + formatSize(f.size) + '</span>' + '<span class="name">📦 ' + item.name + '</span>' +
'<span class="size">' + formatSize(item.size) + ' (' + item._entries.length + ' файлов)</span>' +
'<span class="remove" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px;"></i></span>' + '<span class="remove" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px;"></i></span>' +
'</div>' '</div>';
).join(''); if (item._entries) {
html += '<div class="queue-sub">';
item._entries.forEach(e => {
html += '<div class="queue-item">' +
'<i data-lucide="file-text" style="width:14px;height:14px;color:var(--muted);"></i>' +
'<span class="name">' + e.name + '</span>' +
'<span class="size">' + formatSize(e.size) + '</span>' +
'</div>';
});
html += '</div>';
}
return html;
}
return '<div class="queue-item">' +
'<i data-lucide="file-text" style="width:16px;height:16px;color:var(--muted);"></i>' +
'<span class="name">' + item.name + '</span>' +
'<span class="size">' + formatSize(item.size) + '</span>' +
'<span class="remove" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:14px;height:14px;"></i></span>' +
'</div>';
}).join('');
lucide.createIcons(); lucide.createIcons();
} }
} }
// Отправка формы: переносим файлы из очереди в скрытый input
processBtn.addEventListener('click', () => { processBtn.addEventListener('click', () => {
if (fileQueue.length === 0) return; if (fileQueue.length === 0) return;
// Переносим File объекты в скрытый input
const dt = new DataTransfer(); const dt = new DataTransfer();
fileQueue.forEach(f => dt.items.add(f)); fileQueue.forEach(item => {
if (item.file) dt.items.add(item.file);
});
hiddenInput.files = dt.files; hiddenInput.files = dt.files;
processBtn.disabled = true; processBtn.disabled = true;
processBtn.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> Обработка...'; processBtn.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> Обработка...';
document.getElementById('uploadForm').submit(); document.getElementById('uploadForm').submit();
}); });
// Анимация спиннера
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }'; style.textContent = '@keyframes spin { to { transform: rotate(360deg); } }';
document.head.appendChild(style); document.head.appendChild(style);