673 lines
32 KiB
HTML
673 lines
32 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||
<meta http-equiv="Pragma" content="no-cache">
|
||
<meta http-equiv="Expires" content="0">
|
||
<title>DrHider — обфускация документов</title>
|
||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||
<style>
|
||
:root {
|
||
--brand-primary: #2563eb;
|
||
--brand-gray: #d1d5db;
|
||
--brand-grey-light: #f3f4f6;
|
||
--bg: #f9fafb;
|
||
--card: #ffffff;
|
||
--text: #1a1a1a;
|
||
--muted: #6b7280;
|
||
--red: #ef4444;
|
||
--green: #22c55e;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font: 14px system-ui, sans-serif; color: var(--text); background: var(--bg); min-height: 100vh; }
|
||
.topbar {
|
||
background: var(--card); border-bottom: 1px solid var(--brand-gray);
|
||
padding: 0 16px; height: 48px; display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.topbar img { height: 18px; }
|
||
.topbar .title { font-weight: 600; font-size: 14px; }
|
||
.topbar .ver { font-weight: 400; color: var(--muted); font-size: 12px; }
|
||
.topbar .help {
|
||
margin-left: auto; cursor: pointer; text-decoration: none; user-select: none;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
padding: 5px 14px; border-radius: 6px;
|
||
background: #1d4ed8; color: #fff; font-size: 13px; font-weight: 700; line-height: 1;
|
||
letter-spacing: .06em;
|
||
box-shadow: 0 1px 3px rgba(0,0,0,.25);
|
||
}
|
||
.topbar .help:hover { background: #1e40af; color: #fff; }
|
||
.content { max-width: 820px; margin: 20px auto; padding: 0 12px; }
|
||
.card {
|
||
background: var(--card); border-radius: 12px;
|
||
border: 1px solid var(--brand-gray); box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||
}
|
||
.card-header {
|
||
background: var(--brand-grey-light); padding: 10px 12px;
|
||
font-weight: 600; font-size: 14px; border-bottom: 1px solid var(--brand-gray);
|
||
}
|
||
.card-body { padding: 12px; }
|
||
.sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; line-height: 1.5; }
|
||
.file-input-wrap { margin-bottom: 10px; }
|
||
.file-input-wrap input[type="file"] { width: 100%; font-size: 13px; }
|
||
.table-wrap { border: 1px solid var(--brand-gray); border-radius: 8px; overflow: hidden; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
th {
|
||
background: var(--brand-grey-light); text-transform: uppercase; padding: 6px 8px;
|
||
border-right: 1px solid var(--brand-gray); text-align: left;
|
||
font-weight: 600; font-size: 10px; color: var(--muted);
|
||
}
|
||
td {
|
||
padding: 5px 8px; border-right: 1px solid var(--brand-gray);
|
||
border-bottom: 1px solid var(--brand-gray);
|
||
}
|
||
tr:hover td { background: rgba(37,99,235,.03); }
|
||
.name-cell { max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.num-cell { text-align: right; white-space: nowrap; }
|
||
.empty-row td { color: var(--muted); text-align: center; padding: 20px; }
|
||
.row-over td { background: rgba(220,38,38,.10) !important; color:#c0392b; }
|
||
.row-over .num-cell { color:#c0392b; }
|
||
.row-over .name-cell { color:#c0392b; }
|
||
.row-current td { background: rgba(37,99,235,.12) !important; color:#1d4ed8; font-weight: 600; }
|
||
.grp-row td {
|
||
background: var(--brand-gray); font-size: 12px; font-weight: 700;
|
||
letter-spacing: .03em; text-transform: uppercase; color: var(--muted);
|
||
padding: 4px 8px; border: none;
|
||
}
|
||
.remove-btn {
|
||
cursor: pointer; color: #f87171; background: none; border: none;
|
||
padding: 2px 4px; font-size: 14px; line-height: 1;
|
||
}
|
||
.remove-btn:hover { color: var(--red); }
|
||
body.busy .remove-btn { display: none; }
|
||
.footer-bar {
|
||
margin-top: 10px; display: flex; justify-content: space-between; align-items: center;
|
||
font-size: 12px; color: var(--muted);
|
||
}
|
||
.btn {
|
||
height: 32px; border-radius: 6px; padding: 0 12px; font-size: 13px;
|
||
font-family: inherit; cursor: pointer; border: 1px solid var(--brand-gray);
|
||
background: var(--card); color: var(--text);
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
}
|
||
.btn-primary { background: var(--brand-primary); border-color: var(--brand-primary); color: #fff; }
|
||
.btn-primary:hover { opacity: .9; }
|
||
.btn:disabled { opacity: .4; cursor: not-allowed; }
|
||
.status { margin-top: 10px; padding: 8px 12px; border-radius: 8px; font-size: 13px; display: none; }
|
||
.status.progress { background: #eff6ff; border: 1px solid var(--brand-primary); color: var(--brand-primary); display: block; }
|
||
.status.done { background: #f0fdf4; border: 1px solid var(--green); color: #16a34a; display: block; }
|
||
.status.error { background: #fef2f2; border: 1px solid var(--red); color: #991b1b; display: block; }
|
||
.dl-btns { display: none; gap: 8px; margin-top: 8px; }
|
||
.dl-btns.show { display: flex; }
|
||
.stats-block {
|
||
display: none; margin-top: 12px; padding: 16px;
|
||
background: linear-gradient(135deg, #f0fdf4, #ecfdf5);
|
||
border: 2px solid var(--green); border-radius: 12px;
|
||
}
|
||
.stats-block.show { display: block; }
|
||
.stats-title { font-size: 16px; font-weight: 700; color: #14532d; margin-bottom: 10px; }
|
||
.stats-row { display: flex; flex-wrap: wrap; gap: 10px; }
|
||
.stat-item {
|
||
flex: 1; min-width: 130px; background: #fff; border: 1px solid #bbf7d0;
|
||
border-radius: 10px; padding: 10px 12px; text-align: center;
|
||
}
|
||
.stat-label { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 4px; }
|
||
.stat-value { font-size: 24px; font-weight: 800; color: #166534; }
|
||
.live-block {
|
||
display: none; margin-top: 12px; padding: 16px;
|
||
background: linear-gradient(135deg, #eff6ff, #dbeafe);
|
||
border: 2px solid var(--brand-primary); border-radius: 12px;
|
||
}
|
||
.live-block.show { display: block; }
|
||
.live-title { font-size: 18px; font-weight: 800; color: #1e40af; margin-bottom: 6px; }
|
||
.live-file { font-size: 15px; color: #1e3a8a; margin-bottom: 10px; word-break: break-word; }
|
||
.live-timer { font-size: 40px; font-weight: 800; color: #1d4ed8; line-height: 1.1; }
|
||
.live-llm {
|
||
display: none; margin-top: 8px; padding: 8px 12px; border-radius: 8px;
|
||
background: #fef3c7; border: 1px solid #f59e0b; color: #92400e;
|
||
font-size: 18px; font-weight: 700; width: fit-content;
|
||
}
|
||
.live-llm.show { display: inline-block; }
|
||
.live-eta { margin-top: 8px; font-size: 14px; color: #1d4ed8; font-weight: 600; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="topbar">
|
||
<img src="/static/logo.svg" alt="Nubes">
|
||
<span class="title">DrHider</span>
|
||
<span class="ver">v{{ version }}</span>
|
||
<span class="help" onclick="document.getElementById('helpModal').style.display='flex'">HELP</span>
|
||
</div>
|
||
<div class="content">
|
||
<div class="card">
|
||
<div class="card-header">Обфускация документов</div>
|
||
<div class="card-body">
|
||
<p class="sub">
|
||
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP с обезличенными копиями.<br>
|
||
CSV с таблицей замен скачивается отдельно.<br>
|
||
<span style="color:#c0392b;">Ограничения: один файл не более 50 МБ, суммарно не более 500 МБ. Файлы сверх лимитов помечаются красным (статус «пропущен») и не участвуют в обфускации. Пустые, повреждённые файлы и сканы без текстового слоя получают статус «не извлечён» — они тоже не попадают в результат.</span><br>
|
||
<span style="color:#7d3c98;">⏱ Время обработки каждого файла — ориентировочное (обновляется по факту).</span>
|
||
</p>
|
||
<div class="file-input-wrap">
|
||
<input type="file" id="fileInput" multiple accept=".doc,.docx,.pdf,.txt,.zip">
|
||
<button type="button" class="btn" id="folderBtn" onclick="document.getElementById('folderInput').click()">📁 Выбрать папку</button>
|
||
<input type="file" id="folderInput" webkitdirectory multiple style="display:none">
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Имя</th>
|
||
<th style="width:80px;">Размер</th>
|
||
<th style="width:80px;">Статус</th>
|
||
<th style="width:32px;"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="fileList">
|
||
<tr class="empty-row"><td colspan="4">Нет выбранных файлов</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="footer-bar">
|
||
<span id="fileCount">0 файлов</span>
|
||
<button class="btn" id="cancelBtn" style="display:none" onclick="confirmCancel()">⏹ Прервать</button>
|
||
<button class="btn" id="newSessionBtn" style="display:none" onclick="resetAll()">🔄 Новая сессия</button>
|
||
<button class="btn btn-primary" id="uploadBtn" disabled onclick="uploadFiles()">🛡️ Обфусцировать</button>
|
||
</div>
|
||
<div class="status" id="status"></div>
|
||
<div class="dl-btns" id="dlBtns">
|
||
<button class="btn btn-primary" id="dlZip" onclick="downloadZip()">📦 Скачать ZIP</button>
|
||
<button class="btn" id="dlCsv" onclick="downloadCsv()">📋 Скачать CSV</button>
|
||
</div>
|
||
<div class="live-block" id="liveBlock">
|
||
<div class="live-title">⏱ Обработка…</div>
|
||
<div class="live-file" id="liveFile">—</div>
|
||
<div class="live-timer" id="liveTimer">0.0 с</div>
|
||
<div class="live-llm" id="liveLlm">🤖 ИИ обрабатывает… <span id="liveLlmTime">0.0 с</span></div>
|
||
<div class="live-eta" id="liveEta"></div>
|
||
</div>
|
||
<div class="stats-block" id="statsBlock">
|
||
<div class="stats-title">📊 Итоги обработки</div>
|
||
<div class="stats-row">
|
||
<div class="stat-item">
|
||
<div class="stat-label">Общее время</div>
|
||
<div class="stat-value" id="stTotalTime">—</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-label">Из них ИИ</div>
|
||
<div class="stat-value" id="stLlmTime">—</div>
|
||
</div>
|
||
<div class="stat-item">
|
||
<div class="stat-label">Токены ИИ</div>
|
||
<div class="stat-value" id="stLlmTokens">—</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<script type="module">
|
||
import { initUploadTable } from '/upload/table/init_upload_table.js';
|
||
import { uploadViaVM } from '/upload/upload/upload_via_vm.js';
|
||
import { fs } from '/upload/table/fs.js';
|
||
|
||
const fi = document.getElementById('fileInput');
|
||
const folderInput = document.getElementById('folderInput');
|
||
const DOC_EXTS = ['.pdf', '.doc', '.docx', '.txt', '.md']; // документы (из папки/архивов)
|
||
const fl = document.getElementById('fileList');
|
||
const fc = document.getElementById('fileCount');
|
||
const ub = document.getElementById('uploadBtn');
|
||
const st = document.getElementById('status');
|
||
const db = document.getElementById('dlBtns');
|
||
const MAX_FILE_BYTES = 50 * 1024 * 1024; // 50 МБ на один файл
|
||
const MAX_SESSION_BYTES = 500 * 1024 * 1024; // 500 МБ суммарно на сессию
|
||
const VM_UPLOAD_URL = 'https://contracts.kube5s.ru/drhider-upload/'; // ВМ-буфер: PUT больших файлов (шлюз кластера их рвёт)
|
||
// Тест-режим: открыть страницу с ?upload-only=1 → только загрузка, без обработки
|
||
const TEST_UPLOAD_ONLY = new URLSearchParams(location.search).get('upload-only') === '1';
|
||
let currentSid = '';
|
||
let activeES = null; // активный EventSource
|
||
let activeXHR = null; // активный XHR
|
||
|
||
// ── Состояние обработки (3-секционная таблица: готово / текущий / ожидают) ──
|
||
let procPhase = 'idle'; // 'idle' | 'processing'
|
||
let procState = {}; // sfIdx -> {st, elapsed, eta, est, chars, t0}
|
||
let procNameIdx = {}; // имя (с бэка) -> sfIdx
|
||
let procExtractDone = false; // true после extract_done (далее done = реальная готовность)
|
||
let procExtractRate = null; // измеренная скорость извлечения (сек/МБ) для оценок ожидающих
|
||
let procRefresh = null; // setInterval перерисовки таблицы
|
||
let ptimer = null, liveRefresh = null; // таймеры статуса и live-блока
|
||
let sessionDone = false; // результат готов — сессия заморожена до «Новая сессия»
|
||
|
||
// Слой 1: выбор файлов/папки/архива (состояние внутри модуля)
|
||
const table = initUploadTable({
|
||
allowedExt: DOC_EXTS,
|
||
maxFileBytes: MAX_FILE_BYTES,
|
||
maxSessionBytes: MAX_SESSION_BYTES,
|
||
estMbSec: 12,
|
||
}, {
|
||
fileInput: fi,
|
||
folderInput: folderInput,
|
||
tableBody: fl,
|
||
countEl: fc,
|
||
uploadBtnEl: ub,
|
||
onStatus(cls, text) {
|
||
st.className = cls ? 'status ' + cls : '';
|
||
st.textContent = text;
|
||
},
|
||
});
|
||
|
||
// Связать состояние обработки с рендером модуля (renderProcTable читает state.proc)
|
||
table.state.proc = {};
|
||
function syncProcCtx() {
|
||
table.state.proc.phase = procPhase;
|
||
table.state.proc.procState = procState;
|
||
table.state.proc.procExtractRate = procExtractRate;
|
||
}
|
||
syncProcCtx();
|
||
|
||
function resetAll() {
|
||
if (activeES) { activeES.close(); activeES = null; }
|
||
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
||
if (ptimer) { clearInterval(ptimer); ptimer = null; }
|
||
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
|
||
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
|
||
currentSid = '';
|
||
procPhase = 'idle';
|
||
procState = {};
|
||
procNameIdx = {};
|
||
procExtractDone = false;
|
||
procExtractRate = null;
|
||
sessionDone = false;
|
||
setBusy(false);
|
||
table.clear();
|
||
syncProcCtx();
|
||
document.getElementById('newSessionBtn').style.display = 'none';
|
||
document.getElementById('cancelBtn').style.display = 'none';
|
||
st.className = '';
|
||
st.textContent = '';
|
||
db.classList.remove('show');
|
||
document.getElementById('statsBlock').classList.remove('show');
|
||
document.getElementById('liveBlock').classList.remove('show');
|
||
// Кнопку «Обфусцировать» НЕ перекрываем: table.clear() уже поставил disabled при пустом списке
|
||
}
|
||
|
||
// При F5 / закрытии вкладки — обрубить всё
|
||
window.addEventListener('beforeunload', () => resetAll());
|
||
|
||
function fmtSec(s) {
|
||
s = Math.max(0, Math.round(s));
|
||
return s >= 60 ? Math.floor(s / 60) + 'м ' + (s % 60) + 'с' : s + 'с';
|
||
}
|
||
|
||
function confirmCancel() {
|
||
document.getElementById('cancelModal').style.display = 'flex';
|
||
}
|
||
|
||
function doCancel() {
|
||
document.getElementById('cancelModal').style.display = 'none';
|
||
if (!currentSid) return;
|
||
fetch('/api/cancel/' + currentSid, { method: 'POST' }).catch(() => {});
|
||
st.textContent = '⏹ Остановка… завершаем текущий файл';
|
||
}
|
||
|
||
function finishProcUI() {
|
||
if (activeES) { activeES.close(); activeES = null; }
|
||
if (ptimer) { clearInterval(ptimer); ptimer = null; }
|
||
if (liveRefresh) { clearInterval(liveRefresh); liveRefresh = null; }
|
||
if (procRefresh) { clearInterval(procRefresh); procRefresh = null; }
|
||
document.getElementById('liveBlock').classList.remove('show');
|
||
document.getElementById('cancelBtn').style.display = 'none';
|
||
procPhase = 'idle';
|
||
syncProcCtx();
|
||
table.render();
|
||
}
|
||
|
||
function setBusy(b) {
|
||
table.setBusy(b);
|
||
document.body.classList.toggle('busy', b); // скрывает кнопки «✕» в таблице
|
||
}
|
||
|
||
function ss(idx, h) { table.setStatus(idx, h); }
|
||
|
||
async function uploadFiles() {
|
||
if (table.state.busy) return; // уже идёт загрузка/обработка
|
||
if (table.state.files.length === 0) return;
|
||
setBusy(true);
|
||
sessionDone = false;
|
||
document.getElementById('newSessionBtn').style.display = 'none';
|
||
// Только учитываемые файлы идут на загрузку/обфускацию (сверхлимитные пропускаем)
|
||
const files = table.state.files.slice();
|
||
const toSend = [];
|
||
const sendIdx = []; // sendIdx[k] = индекс в files для отправленного файла
|
||
for (let i = 0; i < files.length; i++) {
|
||
if (!table.state.overNames.has(files[i].name)) { toSend.push(files[i]); sendIdx.push(i); }
|
||
else { ss(i, '<span style="color:#c0392b;">пропущен (лимит)</span>'); }
|
||
}
|
||
// Обрубить всё что могло остаться от предыдущего раза
|
||
if (activeES) { activeES.close(); activeES = null; }
|
||
if (activeXHR) { activeXHR.abort(); activeXHR = null; }
|
||
currentSid = '';
|
||
st.className = '';
|
||
st.textContent = '';
|
||
db.classList.remove('show');
|
||
document.getElementById('statsBlock').classList.remove('show');
|
||
document.getElementById('liveBlock').classList.remove('show');
|
||
ub.disabled = true;
|
||
const total = toSend.length;
|
||
if (total === 0) { st.className = 'status error'; st.textContent = 'Нет файлов для обфускации (все превышают лимит).'; setBusy(false); ub.disabled = false; return; }
|
||
|
||
// Фаза 1+1b: загрузка через ВМ-буфер (слой 2 модуля: PUT + POST /api/upload_refs)
|
||
let uploadedCount = 0;
|
||
const res = await uploadViaVM(toSend, VM_UPLOAD_URL, {
|
||
session: currentSid,
|
||
onStatus(k, html) { ss(sendIdx[k], html); },
|
||
onUploadStatus(text) { st.className = 'status progress'; st.textContent = text; },
|
||
});
|
||
if (!res.ok) {
|
||
st.className = 'status error';
|
||
st.textContent = res.error;
|
||
setBusy(false);
|
||
ub.disabled = false;
|
||
return;
|
||
}
|
||
currentSid = res.session;
|
||
uploadedCount = res.count;
|
||
|
||
// Тест-режим (?upload-only=1): только загрузка, без обработки
|
||
if (TEST_UPLOAD_ONLY) {
|
||
st.className = 'status done';
|
||
st.textContent = '✅ Загружено ' + uploadedCount + ' файлов (тест: обработка пропущена). Сессия: ' + currentSid;
|
||
setBusy(false);
|
||
ub.disabled = false;
|
||
return;
|
||
}
|
||
|
||
// Фаза 2: обработка (SSE — прогресс по каждому файлу)
|
||
// Сброс загрузочных статусов — теперь этап обработки (только отправленные)
|
||
for (let k = 0; k < total; k++) ss(sendIdx[k], '<span style="color:#2563eb">⏳</span>');
|
||
// Инициализация 3-секционной таблицы (готово / текущий / ожидают)
|
||
procPhase = 'processing';
|
||
procState = {};
|
||
procNameIdx = {};
|
||
procExtractDone = false;
|
||
procExtractRate = null;
|
||
for (let k = 0; k < total; k++) {
|
||
procState[sendIdx[k]] = { st: 'pending', elapsed: 0, eta: null, est: null, chars: 0, t0: 0 };
|
||
}
|
||
syncProcCtx();
|
||
document.getElementById('cancelBtn').style.display = 'inline-block';
|
||
if (procRefresh) clearInterval(procRefresh);
|
||
procRefresh = setInterval(() => table.render(), 1000); // тикающий рендер (elapsed текущего файла)
|
||
table.render();
|
||
const fileTimers = {}; // idx -> performance.now()
|
||
const fileIntervals = {}; // idx -> setInterval id
|
||
st.className = 'status progress';
|
||
st.textContent = 'Обработка (этап 2/2)…';
|
||
const t0 = performance.now();
|
||
ptimer = setInterval(() => {
|
||
const sec = Math.round((performance.now() - t0) / 1000);
|
||
st.textContent = 'Обработка (этап 2/2)… ' + sec + 'с';
|
||
}, 1000);
|
||
|
||
// Крупный live-блок «Идёт обработка» с тикающим таймером
|
||
const liveBlock = document.getElementById('liveBlock');
|
||
const liveTimerEl = document.getElementById('liveTimer');
|
||
const liveFileEl = document.getElementById('liveFile');
|
||
const liveLlm = document.getElementById('liveLlm');
|
||
const liveLlmTime = document.getElementById('liveLlmTime');
|
||
const liveEta = document.getElementById('liveEta');
|
||
let currentLiveFile = ''; // последнее имя файла из start
|
||
liveLlm.classList.remove('show');
|
||
liveEta.textContent = '';
|
||
liveTimerEl.textContent = '0.0 с';
|
||
liveFileEl.textContent = 'Подготовка…';
|
||
liveBlock.classList.add('show');
|
||
liveRefresh = setInterval(() => {
|
||
const sec = ((performance.now() - t0) / 1000).toFixed(1);
|
||
liveTimerEl.textContent = sec + ' с';
|
||
}, 200);
|
||
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
activeES = new EventSource('/api/process_stream/' + currentSid);
|
||
activeES.addEventListener('start', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
const idx = sendIdx[d.idx]; // индекс в files для вывода прогресса
|
||
procNameIdx[d.name] = idx;
|
||
const p = procState[idx];
|
||
if (p) { p.st = 'pending'; p.elapsed = 0; }
|
||
// Этап извлечения: текущий файл = обрабатываемый сейчас (тикер в таблице).
|
||
// Предыдущий «текущий» демотируем и замеряем его скорость (сек/МБ) для оценок.
|
||
if (!procExtractDone) {
|
||
let prevIdx = null;
|
||
for (const i in procState) {
|
||
if (procState[i].st === 'current') { prevIdx = Number(i); procState[i].st = 'pending'; }
|
||
}
|
||
if (prevIdx != null && table.state.files[prevIdx] && procState[prevIdx].t0) {
|
||
const el = (performance.now() - procState[prevIdx].t0) / 1000;
|
||
const szMB = table.state.files[prevIdx].size / 1048576;
|
||
if (el > 0 && szMB > 0) procExtractRate = el / szMB;
|
||
}
|
||
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
|
||
}
|
||
syncProcCtx();
|
||
const f = table.state.files[idx];
|
||
const sz = f ? fs(f.size) : '';
|
||
currentLiveFile = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name + (sz ? ' (' + sz + ')' : '');
|
||
liveFileEl.textContent = currentLiveFile;
|
||
});
|
||
activeES.addEventListener('extract_done', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
procExtractDone = true;
|
||
// Этап извлечения завершён — снимаем подсветку «текущего» извлечения
|
||
for (const i in procState) {
|
||
if (procState[i].st === 'current') procState[i].st = 'pending';
|
||
}
|
||
// per-file символы -> оценка времени для ожидающих файлов
|
||
if (d.per_file) {
|
||
for (const nm in d.per_file) {
|
||
const idx = procNameIdx[nm];
|
||
if (idx != null && procState[idx]) procState[idx].chars = d.per_file[nm];
|
||
}
|
||
}
|
||
});
|
||
activeES.addEventListener('file_start', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
const idx = sendIdx[d.idx];
|
||
const p = procState[idx];
|
||
if (p) { p.st = 'current'; p.t0 = performance.now(); p.eta = null; }
|
||
});
|
||
activeES.addEventListener('file_chunk', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
const idx = sendIdx[d.idx];
|
||
const p = procState[idx];
|
||
if (p && typeof d.eta_sec === 'number') p.eta = d.eta_sec;
|
||
liveFileEl.textContent = 'Файл ' + (d.idx + 1) + '/' + total + ': ' + d.name +
|
||
(typeof d.eta_sec === 'number' ? ' — осталось ~' + fmtSec(d.eta_sec) : '');
|
||
});
|
||
activeES.addEventListener('file_done', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
const idx = sendIdx[d.idx];
|
||
const p = procState[idx];
|
||
if (p) { p.st = 'analyzed'; }
|
||
});
|
||
activeES.addEventListener('llm', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
if (d.active) {
|
||
liveLlmTime.textContent = d.elapsed + ' с';
|
||
liveLlm.classList.add('show');
|
||
liveFileEl.textContent = '🤖 ИИ анализирует все файлы…';
|
||
if (typeof d.eta_sec === 'number' && d.eta_sec >= 0) liveEta.textContent = '⏳ осталось ~' + fmtSec(d.eta_sec);
|
||
else liveEta.textContent = '';
|
||
} else {
|
||
liveLlm.classList.remove('show');
|
||
if (currentLiveFile) liveFileEl.textContent = currentLiveFile;
|
||
}
|
||
});
|
||
activeES.addEventListener('done', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
const idx = sendIdx[d.idx];
|
||
if (!procExtractDone) {
|
||
// done на этапе извлечения = битый/пропущенный файл
|
||
const p0 = procState[idx];
|
||
if (p0) p0.st = 'skipped';
|
||
return;
|
||
}
|
||
// Время на КОНКРЕТНЫЙ файл приходит с бэка (extract_text + замена, без общего LLM)
|
||
const sec = (typeof d.elapsed === 'number' && d.elapsed > 0)
|
||
? d.elapsed.toFixed(1)
|
||
: ((performance.now() - (fileTimers[idx] || performance.now())) / 1000).toFixed(1);
|
||
const p = procState[idx];
|
||
if (p) { p.st = 'done'; p.elapsed = d.elapsed > 0 ? d.elapsed : parseFloat(sec); }
|
||
else procState[idx] = { st: 'done', elapsed: parseFloat(sec), eta: null, est: null, chars: 0, t0: 0 };
|
||
});
|
||
activeES.addEventListener('cancelled', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
finishProcUI();
|
||
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
|
||
st.className = 'status done';
|
||
st.textContent = '⏹ Остановлено. Сохранено ' + d.saved + ' из ' + d.total + ' файлов + таблица замен (общее ' + totalSec + 'с)';
|
||
const sb = document.getElementById('statsBlock');
|
||
document.getElementById('stTotalTime').textContent = totalSec + ' с';
|
||
if (d.llm_sec > 0) {
|
||
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
|
||
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
|
||
} else {
|
||
document.getElementById('stLlmTime').textContent = '—';
|
||
document.getElementById('stLlmTokens').textContent = '—';
|
||
}
|
||
sb.classList.add('show');
|
||
db.classList.add('show');
|
||
// Заморозка сессии: результат готов (частичный), список файлов больше не меняем
|
||
sessionDone = true;
|
||
ub.disabled = true;
|
||
fi.disabled = true;
|
||
document.getElementById('newSessionBtn').style.display = 'inline-block';
|
||
resolve();
|
||
});
|
||
activeES.addEventListener('complete', function(e) {
|
||
finishProcUI();
|
||
const d = JSON.parse(e.data);
|
||
const totalSec = ((performance.now() - t0) / 1000).toFixed(1);
|
||
st.className = 'status done';
|
||
st.textContent = '✅ Обработано ' + d.total + ' файлов: общее ' + totalSec + 'с, из них ИИ ' + (d.llm_sec > 0 ? d.llm_sec + 'с' : '0с');
|
||
// Крупный блок статистики
|
||
const sb = document.getElementById('statsBlock');
|
||
document.getElementById('stTotalTime').textContent = totalSec + ' с';
|
||
if (d.llm_sec > 0) {
|
||
document.getElementById('stLlmTime').textContent = d.llm_sec + ' с';
|
||
document.getElementById('stLlmTokens').textContent = d.tokens > 0 ? d.tokens : '—';
|
||
} else {
|
||
document.getElementById('stLlmTime').textContent = '—';
|
||
document.getElementById('stLlmTokens').textContent = '—';
|
||
}
|
||
sb.classList.add('show');
|
||
db.classList.add('show');
|
||
// Заморозка сессии: результат готов, список файлов больше не меняем
|
||
sessionDone = true;
|
||
ub.disabled = true;
|
||
fi.disabled = true;
|
||
document.getElementById('newSessionBtn').style.display = 'inline-block';
|
||
resolve();
|
||
});
|
||
activeES.addEventListener('proc_error', function(e) {
|
||
const d = JSON.parse(e.data);
|
||
finishProcUI();
|
||
setBusy(false);
|
||
st.className = 'status error';
|
||
st.textContent = 'Ошибка обработки: ' + (d.error || 'неизвестная ошибка');
|
||
resolve();
|
||
});
|
||
activeES.onerror = function() {
|
||
finishProcUI();
|
||
reject(new Error('SSE connection failed'));
|
||
};
|
||
});
|
||
} catch (err) {
|
||
finishProcUI();
|
||
setBusy(false);
|
||
st.className = 'status error';
|
||
st.textContent = 'Ошибка: ' + err.message;
|
||
}
|
||
if (!sessionDone) ub.disabled = false;
|
||
}
|
||
|
||
function downloadZip() {
|
||
if (!currentSid) return;
|
||
fetch('/api/download/' + currentSid)
|
||
.then(r => {
|
||
const disp = r.headers.get('Content-Disposition');
|
||
const m = disp && disp.match(/filename="?(.+?)"?$/);
|
||
return Promise.all([r.blob(), m ? m[1] : 'drhider.zip']);
|
||
})
|
||
.then(([blob, fname]) => {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = fname;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(a.href);
|
||
});
|
||
}
|
||
|
||
function downloadCsv() {
|
||
if (!currentSid) return;
|
||
fetch('/api/csv/' + currentSid)
|
||
.then(r => {
|
||
const disp = r.headers.get('Content-Disposition');
|
||
const m = disp && disp.match(/filename="?(.+?)"?$/);
|
||
return Promise.all([r.blob(), m ? m[1] : 'mapping.csv']);
|
||
})
|
||
.then(([blob, fname]) => {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = fname;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(a.href);
|
||
});
|
||
}
|
||
|
||
// Экспонировать в window для onclick-атрибутов в HTML
|
||
window.uploadFiles = uploadFiles;
|
||
window.resetAll = resetAll;
|
||
window.confirmCancel = confirmCancel;
|
||
window.doCancel = doCancel;
|
||
window.downloadZip = downloadZip;
|
||
window.downloadCsv = downloadCsv;
|
||
|
||
// Инициализация: пустой список + прогрев upstream-соединения
|
||
table.render();
|
||
fetch('/health').catch(() => {});
|
||
</script>
|
||
<div id="helpModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'">
|
||
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:420px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
|
||
<h3 style="margin-bottom:12px">DrHider — обфускация документов</h3>
|
||
<p style="font-size:13px;color:var(--muted);line-height:1.6">
|
||
Загрузите документы (.docx, .doc, .pdf, .txt, .md, .zip) — получите ZIP-архив с обезличенными копиями.
|
||
Персональные данные, реквизиты компаний, телефоны, email и другие приватные сведения заменяются на обезличенные коды.
|
||
CSV-файл с таблицей соответствия оригинал→замена скачивается отдельно.
|
||
</p>
|
||
<button class="btn" style="margin-top:12px;width:100%" onclick="document.getElementById('helpModal').style.display='none'">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
<div id="cancelModal" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:999;justify-content:center;align-items:center" onclick="this.style.display='none'">
|
||
<div style="background:var(--card);border-radius:12px;padding:24px;max-width:440px;box-shadow:0 4px 24px rgba(0,0,0,.15)" onclick="event.stopPropagation()">
|
||
<h3 style="margin-bottom:12px">⏹ Остановить обработку?</h3>
|
||
<p style="font-size:13px;color:var(--muted);line-height:1.6" id="cancelModalText">
|
||
Будут сохранены: полностью обработанные файлы и таблица замен.
|
||
Необработанные файлы в результат не попадут.
|
||
Текущий анализ будет доведён до конца.
|
||
</p>
|
||
<div style="margin-top:16px;display:flex;gap:8px;justify-content:flex-end">
|
||
<button class="btn" onclick="document.getElementById('cancelModal').style.display='none'">Отмена</button>
|
||
<button class="btn btn-primary" onclick="doCancel()">Остановить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|