fix: site/ with __init__.py — proper Python package, no stdlib conflict
Deploy contracts-flask / validate (push) Successful in 0s
Deploy contracts-flask / validate (push) Successful in 0s
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
// ⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔
|
||||
// Contracts App v2.0 — всё на Flask, ВМ больше нет
|
||||
var VM_API = '';
|
||||
var UPLOAD_URL = '/upload';
|
||||
var CONVERT_URL = '/convert-doc';
|
||||
var UNZIP_URL = '/unzip-upload';
|
||||
// SITE_URL удалён (Фаза 4) — не использовался
|
||||
|
||||
var fileInput = document.getElementById('fileInput');
|
||||
var fileTable = document.getElementById('fileTable');
|
||||
// state.files — теперь в state.js (Фаза 0: state + render)
|
||||
// state.contractId — теперь в state.js (Фаза 0: state + render)
|
||||
// state.batchId — теперь в state.js (Фаза 0: state + render)
|
||||
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
|
||||
|
||||
// Автоочистка старых записей при загрузке страницы
|
||||
(async function cleanup() {
|
||||
try {
|
||||
await fetch(VM_API + '/api/cleanup', { method: 'POST' });
|
||||
} catch(e) { /* ignore */ }
|
||||
})();
|
||||
|
||||
/**
|
||||
* render(state) — Главная функция рендеринга (Фаза 0, decoupling-final-plan.md).
|
||||
*
|
||||
* ПАТТЕРН: action → мутация state → render(state)
|
||||
*
|
||||
* Фаза 0: вызывает существующий renderTable().
|
||||
* renderTable читает state.files напрямую (state — глобальный объект).
|
||||
* Фаза 1+: будет вызывать renderFiles(state), renderGroups(state), renderStepper(state) и т.д.
|
||||
*
|
||||
* ПРАВИЛО: любое изменение state ДОЛЖНО завершаться вызовом render(state).
|
||||
* Никакой прямой манипуляции DOM в обход render().
|
||||
* console.log(state) показывает ВСЁ состояние в любой момент.
|
||||
*/
|
||||
function render(state) {
|
||||
renderFiles(state);
|
||||
renderStepper(state);
|
||||
}
|
||||
|
||||
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
|
||||
// → вынесены в files.js (Фаза 1)
|
||||
|
||||
// ── Pipeline stepper (Фаза 4: state-driven) ──────────────────
|
||||
|
||||
/**
|
||||
* renderStepper(state) — Рендер степпера из state.ui.steps (Фаза 4).
|
||||
*
|
||||
* Читает state.ui.steps.{upload,classify,groups,compare}, обновляет DOM.
|
||||
* Значения: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён).
|
||||
*/
|
||||
function renderStepper(state) {
|
||||
var steps = state.ui.steps;
|
||||
var map = { upload: 'stepUpload', classify: 'stepClassify', groups: 'stepGroups', compare: 'stepCompare' };
|
||||
var colors = { '○': 'var(--muted)', '⏳': 'var(--brand-primary)', '✓': 'var(--green)' };
|
||||
Object.keys(map).forEach(function(key) {
|
||||
var el = document.getElementById(map[key]);
|
||||
if (el) {
|
||||
el.textContent = steps[key] + ' ' + {upload:'Загрузка',classify:'Классификация',groups:'Группировка',compare:'Сравнение'}[key];
|
||||
el.style.color = colors[steps[key]] || 'var(--muted)';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* stepDone(id) — Отметить шаг как завершённый (Фаза 4).
|
||||
* Мутирует state.ui.steps, вызывает renderStepper.
|
||||
*/
|
||||
function stepDone(id) {
|
||||
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
|
||||
if (key) { state.ui.steps[key] = '✓'; renderStepper(state); }
|
||||
}
|
||||
|
||||
/**
|
||||
* stepActive(id) — Отметить шаг как активный (Фаза 4).
|
||||
* Мутирует state.ui.steps, вызывает renderStepper.
|
||||
*/
|
||||
function stepActive(id) {
|
||||
var key = { stepUpload: 'upload', stepClassify: 'classify', stepGroups: 'groups', stepCompare: 'compare' }[id];
|
||||
if (key) { state.ui.steps[key] = '⏳'; renderStepper(state); }
|
||||
}
|
||||
|
||||
/**
|
||||
* resetStepper(fromId) — Сбросить шаги начиная с fromId (Фаза 4).
|
||||
* Все шаги после (и включая) fromId получают '○'.
|
||||
* Мутирует state.ui.steps, вызывает renderStepper.
|
||||
*/
|
||||
function resetStepper(fromId) {
|
||||
var order = ['stepUpload','stepClassify','stepGroups','stepCompare'];
|
||||
var keys = ['upload','classify','groups','compare'];
|
||||
var reset = false;
|
||||
order.forEach(function(id, i) {
|
||||
if (id === fromId) reset = true;
|
||||
if (reset) { state.ui.steps[keys[i]] = '○'; }
|
||||
});
|
||||
renderStepper(state);
|
||||
}
|
||||
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
||||
// вынесены в app_utils.js для облегчения анализа и отладки.
|
||||
// statusToHTML, renderFiles → files.js (Фаза 1)
|
||||
|
||||
// ── Раскрыть/скрыть результат классификации под строкой файла ──
|
||||
// toggleClassifyDetail → files.js (Фаза 1)
|
||||
|
||||
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
|
||||
|
||||
// ── Автозагрузка при выборе файлов ──────────────────────────
|
||||
// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции
|
||||
// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload
|
||||
// Вся логика — в files.js. Здесь только тонкая обёртка.
|
||||
fileInput.addEventListener('change', async function() {
|
||||
var newFiles = Array.from(fileInput.files);
|
||||
onFilesSelected(newFiles);
|
||||
});
|
||||
|
||||
// ── Классификация ──────────────────────────────────────────
|
||||
function showClassifyBtn() {
|
||||
var btn = document.getElementById('classifyBtn');
|
||||
if (!btn) {
|
||||
btn = document.createElement('button');
|
||||
btn.id = 'classifyBtn';
|
||||
btn.className = 'btn btn-primary';
|
||||
btn.style.cssText = 'width:100%;justify-content:center;margin-top:8px;background:var(--brand-primary);border-color:var(--brand-primary);';
|
||||
btn.innerHTML = '<i data-lucide="tags" style="width:16px;height:16px;"></i> Классифицировать';
|
||||
btn.addEventListener('click', runClassify);
|
||||
document.getElementById('llmBtn').parentNode.insertBefore(btn, document.getElementById('llmBtn'));
|
||||
}
|
||||
btn.style.display = 'flex';
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
async function runClassify() {
|
||||
var btn = document.getElementById('classifyBtn');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Классификация... 0с';
|
||||
stepActive('stepClassify');
|
||||
|
||||
var start = Date.now();
|
||||
var timer = setInterval(function() {
|
||||
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + Math.round((Date.now() - start) / 1000) + 'с';
|
||||
}, 1000);
|
||||
|
||||
var totalFiles = 0;
|
||||
var classifyDone = false;
|
||||
|
||||
// Poll progress каждые 2с — работает и для sync, и для async classify
|
||||
var pollTimer = setInterval(async function() {
|
||||
try {
|
||||
var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
|
||||
var d = await r.json();
|
||||
if (d.ok && d.counts) {
|
||||
var done = (d.counts.classified || 0) + (d.counts.failed || 0);
|
||||
var total = d.total || 0;
|
||||
if (total > 0) totalFiles = total;
|
||||
btn.innerHTML = '<span class="spinner"></span> Классификация... ' + done + '/' + total + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||
if (done >= total && total > 0) {
|
||||
clearInterval(pollTimer);
|
||||
clearInterval(timer);
|
||||
classifyDone = true;
|
||||
btn.innerHTML = '✓ ' + done + '/' + total + ' классифицировано (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||
stepDone('stepClassify');
|
||||
stepActive('stepGroups');
|
||||
loadGroupsAction();
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}, 2000);
|
||||
|
||||
try {
|
||||
var resp = await fetch(VM_API + '/api/classify-batch', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({batch_id: state.batchId})
|
||||
});
|
||||
var data = await resp.json();
|
||||
|
||||
// sync classify: ответ уже содержит done
|
||||
if (data.ok && data.done !== undefined) {
|
||||
clearInterval(timer);
|
||||
clearInterval(pollTimer);
|
||||
classifyDone = true;
|
||||
var summary = data.summary || (data.done + '/' + data.total + ' классифицировано');
|
||||
if (data.garbage) summary += ' | 🗑️ ' + data.garbage + ' мусор';
|
||||
if (data.types) summary += ' | ' + Object.entries(data.types).map(function(e){return e[1]+'×'+e[0];}).join(', ');
|
||||
btn.innerHTML = '✓ ' + summary + ' (' + Math.round((Date.now() - start) / 1000) + 'с)';
|
||||
stepDone('stepClassify');
|
||||
stepActive('stepGroups');
|
||||
loadGroupsAction();
|
||||
} else if (data.ok) {
|
||||
// async classify: 202 — ждём poll до done>=total
|
||||
totalFiles = data.total || 0;
|
||||
if (totalFiles === 0) {
|
||||
clearInterval(timer);
|
||||
clearInterval(pollTimer);
|
||||
btn.innerHTML = '✗ нет файлов для классификации';
|
||||
btn.disabled = false;
|
||||
}
|
||||
} else {
|
||||
clearInterval(timer);
|
||||
clearInterval(pollTimer);
|
||||
btn.innerHTML = '✗ ' + (data.error || 'ошибка');
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch(e) {
|
||||
if (!classifyDone) {
|
||||
clearInterval(timer);
|
||||
clearInterval(pollTimer);
|
||||
btn.innerHTML = '✗ ' + escHtml(e.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// loadGroups, buildGroupCard, markGroupDone → groups.js (Фаза 2)
|
||||
|
||||
// Текущий активный EventSource (только один одновременно)
|
||||
// state._activeCompare — теперь в state.js (Фаза 0: state + render)
|
||||
|
||||
window.runCompareForGroup = async function(gi, btn) {
|
||||
var group = state.groups[gi];
|
||||
if (!group || group.contract_number === '__unresolved__') return;
|
||||
|
||||
// Фаза 2: отметить группу как «в процессе»
|
||||
// renderGroups НЕ вызываем — инкрементальные DOM-мутации внутри SSE
|
||||
group.compare = { status: 'running' };
|
||||
|
||||
// Остановить предыдущее сравнение
|
||||
if (state._activeCompare.es) { state._activeCompare.es.close(); state._activeCompare.es = null; }
|
||||
if (state._activeCompare.timer) { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; }
|
||||
|
||||
// Свернуть ТОЛЬКО тело текущей группы
|
||||
var curBody = document.getElementById('cmpBody_' + gi);
|
||||
if (curBody) curBody.style.display = 'none';
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Применяю...';
|
||||
// Заблокировать ВСЕ кнопки сравнения пока идёт процесс
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = true; });
|
||||
|
||||
var ar = await fetch(VM_API + '/api/apply-groups', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({batch_id: state.batchId, groups: [group]})
|
||||
});
|
||||
var ad = await ar.json();
|
||||
if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) {
|
||||
btn.innerHTML = '✗ ошибка'; btn.disabled = false;
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
return;
|
||||
}
|
||||
|
||||
var cid = ad.contract_ids[0];
|
||||
|
||||
// Создать контейнер для результатов сравнения
|
||||
var cmpBody = document.getElementById('cmpBody_' + gi);
|
||||
if (!cmpBody) {
|
||||
cmpBody = document.createElement('div');
|
||||
cmpBody.className = 'cmp-body';
|
||||
cmpBody.id = 'cmpBody_' + gi;
|
||||
cmpBody.style.cssText = 'margin-top:8px;';
|
||||
btn.parentNode.insertBefore(cmpBody, btn.nextSibling);
|
||||
}
|
||||
cmpBody.innerHTML = '';
|
||||
cmpBody.style.display = 'block';
|
||||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||||
stepActive('stepCompare');
|
||||
|
||||
var statusEl = document.createElement('div');
|
||||
statusEl.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px;';
|
||||
statusEl.textContent = '⏳ 0.0с';
|
||||
cmpBody.appendChild(statusEl);
|
||||
|
||||
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
||||
var result = startCompareSSE(
|
||||
VM_API + '/process-v2?contract_id=' + cid,
|
||||
cmpBody,
|
||||
statusEl,
|
||||
{
|
||||
onDone: function(d, sections) {
|
||||
state._activeCompare.timer = null;
|
||||
state._activeCompare.es = null;
|
||||
group.compare = {
|
||||
status: 'done',
|
||||
totalTime: d.total_time_s + 'с',
|
||||
bodyHTML: cmpBody.innerHTML,
|
||||
sections: sections
|
||||
};
|
||||
renderGroups(state);
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
stepDone('stepCompare');
|
||||
},
|
||||
onError: function(d) {
|
||||
state._activeCompare.timer = null;
|
||||
state._activeCompare.es = null;
|
||||
group.compare = null;
|
||||
cmpBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + escHtml(d.message) + '</div>';
|
||||
btn.innerHTML = '✗'; btn.disabled = false;
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
},
|
||||
onConnectionError: function() {
|
||||
state._activeCompare.timer = null;
|
||||
state._activeCompare.es = null;
|
||||
group.compare = null;
|
||||
btn.innerHTML = '✗ соединение'; btn.disabled = false;
|
||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||
}
|
||||
}
|
||||
);
|
||||
state._activeCompare.es = result.es;
|
||||
state._activeCompare.timer = result.timer;
|
||||
};
|
||||
|
||||
// uploadFile → files.js (Фаза 1)
|
||||
|
||||
// ── LLM: Общее сравнение (Event Sourcing) ────────────────────────
|
||||
document.getElementById('llmBtn').addEventListener('click', function() {
|
||||
if (!state.contractId) return;
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||||
|
||||
var compareCard = document.getElementById('compareCard');
|
||||
var compareBody = document.getElementById('compareBody');
|
||||
var compareStatus = document.getElementById('compareStatus');
|
||||
compareCard.style.display = 'block';
|
||||
compareBody.innerHTML = '';
|
||||
compareStatus.textContent = '⏳ 0.0с';
|
||||
|
||||
var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
|
||||
var url = '/process-v2?contract_id=' + state.contractId +
|
||||
(order ? '&order=' + encodeURIComponent(order) : '');
|
||||
|
||||
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
||||
startCompareSSE(url, compareBody, compareStatus, {
|
||||
onDone: function(d) {
|
||||
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
|
||||
stepDone('stepCompare');
|
||||
if (d.total_unresolved > 0) {
|
||||
compareBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href=\"/resolve.cfm?contract_id=' + state.contractId + '\">разобрать</a></div>';
|
||||
}
|
||||
document.getElementById('chatCard').style.display = 'block';
|
||||
lucide.createIcons();
|
||||
},
|
||||
onError: function(d) {
|
||||
compareBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + escHtml(d.message) + '</div>';
|
||||
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||||
btn.disabled = false;
|
||||
},
|
||||
onConnectionError: function() {
|
||||
if (compareBody.innerHTML === '') {
|
||||
compareBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
|
||||
}
|
||||
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||||
btn.disabled = false;
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// refreshSupps → files.js (Фаза 1)
|
||||
|
||||
// ── Чат ──────────────────────────────────────────────────────
|
||||
document.getElementById('chatSend').addEventListener('click', function() {
|
||||
var input = document.getElementById('chatInput');
|
||||
var q = input.value.trim();
|
||||
if (!q || !state.contractId) return;
|
||||
var msgs = document.getElementById('chatMessages');
|
||||
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
|
||||
input.value = '';
|
||||
input.disabled = true;
|
||||
document.getElementById('chatSend').disabled = true;
|
||||
msgs.innerHTML += '<div style="color:var(--muted);margin-top:2px;">⏳ Думаю...</div>';
|
||||
|
||||
var fd = new FormData();
|
||||
fd.append('question', q);
|
||||
fetch('/chat.cfm?contract_id=' + state.contractId, { method: 'POST', body: fd })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
msgs.lastChild.remove();
|
||||
msgs.innerHTML += '<div style="margin-top:2px;"><strong>Ответ:</strong> ' + (d.OK ? d.ANSWER : (d.ERROR || '—')) + '</div>';
|
||||
input.disabled = false;
|
||||
document.getElementById('chatSend').disabled = false;
|
||||
input.focus();
|
||||
}).catch(function() {
|
||||
msgs.lastChild.remove();
|
||||
msgs.innerHTML += '<div style="color:var(--destructive);margin-top:2px;">Ошибка сети</div>';
|
||||
input.disabled = false;
|
||||
document.getElementById('chatSend').disabled = false;
|
||||
});
|
||||
});
|
||||
document.getElementById('chatInput').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') document.getElementById('chatSend').click();
|
||||
});
|
||||
|
||||
function closeModal(e) {
|
||||
if (e && e.target !== document.getElementById('modalOverlay')) return;
|
||||
document.querySelector('.modal').classList.remove('wide');
|
||||
document.getElementById('modalOverlay').classList.remove('open');
|
||||
}
|
||||
|
||||
async function showText(i) {
|
||||
var f = state.files[i];
|
||||
var docId = f.doc_id;
|
||||
document.getElementById('modalTitle').textContent = f.name;
|
||||
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
|
||||
var modal = document.querySelector('.modal');
|
||||
modal.classList.add('wide');
|
||||
document.getElementById('modalOverlay').classList.add('open');
|
||||
|
||||
var leftHtml = '<span style="color:var(--muted);">Загрузка...</span>';
|
||||
var rightHtml = '';
|
||||
|
||||
if (docId) {
|
||||
try {
|
||||
var qResp = await fetch(VM_API + '/api/documents/' + docId);
|
||||
var qData = await qResp.json();
|
||||
var elements = [];
|
||||
if (qData.ok && qData.elements_json) {
|
||||
var ej = qData.elements_json;
|
||||
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
||||
if (typeof ej === 'string') elements = JSON.parse(ej);
|
||||
else elements = ej;
|
||||
}
|
||||
|
||||
// Общий textify для обеих панелей
|
||||
var textLines = [];
|
||||
elements.forEach(function(el) {
|
||||
var etype = el.type || el.TYPE || '?';
|
||||
if (etype === 'paragraph') {
|
||||
textLines.push(el.text || el.TEXT || '');
|
||||
} else if (etype === 'table') {
|
||||
var rows = el.rows || el.ROWS || [];
|
||||
if (rows.length > 0) {
|
||||
var ncols = (rows[0]||[]).length;
|
||||
textLines.push('--- Таблица ' + rows.length + '×' + ncols + ' ---');
|
||||
rows.forEach(function(row) {
|
||||
var cells = (row||[]).map(function(c){ return String(c||'').replace(/\n/g,' ').replace(/\|/g,'\\|'); });
|
||||
textLines.push('| ' + cells.join(' | ') + ' |');
|
||||
});
|
||||
textLines.push('');
|
||||
}
|
||||
}
|
||||
});
|
||||
var textify = textLines.join('\n').replace(/</g,'<').replace(/>/g,'>');
|
||||
|
||||
// Левая: сырой текст (textify)
|
||||
leftHtml = '<pre>' + textify + '</pre>';
|
||||
|
||||
// Правая: статистика + textify
|
||||
rightHtml += '<div class="kv"><span class="k">Размер</span><span class="v">' + formatSize(f.size) + '</span></div>';
|
||||
rightHtml += '<div class="kv"><span class="k">Изменён</span><span class="v">' + formatDate(f.lastModified) + '</span></div>';
|
||||
if (f.parseInfo) {
|
||||
var d = f.parseInfo;
|
||||
rightHtml += '<div class="kv"><span class="k">Элементов</span><span class="v">' + (d.element_count||0) + '</span></div>';
|
||||
if (d.error) rightHtml += '<div style="margin-top:8px;color:var(--destructive);">' + d.error + '</div>';
|
||||
}
|
||||
rightHtml += '<div style="margin-top:10px;font-weight:600;font-size:12px;">Все элементы:</div>';
|
||||
rightHtml += '<pre>' + textify + '</pre>';
|
||||
if (f.supp_id) {
|
||||
rightHtml += '<div class="kv"><span class="k">Тип ДС</span><span class="v">' + (f.supp_type || '—') + '</span></div>';
|
||||
}
|
||||
|
||||
// ── Результаты классификации ──────────────────────────
|
||||
if (qData.classify_status === 'classified' || qData.classify_raw) {
|
||||
rightHtml += '<div style="margin-top:12px;font-weight:600;font-size:12px;border-top:1px solid var(--brand-gray);padding-top:8px;">🏷️ Классификация LLM</div>';
|
||||
if (qData.doc_type) rightHtml += '<div class="kv"><span class="k">Тип</span><span class="v">' + escHtml(qData.doc_type) + '</span></div>';
|
||||
if (qData.own_number) rightHtml += '<div class="kv"><span class="k">Номер</span><span class="v">' + escHtml(qData.own_number) + '</span></div>';
|
||||
if (qData.parent_number) rightHtml += '<div class="kv"><span class="k">Родительский</span><span class="v">' + escHtml(qData.parent_number) + '</span></div>';
|
||||
if (qData.doc_date) rightHtml += '<div class="kv"><span class="k">Дата</span><span class="v">' + escHtml(qData.doc_date) + '</span></div>';
|
||||
if (qData.counterparty) rightHtml += '<div class="kv"><span class="k">Контрагент</span><span class="v">' + escHtml(qData.counterparty) + '</span></div>';
|
||||
if (qData.classify_raw) {
|
||||
var rawId = 'raw_' + docId.replace(/-/g,'');
|
||||
rightHtml += '<details style="margin-top:8px;"><summary style="cursor:pointer;font-size:11px;color:var(--brand-primary);">Сырой ответ LLM</summary>';
|
||||
rightHtml += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:var(--brand-grey-light);padding:6px;border-radius:4px;margin-top:4px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||||
rightHtml += '</details>';
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
leftHtml = '<span style="color:var(--destructive);">Ошибка</span>';
|
||||
}
|
||||
} else {
|
||||
leftHtml = '<span style="color:var(--muted);">(ещё не загружен)</span>';
|
||||
}
|
||||
|
||||
var html = '<div class="text-panes">' +
|
||||
'<div class="text-pane"><div class="pane-title">Сырой текст</div>' + leftHtml + '</div>' +
|
||||
'<div class="text-pane"><div class="pane-title">Распарсено</div>' + rightHtml + '</div>' +
|
||||
'</div>';
|
||||
document.getElementById('modalBody').innerHTML = html;
|
||||
}
|
||||
|
||||
window.closeModal = closeModal;
|
||||
window.showText = showText;
|
||||
|
||||
// ── Промпты (версионные) ─────────────────────────────────────
|
||||
var promptRole = 'diff'; // текущая выбранная роль
|
||||
var promptEditor = document.getElementById('promptEditor');
|
||||
var promptStatus = document.getElementById('promptStatus');
|
||||
var promptHistory = document.getElementById('promptHistory');
|
||||
var promptModalOverlay = document.getElementById('promptModalOverlay');
|
||||
var promptModalTitle = document.getElementById('promptModalTitle');
|
||||
var promptName = document.getElementById('promptName');
|
||||
var promptNotes = document.getElementById('promptNotes');
|
||||
// Три роли промптов:
|
||||
// extract — извлечение строк из первого документа (базовый договор)
|
||||
// diff — сравнение допсоглашений (ADD/UPDATE/DELETE)
|
||||
// classify — классификация документа (тип, номер, контрагент, дата)
|
||||
var activePromptId = {extract: '', diff: '', classify: ''};
|
||||
|
||||
function openPromptModal(role) {
|
||||
promptRole = role;
|
||||
var labels = {extract: 'Извлечение', diff: 'Сравнение', classify: 'Классификация'};
|
||||
promptModalTitle.textContent = 'Промпт: ' + (labels[role] || role);
|
||||
document.getElementById('promptTabExtract').style.background = role === 'extract' ? 'var(--brand-primary)' : '';
|
||||
document.getElementById('promptTabExtract').style.color = role === 'extract' ? '#fff' : '';
|
||||
document.getElementById('promptTabDiff').style.background = role === 'diff' ? 'var(--brand-primary)' : '';
|
||||
document.getElementById('promptTabDiff').style.color = role === 'diff' ? '#fff' : '';
|
||||
document.getElementById('promptTabClassify').style.background = role === 'classify' ? 'var(--brand-primary)' : '';
|
||||
document.getElementById('promptTabClassify').style.color = role === 'classify' ? '#fff' : '';
|
||||
promptStatus.textContent = '';
|
||||
promptName.value = '';
|
||||
promptNotes.value = '';
|
||||
loadPrompt(role);
|
||||
promptModalOverlay.classList.add('open');
|
||||
}
|
||||
|
||||
function closePromptModal(e) {
|
||||
if (e && e.target !== promptModalOverlay) return;
|
||||
promptModalOverlay.classList.remove('open');
|
||||
}
|
||||
|
||||
function loadPrompt(role) {
|
||||
fetch(VM_API + '/api/prompts?role=' + role)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) {
|
||||
promptEditor.value = d.body || '';
|
||||
activePromptId[role] = d.id || '';
|
||||
loadHistory(role);
|
||||
}
|
||||
})
|
||||
.catch(function(e) { console.error('loadPrompt:', e); });
|
||||
}
|
||||
|
||||
function loadHistory(role) {
|
||||
fetch(VM_API + '/api/prompts/list?role=' + role)
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (!d.ok || !d.versions || !d.versions.length) {
|
||||
promptHistory.innerHTML = '<div style="color:var(--muted);">нет версий</div>';
|
||||
return;
|
||||
}
|
||||
promptHistory.innerHTML = d.versions.map(function(v) {
|
||||
var isActive = v.is_active ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--green);margin-right:4px;" title="активная"></span> ' : '';
|
||||
var style = v.is_active ? 'font-weight:600;' : '';
|
||||
return '<div style="display:flex;align-items:center;padding:4px 0;border-bottom:1px solid var(--brand-gray);font-size:11px;' + style + '">'
|
||||
+ '<span style="flex:1;">' + isActive + escHtml(v.name) + ' <span style="color:var(--muted);">' + fmtDate(v.created_at) + '</span>'
|
||||
+ (v.notes ? ' <span style="color:var(--muted);font-style:italic;">— ' + escHtml(v.notes) + '</span>' : '')
|
||||
+ '</span>'
|
||||
+ (!v.is_active ? '<button class="btn" onclick="activatePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:4px;">активировать</button>' : '')
|
||||
+ (!v.is_active ? '<button class="btn" onclick="deletePrompt(\'' + v.id + '\')" style="font-size:11px;height:22px;padding:0 8px;margin-left:2px;color:var(--destructive);">удалить</button>' : '')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
})
|
||||
.catch(function(e) { console.error('loadHistory:', e); });
|
||||
}
|
||||
|
||||
function activatePrompt(id) {
|
||||
fetch(VM_API + '/api/prompts/activate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) { loadPrompt(promptRole); promptStatus.textContent = '✓ активирован'; }
|
||||
else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
|
||||
});
|
||||
}
|
||||
|
||||
function deletePrompt(id) {
|
||||
if (!confirm('Удалить эту версию промпта?')) return;
|
||||
fetch(VM_API + '/api/prompts/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({id: id})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) { loadHistory(promptRole); promptStatus.textContent = '✓ удалено'; }
|
||||
else { promptStatus.textContent = '✕ ' + (d.error || 'ошибка'); }
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('promptSave').addEventListener('click', function() {
|
||||
var body = promptEditor.value.trim();
|
||||
if (!body) { promptStatus.textContent = '✕ тело промпта пустое'; return; }
|
||||
var name = promptName.value.trim() || ('v' + new Date().toISOString().replace(/[:.]/g,'-').substring(0,16));
|
||||
fetch(VM_API + '/api/prompts/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
role: promptRole, name: name, body: body,
|
||||
notes: promptNotes.value.trim(), is_active: true
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
if (d.ok) {
|
||||
promptStatus.textContent = '✓ сохранено (новая версия)';
|
||||
promptName.value = ''; promptNotes.value = '';
|
||||
loadPrompt(promptRole);
|
||||
} else {
|
||||
promptStatus.textContent = '✕ ' + (d.error || 'ошибка');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('promptTabExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
||||
document.getElementById('promptTabDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
||||
document.getElementById('promptTabClassify').addEventListener('click', function() { openPromptModal('classify'); });
|
||||
document.getElementById('promptBtnExtract').addEventListener('click', function() { openPromptModal('extract'); });
|
||||
document.getElementById('promptBtnDiff').addEventListener('click', function() { openPromptModal('diff'); });
|
||||
document.getElementById('promptBtnClassify').addEventListener('click', function() { openPromptModal('classify'); });
|
||||
lucide.createIcons();
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* app_utils.js — Общие утилиты (форматирование, стрелки, модалки).
|
||||
* Вынесены из app.js для облегчения анализа и отладки.
|
||||
* Загружается ДО app.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Форматирует размер файла в человекочитаемый вид.
|
||||
* Используется в renderTable() для колонки "Размер".
|
||||
*/
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует timestamp в российскую дату + время.
|
||||
* Используется в renderTable() для колонки "Изменён".
|
||||
*/
|
||||
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'});
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет файл из очереди по индексу.
|
||||
* Вызывается по кнопке ✕ в таблице файлов.
|
||||
*
|
||||
* Фаза 0 (state + render): мутирует state.files, затем render(state).
|
||||
* Если очередь опустела — сбрасывает state.contractId.
|
||||
*/
|
||||
function removeFile(i) {
|
||||
state.files.splice(i, 1);
|
||||
if (state.files.length === 0) state.contractId = null;
|
||||
render(state);
|
||||
// Синхронизировать БД с таблицей
|
||||
if (typeof syncDB === 'function') syncDB();
|
||||
// Сбросить прогресс при изменении состава файлов
|
||||
if (typeof resetStepper === 'function') resetStepper('stepUpload');
|
||||
// Разблокировать кнопку классификации при изменении состава файлов
|
||||
if (typeof showClassifyBtn === 'function') showClassifyBtn();
|
||||
}
|
||||
window.removeFile = removeFile;
|
||||
|
||||
/**
|
||||
* Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки).
|
||||
* В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
|
||||
*
|
||||
* Фаза 0 (state + render): мутирует state.files, затем render(state).
|
||||
*/
|
||||
function moveUp(i) {
|
||||
if (i <= 0) return;
|
||||
var tmp = state.files[i]; state.files[i] = state.files[i-1]; state.files[i-1] = tmp;
|
||||
render(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Переместить файл на одну позицию ВНИЗ (сохранено для возможного возврата ручной сортировки).
|
||||
* В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
|
||||
*
|
||||
* Фаза 0 (state + render): мутирует state.files, затем render(state).
|
||||
*/
|
||||
function moveDown(i) {
|
||||
if (i >= state.files.length - 1) return;
|
||||
var tmp = state.files[i]; state.files[i] = state.files[i+1]; state.files[i+1] = tmp;
|
||||
render(state);
|
||||
}
|
||||
window.moveUp = moveUp;
|
||||
window.moveDown = moveDown;
|
||||
|
||||
/**
|
||||
* Открыть модальное окно "О сервисе".
|
||||
*/
|
||||
function openAbout() {
|
||||
document.getElementById('aboutModalOverlay').classList.add('open');
|
||||
}
|
||||
|
||||
/**
|
||||
* Закрыть модальное окно "О сервисе" (по клику на оверлей или кнопку).
|
||||
*/
|
||||
function closeAbout(e) {
|
||||
if (e && e.target !== document.getElementById('aboutModalOverlay')) return;
|
||||
document.getElementById('aboutModalOverlay').classList.remove('open');
|
||||
}
|
||||
window.openAbout = openAbout;
|
||||
window.closeAbout = closeAbout;
|
||||
|
||||
/**
|
||||
* Экранировать HTML (для безопасного рендеринга пользовательских данных).
|
||||
* Используется в истории промптов.
|
||||
*/
|
||||
function escHtml(s) {
|
||||
if (s == null) return '';
|
||||
var d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматировать дату для истории промптов (YYYY-MM-DD HH:MM).
|
||||
*/
|
||||
function fmtDate(d) {
|
||||
if (!d) return '';
|
||||
return d.replace('T', ' ').substring(0, 16);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* compare.js — Модуль сравнения через SSE (Фаза 3, decoupling-final-plan.md).
|
||||
*
|
||||
* УНИФИЦИРУЕТ дублирование (~160 строк) между runCompareForGroup и llmBtn.
|
||||
*
|
||||
* ПАТТЕРН:
|
||||
* applyCompareEvent(sections, event) — чистая мутация: SSE-событие → state
|
||||
* startCompareSSE(url, container, statusEl, callbacks) — жизненный цикл SSE
|
||||
* renderCompareOpsTable(ops) — общий рендер таблицы операций
|
||||
*
|
||||
* ЗАВИСИМОСТИ:
|
||||
* state.js → state (для _activeCompare)
|
||||
*/
|
||||
|
||||
/**
|
||||
* applyCompareEvent(sections, event) — Чистая функция (Фаза 3).
|
||||
*
|
||||
* Применяет ОДНО SSE-событие к состоянию sections.
|
||||
* НЕ трогает DOM — только мутирует объект sections.
|
||||
*
|
||||
* Вход: sections — объект { [supplement_id]: sectionState }
|
||||
* event — { type, supplement_id, filename, ops_count, mode, ... }
|
||||
* Выход: нет (мутирует sections)
|
||||
*
|
||||
* Типы событий:
|
||||
* extract_start → создаёт запись в sections
|
||||
* llm_done → обновляет статус, ops_count, mode, time_s
|
||||
* applied → сохраняет ops и summary
|
||||
* extract_error/apply_error → сохраняет ошибку
|
||||
*/
|
||||
function applyCompareEvent(sections, event) {
|
||||
var d = event;
|
||||
var suppId = d.supplement_id;
|
||||
|
||||
if (d.type === 'extract_start') {
|
||||
// Создать новую секцию: извлечение началось
|
||||
sections[suppId] = {
|
||||
filename: d.filename,
|
||||
status: 'extracting',
|
||||
ops_count: 0,
|
||||
mode: '',
|
||||
time_s: 0,
|
||||
summary: null,
|
||||
ops: null,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
else if (d.type === 'llm_done') {
|
||||
// LLM закончил — сохранить метаданные
|
||||
var sec = sections[suppId];
|
||||
if (sec) {
|
||||
sec.status = 'llm_done';
|
||||
sec.ops_count = d.ops_count || 0;
|
||||
sec.mode = d.mode || '';
|
||||
sec.time_s = d.time_s || 0;
|
||||
}
|
||||
}
|
||||
else if (d.type === 'applied') {
|
||||
// Результаты сравнения применены — сохранить ops и summary
|
||||
var sec = sections[suppId];
|
||||
if (sec) {
|
||||
sec.status = 'applied';
|
||||
sec.summary = d.summary || {};
|
||||
sec.ops = d.ops || [];
|
||||
}
|
||||
}
|
||||
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
||||
// Ошибка извлечения или применения
|
||||
var sec = sections[suppId];
|
||||
if (sec) {
|
||||
sec.status = 'error';
|
||||
sec.error = d.error || 'неизвестная ошибка';
|
||||
} else {
|
||||
// Ошибка для ещё не созданной секции
|
||||
sections[suppId] = {
|
||||
filename: d.filename || '?',
|
||||
status: 'error',
|
||||
error: d.error || 'неизвестная ошибка',
|
||||
ops_count: 0, mode: '', time_s: 0, summary: null, ops: null
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* renderCompareSectionHeader(sec) — Чистая функция: заголовок секции (Фаза 3).
|
||||
*
|
||||
* Вход: sec — { filename, status, ops_count, mode, time_s, error }
|
||||
* Выход: HTML-строка для div.diff-section-header
|
||||
*/
|
||||
function renderCompareSectionHeader(sec) {
|
||||
if (sec.status === 'extracting') {
|
||||
return '⏳ ' + sec.filename;
|
||||
}
|
||||
if (sec.status === 'llm_done' || sec.status === 'applied') {
|
||||
return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
|
||||
}
|
||||
if (sec.status === 'error') {
|
||||
return '✗ ' + sec.filename + ': ' + sec.error;
|
||||
}
|
||||
return sec.filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderCompareOpsTable(ops) — Чистая функция: таблица операций (Фаза 3).
|
||||
*
|
||||
* Используется ОБОИМИ обработчиками (runCompareForGroup и llmBtn).
|
||||
* Раньше этот код был продублирован ~30 строк × 2.
|
||||
*
|
||||
* Вход: ops — [{ action, new_row: { name, price, qty, sum, date_start } }]
|
||||
* Выход: HTML-строка <table>
|
||||
*/
|
||||
function renderCompareOpsTable(ops) {
|
||||
var html = '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
||||
ops.forEach(function(op) {
|
||||
var action = op.action;
|
||||
// Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
|
||||
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
||||
var nr = op.new_row || {};
|
||||
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderCompareSectionBody(sec) — Чистая функция: тело секции (Фаза 3).
|
||||
*
|
||||
* Рендерит summary (статистику) + таблицу ops.
|
||||
*
|
||||
* Вход: sec — { summary: { added, updated, deleted, unresolved }, ops }
|
||||
* Выход: HTML-строка
|
||||
*/
|
||||
function renderCompareSectionBody(sec) {
|
||||
if (!sec || sec.status !== 'applied' || !sec.ops || !sec.ops.length) return '';
|
||||
var s = sec.summary || {};
|
||||
var html = '<div style="font-size:12px;margin-bottom:6px;">';
|
||||
html += '+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0);
|
||||
if (s.unresolved) html += ' ?' + s.unresolved;
|
||||
html += '</div>';
|
||||
html += renderCompareOpsTable(sec.ops);
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* startCompareSSE(url, container, statusEl, callbacks) — Жизненный цикл SSE (Фаза 3).
|
||||
*
|
||||
* УНИФИЦИРОВАННЫЙ обработчик SSE для ОБОИХ режимов сравнения.
|
||||
* Раньше ~80 строк дублировалось в runCompareForGroup и llmBtn.
|
||||
*
|
||||
* Параметры:
|
||||
* url — URL для EventSource (/process-v2?contract_id=...)
|
||||
* container — DOM-элемент, куда добавлять секции (cmpBody или compareBody)
|
||||
* statusEl — DOM-элемент для отображения таймера/статуса
|
||||
* callbacks — { onDone(d, sections), onError(d), onConnectionError() }
|
||||
* Все колбэки опциональны.
|
||||
* Таймер и EventSource УЖЕ остановлены к моменту вызова колбэков.
|
||||
*
|
||||
* Возвращает: { es, timer, sections } — для сохранения в state._activeCompare
|
||||
*/
|
||||
function startCompareSSE(url, container, statusEl, callbacks) {
|
||||
callbacks = callbacks || {};
|
||||
|
||||
// Таймер: обновляет statusEl каждые 200мс
|
||||
var start = Date.now();
|
||||
var timer = setInterval(function() {
|
||||
statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с';
|
||||
}, 200);
|
||||
|
||||
var es = new EventSource(url);
|
||||
var sections = {}; // supplement_id → { filename, status, ops_count, mode, time_s, summary, ops, error, _el }
|
||||
|
||||
es.onmessage = function(e) {
|
||||
var d = JSON.parse(e.data);
|
||||
|
||||
// ── extract_start: создать DOM-секцию ──
|
||||
if (d.type === 'extract_start') {
|
||||
applyCompareEvent(sections, d);
|
||||
var sec = sections[d.supplement_id];
|
||||
var secEl = document.createElement('div');
|
||||
secEl.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
|
||||
secEl.innerHTML =
|
||||
'<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;"' +
|
||||
' onclick="var b=this.nextElementSibling;b.style.display=b.style.display===\'none\'?\'block\':\'none\';">' +
|
||||
renderCompareSectionHeader(sec) + '</div>' +
|
||||
'<div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
||||
container.appendChild(secEl);
|
||||
sec._el = secEl; // ссылка на DOM для последующих обновлений
|
||||
}
|
||||
|
||||
// ── llm_done: обновить заголовок секции ──
|
||||
else if (d.type === 'llm_done') {
|
||||
applyCompareEvent(sections, d);
|
||||
var sec = sections[d.supplement_id];
|
||||
if (sec && sec._el) {
|
||||
sec._el.querySelector('.diff-section-header').innerHTML = renderCompareSectionHeader(sec);
|
||||
sec._el.querySelector('.diff-section-header').style.color = 'var(--green)';
|
||||
}
|
||||
}
|
||||
|
||||
// ── applied: заполнить тело секции таблицей ops ──
|
||||
else if (d.type === 'applied') {
|
||||
applyCompareEvent(sections, d);
|
||||
var sec = sections[d.supplement_id];
|
||||
if (sec && sec._el && sec.ops && sec.ops.length > 0) {
|
||||
var body = sec._el.querySelector('.diff-section-body');
|
||||
body.innerHTML = renderCompareSectionBody(sec);
|
||||
body.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// ── extract_error / apply_error: показать ошибку ──
|
||||
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
||||
applyCompareEvent(sections, d);
|
||||
container.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + escHtml(d.filename) + ': ' + escHtml(d.error) + '</div>';
|
||||
}
|
||||
|
||||
// ── done: завершение ──
|
||||
else if (d.type === 'done') {
|
||||
clearInterval(timer);
|
||||
es.close();
|
||||
statusEl.textContent = '✓ ' + d.total_time_s + 'с';
|
||||
statusEl.style.color = 'var(--green)';
|
||||
if (callbacks.onDone) callbacks.onDone(d, sections);
|
||||
}
|
||||
|
||||
// ── error: фатальная ошибка ──
|
||||
else if (d.type === 'error') {
|
||||
clearInterval(timer);
|
||||
es.close();
|
||||
if (callbacks.onError) callbacks.onError(d);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Сетевая ошибка ──
|
||||
es.onerror = function() {
|
||||
clearInterval(timer);
|
||||
es.close();
|
||||
if (callbacks.onConnectionError) callbacks.onConnectionError();
|
||||
};
|
||||
|
||||
return { es: es, timer: timer, sections: sections };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="4" fill="#001C34"/>
|
||||
<text x="16" y="24" text-anchor="middle" font-size="22" font-weight="bold" fill="white" font-family="sans-serif">N</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md).
|
||||
*
|
||||
* ВЫНЕСЕНО из app.js:
|
||||
* - statusToHTML(st) — чистая: структура → HTML
|
||||
* - renderFiles(state) — рендер таблицы файлов
|
||||
* - syncDB() — синхронизация БД с fileQueue
|
||||
* - toggleClassifyDetail(i) — раскрыть результат классификации
|
||||
* - uploadFile(file, cb) — XHR-загрузка одного файла (.doc/.docx/.pdf)
|
||||
* - refreshSupps() — обновить supplement_id для файлов
|
||||
*
|
||||
* ЗАВИСИМОСТИ (глобальные, загружаются раньше):
|
||||
* state.js → state (центральное состояние)
|
||||
* app_utils.js → escHtml, formatSize, formatDate
|
||||
* app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn
|
||||
*
|
||||
* ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
|
||||
*/
|
||||
|
||||
/**
|
||||
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
||||
*
|
||||
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
||||
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
|
||||
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
||||
*
|
||||
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
||||
* state хранит чистые данные, statusToHTML — чистая функция рендеринга.
|
||||
*/
|
||||
function statusToHTML(st) {
|
||||
if (!st || !st.kind) return '';
|
||||
switch (st.kind) {
|
||||
case 'uploading': return '↑ ' + (st.pct || 0) + '%';
|
||||
case 'uploaded': return '<span class="status-ok">✓</span>';
|
||||
case 'unzipping': return '⏳ распаковка...';
|
||||
case 'parsing': return '⏳ парсинг...';
|
||||
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>';
|
||||
case 'error': return '<span class="status-err">✗ ' + escHtml(st.text || 'Неизвестная ошибка') + '</span>';
|
||||
case 'duplicate': return '<span style="color:var(--muted);">⤿ дубликат</span>';
|
||||
case 'warning': return '<span style="color:#eab308;">⚠ ' + escHtml(st.text || 'пустой') + '</span>';
|
||||
case 'no_format': return '<span class="status-err">✗ формат не поддерживается</span>';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1).
|
||||
*
|
||||
* Читает state.files, генерирует HTML для #fileTable.
|
||||
* Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
|
||||
* Вызывает lucide.createIcons() для иконок.
|
||||
*
|
||||
* Вход: state (весь объект состояния, но читает только state.files).
|
||||
* Выход: мутирует DOM (fileTable.innerHTML).
|
||||
*/
|
||||
function renderFiles(state) {
|
||||
if (state.files.length === 0) {
|
||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
// Сгруппировать файлы по zip_source
|
||||
var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}]
|
||||
var seen = {};
|
||||
for (var i = 0; i < state.files.length; i++) {
|
||||
var f = state.files[i];
|
||||
var zip = f.zip_source || null;
|
||||
var key = zip || '__naked__';
|
||||
if (!seen[key]) {
|
||||
seen[key] = { zip: zip, files: [] };
|
||||
groups.push(seen[key]);
|
||||
}
|
||||
// Сохраняем оригинальный индекс для id строк
|
||||
f._idx = i;
|
||||
seen[key].files.push(f);
|
||||
}
|
||||
|
||||
// Рендер: заголовок ZIP → файлы с отступом
|
||||
var rows = [];
|
||||
for (var gi = 0; gi < groups.length; gi++) {
|
||||
var g = groups[gi];
|
||||
|
||||
if (g.zip) {
|
||||
// Заголовок-секция ZIP
|
||||
rows.push(
|
||||
'<tr class="zip-header" style="background:var(--brand-grey-light);">' +
|
||||
'<td colspan="7" style="padding:6px 12px;font-size:12px;font-weight:600;">' +
|
||||
'📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' +
|
||||
(g.files.length === 1 ? '' : 'ов') +
|
||||
'</td></tr>'
|
||||
);
|
||||
}
|
||||
|
||||
// Файлы внутри группы (с отступом если из ZIP)
|
||||
for (var fi = 0; fi < g.files.length; fi++) {
|
||||
var f = g.files[fi];
|
||||
var i = f._idx;
|
||||
var indent = g.zip ? 'padding-left:24px;' : '';
|
||||
|
||||
rows.push(
|
||||
'<tr id="row_' + i + '">' +
|
||||
'<td class="name-cell" style="cursor:pointer;' + indent + '" onclick="toggleClassifyDetail(' + i + ')">' +
|
||||
(f.doc_id ? '<span id="expand_' + i + '" style="font-size:10px;margin-right:4px;">▸</span>' : '') +
|
||||
escHtml(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">' + statusToHTML(f.status) + '</td>' +
|
||||
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
|
||||
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
|
||||
'</tr>' +
|
||||
'<tr id="detail_' + i + '" style="display:none;"><td colspan="7" style="padding:4px 12px;font-size:11px;background:var(--brand-grey-light);"><span style="color:var(--muted);">Загрузка...</span></td></tr>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fileTable.innerHTML = rows.join('');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* syncDB() — Синхронизация БД с текущим состоянием файлов.
|
||||
*
|
||||
* Отправляет на бэкенд список doc_id, которые должны остаться в БД.
|
||||
* Всё, чего нет в state.files, будет удалено из БД (cascade).
|
||||
* Вызывается после каждого изменения состава файлов (removeFile, upload).
|
||||
*/
|
||||
async function syncDB() {
|
||||
var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean);
|
||||
try {
|
||||
await fetch(VM_API + '/api/sync', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({keep_ids: keepIds})
|
||||
});
|
||||
} catch(e) { /* сервер недоступен — не критично */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* toggleClassifyDetail(i) — Раскрыть/скрыть результат классификации под строкой файла.
|
||||
*
|
||||
* Вызывается по клику на имя файла в таблице.
|
||||
* Загружает данные с бэкенда (/api/documents/:id) и показывает:
|
||||
* - Тип, номер, родительский номер, дату, контрагента
|
||||
* - Текст отправленный в LLM (📤)
|
||||
* - Сырой ответ LLM (📥)
|
||||
* - Распарсенные элементы JSON (📄)
|
||||
*/
|
||||
window.toggleClassifyDetail = async function(i) {
|
||||
var detailRow = document.getElementById('detail_' + i);
|
||||
var expandIcon = document.getElementById('expand_' + i);
|
||||
if (!detailRow) return;
|
||||
|
||||
if (detailRow.style.display !== 'none') {
|
||||
detailRow.style.display = 'none';
|
||||
if (expandIcon) expandIcon.textContent = '▸';
|
||||
return;
|
||||
}
|
||||
|
||||
var f = state.files[i];
|
||||
if (!f || !f.doc_id) return;
|
||||
|
||||
detailRow.style.display = '';
|
||||
if (expandIcon) expandIcon.textContent = '▾';
|
||||
|
||||
try {
|
||||
var resp = await fetch(VM_API + '/api/documents/' + f.doc_id);
|
||||
var qData = await resp.json();
|
||||
if (!qData.ok) { detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка загрузки</span>'; return; }
|
||||
|
||||
var html = '';
|
||||
if (qData.classify_status === 'classified') {
|
||||
html += '<div style="font-weight:600;margin-bottom:4px;">🏷️ Результат классификации</div>';
|
||||
html += '<div style="display:flex;gap:16px;flex-wrap:wrap;">';
|
||||
if (qData.doc_type) html += '<span><b>Тип:</b> ' + escHtml(qData.doc_type) + '</span>';
|
||||
if (qData.own_number) html += '<span><b>Номер:</b> ' + escHtml(qData.own_number) + '</span>';
|
||||
if (qData.parent_number) html += '<span><b>Родительский:</b> ' + escHtml(qData.parent_number) + '</span>';
|
||||
if (qData.doc_date) html += '<span><b>Дата:</b> ' + escHtml(qData.doc_date) + '</span>';
|
||||
if (qData.counterparty) html += '<span><b>Контрагент:</b> ' + escHtml(qData.counterparty) + '</span>';
|
||||
html += '</div>';
|
||||
if (qData.classify_input) {
|
||||
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📤 Текст отправленный в LLM</summary>';
|
||||
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_input) + '</pre>';
|
||||
html += '</details>';
|
||||
}
|
||||
if (qData.classify_raw) {
|
||||
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📥 Сырой ответ LLM</summary>';
|
||||
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||||
html += '</details>';
|
||||
}
|
||||
if (qData.elements_json) {
|
||||
var ej = qData.elements_json;
|
||||
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
||||
var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2);
|
||||
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📄 Распарсенные элементы (JSON)</summary>';
|
||||
html += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '</pre>';
|
||||
html += '</details>';
|
||||
}
|
||||
} else if (qData.classify_status === 'failed') {
|
||||
html += '<span style="color:var(--destructive);">✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + '</span>';
|
||||
} else {
|
||||
html += '<span style="color:var(--muted);">Ещё не классифицирован</span>';
|
||||
}
|
||||
detailRow.cells[0].innerHTML = html;
|
||||
} catch(e) {
|
||||
detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка: ' + escHtml(e.message) + '</span>';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд.
|
||||
*
|
||||
* Особенности:
|
||||
* - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой
|
||||
* - onProgress(pct) — callback с процентом загрузки (0-100)
|
||||
* - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
|
||||
* - Таймаут 180с (большие PDF)
|
||||
*/
|
||||
function uploadFile(file, onProgress, zipSource) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
// .doc → конвертация в docx (старый формат Word)
|
||||
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
|
||||
var uploadFile = file;
|
||||
var uploadName = file.name;
|
||||
|
||||
function doUpload() {
|
||||
var xhr = new XMLHttpRequest();
|
||||
var fd = new FormData();
|
||||
fd.append('files', uploadFile, uploadName);
|
||||
if (state.contractId) fd.append('contract_id', state.contractId);
|
||||
fd.append('batch_id', state.batchId);
|
||||
if (zipSource) fd.append('zip_source', zipSource);
|
||||
xhr.open('POST', UPLOAD_URL);
|
||||
xhr.upload.onprogress = function(e) {
|
||||
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
|
||||
};
|
||||
xhr.onload = function() {
|
||||
try {
|
||||
var r = JSON.parse(xhr.responseText);
|
||||
if (r.ok) resolve(r);
|
||||
else reject(new Error(r.error || 'Неизвестная ошибка'));
|
||||
} catch(e) { reject(new Error('Некорректный ответ')); }
|
||||
};
|
||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
||||
xhr.timeout = 180000;
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
if (isDoc) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', CONVERT_URL);
|
||||
xhr.responseType = 'blob';
|
||||
xhr.onload = function() {
|
||||
if (xhr.status === 200 && xhr.response.size > 100) {
|
||||
uploadFile = xhr.response;
|
||||
uploadName = file.name.replace(/\.doc$/i, '.docx');
|
||||
doUpload();
|
||||
} else {
|
||||
reject(new Error('Конвертация .doc'));
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() { reject(new Error('Конвертер')); };
|
||||
xhr.send(file);
|
||||
} else {
|
||||
doUpload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* refreshSupps() — Обновить supplement_id/type для файлов в state.files.
|
||||
*
|
||||
* Запрашивает с бэкенда список supplement-записей для текущего contractId,
|
||||
* сопоставляет их с файлами по doc_id → document_id и проставляет supp_id, supp_type.
|
||||
* Вызывается после каждого upload и после apply-groups.
|
||||
*/
|
||||
async function refreshSupps() {
|
||||
if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; }
|
||||
try {
|
||||
var resp = await fetch(VM_API + '/api/supplements?contract_id=' + state.contractId);
|
||||
var data = await resp.json();
|
||||
console.log('refreshSupps: data=', data);
|
||||
if (data.ok && data.supplements) {
|
||||
data.supplements.forEach(function(supp) {
|
||||
console.log('refreshSupps: supp=', supp);
|
||||
for (var i = 0; i < state.files.length; i++) {
|
||||
if (state.files[i].doc_id === supp.document_id) {
|
||||
state.files[i].supp_id = supp.id;
|
||||
state.files[i].supp_type = supp.type;
|
||||
}
|
||||
}
|
||||
});
|
||||
render(state);
|
||||
}
|
||||
} catch(e) { console.log('refreshSupps error:', e); }
|
||||
}
|
||||
|
||||
/**
|
||||
* reconcileSelection(files, newFiles) — Чистая функция (Фаза 1).
|
||||
*
|
||||
* Согласование состава: оставляет в массиве files только те,
|
||||
* имена которых есть в newFiles. Остальные удаляются.
|
||||
*
|
||||
* Вход: files — текущий state.files (массив)
|
||||
* newFiles — FileList от <input> (массив File-объектов)
|
||||
* Выход: новый массив (НЕ мутирует входной)
|
||||
*
|
||||
* Вызывается из onFilesSelected() ПЕРЕД обработкой каждого файла.
|
||||
*/
|
||||
function reconcileSelection(files, newFiles) {
|
||||
var newNames = new Set(newFiles.map(function(f) { return f.name; }));
|
||||
return files.filter(function(f) {
|
||||
return newNames.has(f.name);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1).
|
||||
*
|
||||
* Применяет результат парсинга к файлу. Унифицирует две ветки (ZIP и обычную),
|
||||
* которые раньше дублировали этот код.
|
||||
*
|
||||
* Вход: entry — элемент state.files (мутируется)
|
||||
* parsed — { status, element_count, error } от бэкенда
|
||||
* elapsed — время парсинга в секундах (только для обычных файлов)
|
||||
* Выход: нет (мутирует entry на месте)
|
||||
*/
|
||||
function applyParseResult(entry, parsed, elapsed) {
|
||||
if (parsed && parsed.status === 'parsed') {
|
||||
entry.parsed = true;
|
||||
entry.parseInfo = parsed;
|
||||
entry.status = { kind: 'parsed', count: parsed.element_count };
|
||||
if (elapsed) entry.status.elapsed = elapsed;
|
||||
} else if (parsed && parsed.status === 'error') {
|
||||
entry.parseInfo = parsed;
|
||||
entry.status = { kind: 'error', text: parsed.error || 'ошибка парсинга' };
|
||||
} else {
|
||||
entry.status = { kind: 'error', text: 'Неизвестная ошибка' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1, упрощена).
|
||||
*
|
||||
* 1. Отправляет ZIP на бэкенд (только распаковка, без БД/парсинга)
|
||||
* 2. Для каждого файла из архива: base64 → Blob → File → addRegularFile()
|
||||
*
|
||||
* addRegularFile делает ВСЁ: upload, parse, дубликаты, статусы, render.
|
||||
* Никакого дублирования логики — единый путь для обычных и ZIP-файлов.
|
||||
*/
|
||||
async function addZipFile(file) {
|
||||
var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } };
|
||||
state.files.push(zipEntry);
|
||||
render(state);
|
||||
|
||||
try {
|
||||
// Шаг 1: распаковать ZIP на бэкенде
|
||||
var zipResp = await new Promise(function(resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', UNZIP_URL);
|
||||
xhr.responseType = 'json';
|
||||
xhr.onload = function() { resolve(xhr.response); };
|
||||
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);
|
||||
});
|
||||
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
|
||||
|
||||
// Подтверждение: показать первые 10 файлов + итог
|
||||
var preview = zipResp.files.slice(0, 10).map(function(f) { return ' • ' + f.filename + ' (' + (f.size/1024).toFixed(1) + ' KB)'; }).join('\n');
|
||||
if (total > 10) preview += '\n ... и ещё ' + (total - 10) + ' файлов';
|
||||
if (!confirm('Архив «' + file.name + '» — ' + total + ' файлов:\n\n' + preview + '\n\nЗагрузить эти файлы?')) {
|
||||
// Отмена — убрать строку ZIP
|
||||
var zipPos2 = state.files.indexOf(zipEntry);
|
||||
if (zipPos2 >= 0) state.files.splice(zipPos2, 1);
|
||||
render(state);
|
||||
return;
|
||||
}
|
||||
|
||||
// Шаг 2: обработать каждый файл из архива
|
||||
// Временная строка ZIP остаётся в таблице — обновляем её статус
|
||||
var total = zipResp.files.length;
|
||||
for (var zi = 0; zi < total; zi++) {
|
||||
var zf = zipResp.files[zi];
|
||||
zipEntry.status = { kind: 'unzipping' }; // обновляем статус
|
||||
render(state);
|
||||
|
||||
if (zf.error) continue;
|
||||
|
||||
// base64 → File → addRegularFile (единый путь)
|
||||
var byteStr = atob(zf.data_b64);
|
||||
var bytes = new Uint8Array(byteStr.length);
|
||||
for (var b = 0; b < byteStr.length; b++) bytes[b] = byteStr.charCodeAt(b);
|
||||
var mime = {docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',pdf:'application/pdf'}[zf.ext] || 'application/octet-stream';
|
||||
var extractedFile = new File([bytes], zf.filename, { type: mime, lastModified: Date.now() });
|
||||
|
||||
await addRegularFile(extractedFile, file.name);
|
||||
}
|
||||
|
||||
// Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов)
|
||||
var zipPos = state.files.indexOf(zipEntry);
|
||||
if (zipPos >= 0) state.files.splice(zipPos, 1);
|
||||
render(state);
|
||||
|
||||
} catch(ze) {
|
||||
// Ошибка — показать на строке ZIP (zipEntry всё ещё в state.files)
|
||||
zipEntry.status = { kind: 'error', text: 'ZIP: ' + ze.message };
|
||||
render(state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1).
|
||||
*
|
||||
* 1. Добавляет/заменяет файл в state.files
|
||||
* 2. Загружает через XHR (uploadFile) с индикатором прогресса
|
||||
* 3. После загрузки: проставляет doc_id, contractId
|
||||
* 4. applyParseResult — применяет результат парсинга
|
||||
*
|
||||
* Мутирует state.files, вызывает render(state) после каждого изменения.
|
||||
*/
|
||||
async function addRegularFile(file, zipSource) {
|
||||
// Создать запись с начальным статусом
|
||||
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 }, zip_source: zipSource || null };
|
||||
|
||||
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
|
||||
var dupKey = (zipSource || '') + '/' + file.name;
|
||||
var existingIdx = -1;
|
||||
for (var j = 0; j < state.files.length; j++) {
|
||||
var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name;
|
||||
if (ejKey === dupKey) { existingIdx = j; break; }
|
||||
}
|
||||
var rowIdx;
|
||||
if (existingIdx >= 0) {
|
||||
// Файл с таким именем уже есть — спросить пользователя
|
||||
if (!confirm('Файл «' + file.name + '» уже есть в списке.\n\nOK — перезаписать\nОтмена — пропустить')) {
|
||||
return; // пользователь выбрал «пропустить»
|
||||
}
|
||||
state.files[existingIdx] = entry;
|
||||
rowIdx = existingIdx;
|
||||
} else {
|
||||
state.files.push(entry);
|
||||
rowIdx = state.files.length - 1;
|
||||
}
|
||||
render(state);
|
||||
|
||||
try {
|
||||
// XHR-загрузка с прогрессом
|
||||
var resp = await uploadFile(file, function(pct) {
|
||||
state.files[rowIdx].status = { kind: 'uploading', pct: pct };
|
||||
render(state);
|
||||
}, zipSource);
|
||||
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
|
||||
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
||||
state.files[rowIdx].doc_id = resp.doc_id;
|
||||
|
||||
// Дубликат?
|
||||
if (resp && resp.duplicate_of) {
|
||||
state.files[rowIdx].status = { kind: 'duplicate' };
|
||||
render(state);
|
||||
return;
|
||||
}
|
||||
// Warning от парсинга?
|
||||
if (resp && resp.warning) {
|
||||
state.files[rowIdx].status = { kind: 'warning', text: resp.warning };
|
||||
render(state);
|
||||
} else {
|
||||
state.files[rowIdx].status = { kind: 'uploaded' };
|
||||
}
|
||||
state.files[rowIdx].uploaded = true;
|
||||
|
||||
// Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC)
|
||||
var t0 = Date.now();
|
||||
var pr = resp.parsed;
|
||||
var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null;
|
||||
applyParseResult(state.files[rowIdx], pr, elapsed);
|
||||
} catch(err) {
|
||||
state.files[rowIdx].status = { kind: 'error', text: err.message };
|
||||
}
|
||||
render(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
||||
*
|
||||
* Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected().
|
||||
* 1. Обновляет supplement_id/type через refreshSupps()
|
||||
* 2. Отмечает шаг «Загрузка» как готовый
|
||||
* 3. Активирует шаг «Классификация»
|
||||
* 4. Синхронизирует БД
|
||||
* 5. Показывает кнопки (LLM, классификация)
|
||||
*/
|
||||
async function finalizeUpload() {
|
||||
await refreshSupps();
|
||||
stepDone('stepUpload');
|
||||
stepActive('stepClassify');
|
||||
syncDB();
|
||||
|
||||
fileInput.value = '';
|
||||
fileInput.disabled = false;
|
||||
|
||||
var llmBtn = document.getElementById('llmBtn');
|
||||
llmBtn.style.display = 'flex';
|
||||
llmBtn.disabled = false;
|
||||
showClassifyBtn();
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* onFilesSelected(newFiles) — Оркестратор загрузки (Фаза 1).
|
||||
*
|
||||
* ПАТТЕРН:
|
||||
* 1. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный)
|
||||
* 2. finalizeUpload — завершить цикл
|
||||
*
|
||||
* НЕ удаляет существующие файлы — только добавляет новые.
|
||||
* Дубликаты обрабатываются через confirm() в addRegularFile.
|
||||
* Удаление — только вручную (кнопка ✕).
|
||||
*
|
||||
* Вызывается из fileInput.addEventListener('change', ...).
|
||||
*/
|
||||
async function onFilesSelected(newFiles) {
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
fileInput.disabled = true;
|
||||
|
||||
// Сбросить прогресс пайплайна при добавлении новых файлов
|
||||
resetStepper('stepUpload');
|
||||
|
||||
// Обработать каждый файл
|
||||
for (var i = 0; i < newFiles.length; i++) {
|
||||
var f = newFiles[i];
|
||||
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||
await addZipFile(f);
|
||||
} else {
|
||||
await addRegularFile(f);
|
||||
}
|
||||
}
|
||||
|
||||
// Завершить цикл
|
||||
await finalizeUpload();
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* groups.js — Модуль групп и карточек договоров (Фаза 2, decoupling-final-plan.md).
|
||||
*
|
||||
* ВЫНЕСЕНО из app.js:
|
||||
* - loadGroupsAction() — fetch групп, сохранить в state.groups, renderGroups
|
||||
* - renderGroups(state) — пересобрать ВСЕ карточки групп в diffBody
|
||||
* - renderGroupCard(g, gi) — чистая HTML-функция: карточка необработанной группы
|
||||
* - renderGroupCardDone(g, gi)— чистая HTML-функция: карточка обработанной группы
|
||||
*
|
||||
* ПАТТЕРН (decoupling-final-plan.md):
|
||||
* renderGroups пересобирает ВСЕ карточки целиком из state.groups.
|
||||
* Готовность группы: group.compare.status === 'done' (вместо markGroupDone).
|
||||
* Никакого window._groupsData — всё в state.groups.
|
||||
*
|
||||
* ЗАВИСИМОСТИ:
|
||||
* state.js → state (центральное состояние)
|
||||
* app_utils.js → escHtml
|
||||
*/
|
||||
|
||||
/**
|
||||
* loadGroupsAction() — Загрузить группы с бэкенда (Фаза 2).
|
||||
*
|
||||
* 1. fetch /api/groups?batch=...
|
||||
* 2. Сохранить в state.groups (бывший window._groupsData)
|
||||
* 3. Вызвать renderGroups(state) — пересобрать карточки
|
||||
*
|
||||
* Вызывается из runClassify() после успешной классификации.
|
||||
*/
|
||||
async function loadGroupsAction() {
|
||||
var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
|
||||
var data = await resp.json();
|
||||
if (!data.ok || !data.groups) return;
|
||||
|
||||
// Сохраняем в центральное состояние (вместо window._groupsData)
|
||||
state.groups = data.groups;
|
||||
|
||||
// Пересобрать ВСЕ карточки
|
||||
renderGroups(state);
|
||||
|
||||
stepDone('stepGroups');
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGroups(state) — Пересобрать ВСЕ карточки групп (Фаза 2).
|
||||
*
|
||||
* ПЕРЕСОБИРАЕТ весь diffBody.innerHTML заново из state.groups.
|
||||
* Никакого инкрементального обновления — всегда полная перестройка.
|
||||
*
|
||||
* Для каждой группы:
|
||||
* - Необработанная (group.compare.status !== 'done') → renderGroupCard()
|
||||
* - Обработанная (group.compare.status === 'done') → renderGroupCardDone()
|
||||
* - Нераспознанная (contract_number === '__unresolved__') → отдельный рендер
|
||||
*/
|
||||
function renderGroups(state) {
|
||||
var groups = state.groups;
|
||||
if (!groups) return;
|
||||
|
||||
var diffBody = document.getElementById('diffBody');
|
||||
var diffCard = document.getElementById('diffCard');
|
||||
diffCard.style.display = 'block';
|
||||
|
||||
var realGroups = groups.filter(function(g) { return g.contract_number !== '__unresolved__'; });
|
||||
var unresolvedGroup = groups.find(function(g) { return g.contract_number === '__unresolved__'; });
|
||||
|
||||
// Заголовок: количество групп + нераспознанные
|
||||
var html = '<div style="font-weight:600;margin-bottom:8px;">📋 Найдено групп: ' + realGroups.length +
|
||||
(unresolvedGroup ? ' | ⚠ ' + unresolvedGroup.documents.length + ' файлов не распознано' : '') +
|
||||
'</div>';
|
||||
|
||||
// Нераспознанная группа — всегда первая
|
||||
if (unresolvedGroup) {
|
||||
html += renderUnresolvedCard(unresolvedGroup);
|
||||
}
|
||||
|
||||
// Карточки обычных групп
|
||||
groups.forEach(function(g, gi) {
|
||||
if (g.contract_number === '__unresolved__') return;
|
||||
if (g.compare && g.compare.status === 'done') {
|
||||
html += renderGroupCardDone(g, gi);
|
||||
} else {
|
||||
html += renderGroupCard(g, gi);
|
||||
}
|
||||
});
|
||||
|
||||
diffBody.innerHTML = html;
|
||||
|
||||
// Один обработчик на все кнопки сравнения (event delegation не используется —
|
||||
// кнопки пересоздаются при каждом renderGroups)
|
||||
diffBody.querySelectorAll('.cmp-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var gi = parseInt(this.getAttribute('data-gi'));
|
||||
runCompareForGroup(gi, this);
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики сворачивания для готовых карточек
|
||||
diffBody.querySelectorAll('.cmp-header').forEach(function(header) {
|
||||
var gi = parseInt(header.getAttribute('data-gi'));
|
||||
if (isNaN(gi)) return;
|
||||
header.addEventListener('click', function() {
|
||||
var body = document.getElementById('cmpBody_' + gi);
|
||||
var arrow = document.getElementById('cmpArrow_' + gi);
|
||||
if (body) {
|
||||
if (body.style.display === 'none') {
|
||||
body.style.display = 'block';
|
||||
if (arrow) arrow.textContent = '▾';
|
||||
} else {
|
||||
body.style.display = 'none';
|
||||
if (arrow) arrow.textContent = '▸';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
/**
|
||||
* renderUnresolvedCard(group) — Чистая HTML-функция: карточка нераспознанных файлов.
|
||||
*
|
||||
* Вход: group — { contract_number: '__unresolved__', documents: [...] }
|
||||
* Выход: HTML-строка
|
||||
*/
|
||||
function renderUnresolvedCard(group) {
|
||||
var docsHtml = group.documents.map(function(d) {
|
||||
// Определить причину, почему файл не попал ни в одну группу
|
||||
var reason = '';
|
||||
if (d.parent_number) {
|
||||
reason = ' — нет базового договора №' + escHtml(d.parent_number);
|
||||
} else if (d.classify_status === 'failed') {
|
||||
reason = ' — ошибка классификации: ' + escHtml(d.error_message || '?');
|
||||
} else if (!d.doc_type || d.doc_type === 'other') {
|
||||
reason = ' — тип не определён';
|
||||
} else {
|
||||
reason = ' — нет matching-договора';
|
||||
}
|
||||
return '<div style="padding:2px 0;">' +
|
||||
'<span style="color:var(--muted);">[' + escHtml(d.doc_type || '?') + ']</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'<span style="color:var(--destructive);font-size:11px;">' + reason + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
return '<div style="border:1px solid var(--destructive);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div style="font-weight:600;margin-bottom:6px;">❓ Не распознано (' + group.documents.length + ' файлов)</div>' +
|
||||
'<div style="font-size:12px;">' + docsHtml + '</div>' +
|
||||
'<div style="font-size:11px;color:var(--muted);margin-top:4px;">Загрузите недостающие базовые договоры для этих номеров.</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGroupCard(group, gi) — Чистая HTML-функция: необработанная группа (Фаза 2).
|
||||
*
|
||||
* Показывает: номер договора, контрагента, список документов, кнопку «Сравнить».
|
||||
* Если группа в процессе сравнения (group.compare.status === 'running') — кнопка disabled.
|
||||
*
|
||||
* Вход: group — элемент state.groups
|
||||
* gi — индекс группы (для data-gi атрибута кнопки)
|
||||
* Выход: HTML-строка
|
||||
*/
|
||||
function renderGroupCard(group, gi) {
|
||||
var isRunning = group.compare && group.compare.status === 'running';
|
||||
|
||||
var html = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;">' +
|
||||
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
|
||||
'</div>' +
|
||||
'<div style="font-size:12px;">' +
|
||||
group.documents.map(function(d) {
|
||||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||
return '<div style="padding:2px 0;">' +
|
||||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.zip_source ? ' <span style="color:var(--brand-gray);font-size:10px;">[' + escHtml(d.zip_source) + ']</span>' : '') +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('') + '</div>' +
|
||||
'<button class="btn btn-primary cmp-btn" style="margin-top:6px;font-size:12px;height:28px;" data-gi="' + gi + '"' +
|
||||
(isRunning ? ' disabled' : '') + '>▶ Сравнить эту группу</button>' +
|
||||
'</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGroupCardDone(group, gi) — Чистая HTML-функция: обработанная группа (Фаза 2).
|
||||
*
|
||||
* Показывает: ✓ Готово (время), список документов (кликабельные — раскрывают секции),
|
||||
* тело сравнения (cmpBody) — сворачивается по клику на заголовок.
|
||||
*
|
||||
* Вход: group — элемент state.groups (group.compare.status === 'done')
|
||||
* gi — индекс группы
|
||||
* Выход: HTML-строка
|
||||
*
|
||||
* ЗАМЕНЯЕТ удалённую markGroupDone().
|
||||
* Готовность определяется по group.compare.status, а не по наличию кнопки.
|
||||
*/
|
||||
function renderGroupCardDone(group, gi) {
|
||||
var time = group.compare.totalTime || '?с';
|
||||
var bodyHTML = group.compare.bodyHTML || '';
|
||||
|
||||
var html = '<div style="border:1px solid var(--brand-gray);border-radius:6px;padding:10px;margin-bottom:8px;">' +
|
||||
'<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;" data-gi="' + gi + '">' +
|
||||
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
|
||||
'📄 Договор №' + escHtml(group.contract_number || '?') + ' — ' + escHtml(group.counterparty || 'контрагент не определён') +
|
||||
' <span style="font-weight:400;color:var(--green);font-size:11px;">✓ Готово (' + time + ')</span>' +
|
||||
'</div>' +
|
||||
'<div style="font-size:12px;">' +
|
||||
group.documents.map(function(d, di) {
|
||||
var typeLabel = {contract: 'договор', supplement: 'допсоглашение', specification: 'спецификация'}[d.doc_type] || d.doc_type;
|
||||
return '<div style="padding:2px 0;cursor:pointer;" onclick="var b=document.querySelectorAll(\'#cmpBody_' + gi + ' .diff-section-body\');if(b[' + di + '])b[' + di + '].style.display=b[' + di + '].style.display===\'none\'?\'block\':\'none\';">' +
|
||||
'<span style="color:var(--muted);">' + escHtml(typeLabel || '?') + '</span> ' +
|
||||
escHtml(d.filename) +
|
||||
(d.zip_source ? ' <span style="color:var(--brand-gray);font-size:10px;">[' + escHtml(d.zip_source) + ']</span>' : '') +
|
||||
(d.doc_date ? ' <span style="color:var(--muted);">' + escHtml(d.doc_date) + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('') + '</div>' +
|
||||
'<div class="cmp-body" id="cmpBody_' + gi + '" style="margin-top:8px;">' + bodyHTML + '</div>' +
|
||||
'</div>';
|
||||
|
||||
return html;
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Creator: CorelDRAW -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="82.3711mm" height="18.2443mm" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
|
||||
viewBox="0 0 8221.93 1821.07"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
|
||||
<defs>
|
||||
<style type="text/css">
|
||||
<![CDATA[
|
||||
.fil0 {fill:#001C34}
|
||||
]]>
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Слой_x0020_1">
|
||||
<metadata id="CorelCorpID_0Corel-Layer"/>
|
||||
<g id="_2283355517888">
|
||||
<path class="fil0" d="M477.49 479.69l-413.51 0 -63.98 276.48 178.44 0 1.17 1028.71 0 26.75 0.03 0 276.39 0 0 -871.72c0,-101.28 82.43,-183.69 183.75,-183.69l589.25 3.44c101.34,0 183.76,82.46 183.76,183.74l0 871.72 276.44 0 0 -871.72c0,-253.77 -206.46,-460.15 -460.05,-460.15l-589.52 -3.53 -162.17 0.45 0 -0.48z"/>
|
||||
<path class="fil0" d="M6664.65 1813.37l1181.04 0c212.14,0 376.24,-175.01 376.24,-387.08 0,-212.13 -172.55,-384.68 -384.69,-384.68l-653.66 0c-60.22,0 -109.16,-48.94 -109.16,-109.18l0 -66.18c0,-60.19 48.94,-109.14 109.16,-109.14l695.76 0 63.74 -275.49 -759.5 0c-212.13,0 -384.66,172.53 -384.66,384.63l0 66.18c0,212.13 172.53,384.67 384.66,384.67l653.66 0c60.2,0 109.2,49 109.2,109.19 0,60.13 -49,116.1 -109.2,116.1l-1118.9 0 -53.68 270.98z"/>
|
||||
<path class="fil0" d="M6200.79 483.5l-723.21 0c-215.88,0 -391.47,175.6 -391.47,391.51l0 485.5c0,248.38 202.05,450.4 450.4,450.4l989.38 0 62.13 -268.51 -1051.51 0c-96.41,0 -174.95,-85.37 -174.95,-181.88l-1.5 -39.46 923.62 0 308.51 -1.84 0 -237.95 0 -206.26c0,-215.91 -175.59,-391.51 -391.41,-391.51zm115.96 560.28l-955.19 0 0 -168.77c0,-63.96 52.07,-116.05 116.02,-116.05l723.21 0c63.91,0 115.96,52.08 115.96,116.05l0 168.77z"/>
|
||||
<path class="fil0" d="M4683.49 1194.24l0 168.28c0,100.92 -81.7,178.37 -182.67,178.37l-589.87 1.23c-93.18,0 -170.28,-69.96 -181.63,-160.09l0 -458.13c11.36,-90.1 88.46,-160.07 181.63,-160.07l589.44 3.5c100.97,0 183.1,82.18 183.1,183.1l0 30.42 0 213.4zm-1230.2 -345.53l-0.89 -795.03 276.91 -53.68 0 526.07c55.74,-24.16 117.03,-37.74 181.5,-37.74l589.71 3.5c252.69,0 458.42,205.72 458.42,458.59l0 30.42 0 213.4 0 168.28c0,252.86 -205.73,458.54 -458.42,458.54l-589.71 -3.5c-252.7,0 -458.41,-205.67 -458.41,-458.56l0 -171.61 0 -240.48 0.89 -98.2z"/>
|
||||
<path class="fil0" d="M3041.25 1150.84l0 204.28c0,100.93 -82.15,183.05 -183.11,183.05l-582.11 -3.46c-100.96,0 -183.11,-82.16 -183.11,-183.08l0 -67.42 1.48 0.24 0 -862.65 -276.91 53.68 0.12 103.41 -0.12 0.04 0 772.69c0,252.87 205.72,458.54 458.39,458.54l582.4 3.5c252.67,0 458.4,-205.68 458.4,-458.55l0 -70.79 0.71 0.12 0 -862.65 -276.91 53.68 0.76 675.35z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* state.js — Центральное состояние приложения.
|
||||
*
|
||||
* ПАТТЕРН (из decoupling-final-plan.md, Фаза 0):
|
||||
* action → мутация state → render(state)
|
||||
*
|
||||
* ПРАВИЛА:
|
||||
* 1. ВСЕ состояния приложения — только здесь, в объекте state.
|
||||
* 2. DOM — проекция state, никто не читает данные из DOM.
|
||||
* Если что-то нужно узнать — смотри в state, а не в element.innerHTML.
|
||||
* 3. Мутировал любое поле state → обязан вызвать render(state).
|
||||
* Никаких прямых манипуляций DOM в обход render().
|
||||
* 4. В любой момент console.log(state) показывает ВСЁ состояние приложения.
|
||||
*
|
||||
* СТРУКТУРА (Фаза 0 — минимальная, будет расширяться в Фазах 1-4):
|
||||
*
|
||||
* state.files — бывший fileQueue, массив загруженных файлов.
|
||||
* Каждый элемент: { name, size, lastModified, file, doc_id,
|
||||
* uploaded, parsed, parseInfo, supp_id, supp_type, status, zip_source }
|
||||
*
|
||||
* state.contractId — бывший contractId, ID контракта в БД.
|
||||
* Приходит с бэкенда после первого успешного upload.
|
||||
* null пока не загружен ни один файл.
|
||||
*
|
||||
* state.batchId — бывший batchId, уникальный ID сессии (UUID).
|
||||
* Используется для привязки всех файлов сессии при классификации.
|
||||
* Генерируется один раз при загрузке страницы.
|
||||
*
|
||||
* state.groups — бывший window._groupsData.
|
||||
* Массив групп после классификации. Каждая группа:
|
||||
* { contract_number, counterparty, documents[], unresolved? }
|
||||
* null если классификация ещё не запускалась.
|
||||
*
|
||||
* state._activeCompare — бывшие _activeCompareES и _activeCompareTimer.
|
||||
* Управляет ОДНИМ активным SSE-сравнением.
|
||||
* Правило: только одно сравнение одновременно — все кнопки блокируются.
|
||||
* { es: EventSource|null, timer: intervalId|null }
|
||||
*
|
||||
* state.ui — UI-состояние, не связанное с данными.
|
||||
* ui.steps: { upload, classify, groups, compare }
|
||||
* Каждый шаг: '○' (не начат) | '⏳' (в процессе) | '✓' (завершён)
|
||||
* В Фазе 4 будет renderStepper(state).
|
||||
*/
|
||||
var state = {
|
||||
// ── Файлы (бывший fileQueue) ──────────────────────────────
|
||||
files: [],
|
||||
|
||||
// ── Контракт (бывший contractId) ──────────────────────────
|
||||
contractId: null,
|
||||
|
||||
// ── Сессия (бывший batchId) ───────────────────────────────
|
||||
// crypto.randomUUID() — браузерный API, поддержка 92%+ (Chrome 92+, FF 95+, Safari 15.4+)
|
||||
batchId: crypto.randomUUID(),
|
||||
|
||||
// ── Группы (бывший window._groupsData) ────────────────────
|
||||
groups: null,
|
||||
|
||||
// ── Активное сравнение ────────────────────────────────────
|
||||
_activeCompare: {
|
||||
es: null, // EventSource (SSE-соединение с /process-v2)
|
||||
timer: null // setInterval ID для индикатора времени
|
||||
},
|
||||
|
||||
// ── UI-состояние ──────────────────────────────────────────
|
||||
ui: {
|
||||
steps: {
|
||||
upload: '○',
|
||||
classify: '○',
|
||||
groups: '○',
|
||||
compare: '○'
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user