v1.0.178: Фаза 0 — state + render(state). Введён state.js (центральное состояние), render(state) вызывает renderTable(). fileQueue/contractId/batchId/_activeCompare → state.*. app_utils.js адаптирован. Подробные комментарии из decoupling-final-plan.md.

This commit is contained in:
2026-06-25 08:44:52 +04:00
parent b994da8c4c
commit 7490568b4b
4 changed files with 193 additions and 95 deletions
+101 -84
View File
@@ -9,9 +9,10 @@ var SITE_URL = ''; // same origin for api calls
var fileInput = document.getElementById('fileInput'); var fileInput = document.getElementById('fileInput');
var fileTable = document.getElementById('fileTable'); var fileTable = document.getElementById('fileTable');
var fileQueue = []; // state.files — теперь в state.js (Фаза 0: state + render)
var contractId = null; // state.contractId — теперь в state.js (Фаза 0: state + render)
var batchId = crypto.randomUUID(); // классификация: привязка всех файлов сессии // state.batchId — теперь в state.js (Фаза 0: state + render)
// (batchId = crypto.randomUUID() — уникальный ID сессии для классификации)
// Автоочистка старых записей при загрузке страницы // Автоочистка старых записей при загрузке страницы
(async function cleanup() { (async function cleanup() {
@@ -20,9 +21,26 @@ var batchId = crypto.randomUUID(); // классификация: привяз
} catch(e) { /* ignore */ } } catch(e) { /* ignore */ }
})(); })();
// Синхронизация БД с таблицей — удалить всё, чего нет в fileQueue /**
* 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) {
renderTable();
}
// Синхронизация БД с таблицей — удалить всё, чего нет в state.files
async function syncDB() { async function syncDB() {
var keepIds = fileQueue.map(function(f) { return f.doc_id; }).filter(Boolean); var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean);
try { try {
await fetch(VM_API + '/api/sync', { await fetch(VM_API + '/api/sync', {
method: 'POST', method: 'POST',
@@ -55,10 +73,10 @@ function resetStepper(fromId) {
// вынесены в app_utils.js для облегчения анализа и отладки. // вынесены в app_utils.js для облегчения анализа и отладки.
function renderTable() { function renderTable() {
if (fileQueue.length === 0) { if (state.files.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>'; fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
} else { } else {
fileTable.innerHTML = fileQueue.map(function(f, i) { fileTable.innerHTML = state.files.map(function(f, i) {
var rows = '<tr id="row_' + i + '">' + var rows = '<tr id="row_' + i + '">' +
'<td class="name-cell" style="cursor:pointer;" onclick="toggleClassifyDetail(' + i + ')">' + '<td class="name-cell" style="cursor:pointer;" onclick="toggleClassifyDetail(' + i + ')">' +
(f.doc_id ? '<span id="expand_' + i + '" style="font-size:10px;margin-right:4px;">▸</span>' : '') + (f.doc_id ? '<span id="expand_' + i + '" style="font-size:10px;margin-right:4px;">▸</span>' : '') +
@@ -88,7 +106,7 @@ window.toggleClassifyDetail = async function(i) {
return; return;
} }
var f = fileQueue[i]; var f = state.files[i];
if (!f || !f.doc_id) return; if (!f || !f.doc_id) return;
detailRow.style.display = ''; detailRow.style.display = '';
@@ -149,10 +167,10 @@ fileInput.addEventListener('change', async function() {
// Удалить из очереди файлы, которых нет в новом выборе // Удалить из очереди файлы, которых нет в новом выборе
var newNames = new Set(newFiles.map(function(f) { return f.name; })); var newNames = new Set(newFiles.map(function(f) { return f.name; }));
fileQueue = fileQueue.filter(function(f) { state.files = state.files.filter(function(f) {
return newNames.has(f.name); return newNames.has(f.name);
}); });
renderTable(); render(state);
// Разблокировать кнопку классификации — состав файлов изменился // Разблокировать кнопку классификации — состав файлов изменился
showClassifyBtn(); showClassifyBtn();
// Сбросить прогресс пайплайна // Сбросить прогресс пайплайна
@@ -165,9 +183,9 @@ fileInput.addEventListener('change', async function() {
if (isZip) { if (isZip) {
// ZIP: показать временную строку, распаковать, убрать // ZIP: показать временную строку, распаковать, убрать
var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' }; var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' };
fileQueue.push(zipEntry); state.files.push(zipEntry);
var zipIdx = fileQueue.length - 1; var zipIdx = state.files.length - 1;
renderTable(); render(state);
try { try {
var zipResp = await new Promise(function(resolve, reject) { var zipResp = await new Promise(function(resolve, reject) {
@@ -184,8 +202,8 @@ fileInput.addEventListener('change', async function() {
if (!zipData.ok || !zipData.files) throw new Error('unzip failed'); if (!zipData.ok || !zipData.files) throw new Error('unzip failed');
// Убрать строку ZIP из таблицы // Убрать строку ZIP из таблицы
fileQueue.splice(zipIdx, 1); state.files.splice(zipIdx, 1);
renderTable(); render(state);
// Добавить файлы из архива, пропуская дубликаты // Добавить файлы из архива, пропуская дубликаты
for (var zi = 0; zi < zipData.files.length; zi++) { for (var zi = 0; zi < zipData.files.length; zi++) {
@@ -194,8 +212,8 @@ fileInput.addEventListener('change', async function() {
// Проверить дубликат по имени // Проверить дубликат по имени
var dup = -1; var dup = -1;
for (var dj = 0; dj < fileQueue.length; dj++) { for (var dj = 0; dj < state.files.length; dj++) {
if (fileQueue[dj].name === zf.filename) { dup = dj; break; } if (state.files[dj].name === zf.filename) { dup = dj; break; }
} }
if (dup >= 0) continue; // уже есть — не добавляем if (dup >= 0) continue; // уже есть — не добавляем
@@ -204,26 +222,26 @@ fileInput.addEventListener('change', async function() {
doc_id: zf.doc_id, uploaded: true, doc_id: zf.doc_id, uploaded: true,
status: '⏳ парсинг...' status: '⏳ парсинг...'
}; };
fileQueue.push(zEntry); state.files.push(zEntry);
var zIdx = fileQueue.length - 1; var zIdx = state.files.length - 1;
if (!contractId && zf.contract_id) contractId = zf.contract_id; if (!state.contractId && zf.contract_id) state.contractId = zf.contract_id;
renderTable(); render(state);
// Авто-парсинг: VM Python парсит ВСЁ // Авто-парсинг: VM Python парсит ВСЁ
var zpr = zf.parsed; var zpr = zf.parsed;
if (zpr && zpr.status === 'parsed') { if (zpr && zpr.status === 'parsed') {
fileQueue[zIdx].parsed = true; state.files[zIdx].parsed = true;
fileQueue[zIdx].status = '<span class="status-ok">✓ ' + zpr.element_count + ' эл.</span>'; state.files[zIdx].status = '<span class="status-ok">✓ ' + zpr.element_count + ' эл.</span>';
} else if (zpr && zpr.status === 'error') { } else if (zpr && zpr.status === 'error') {
fileQueue[zIdx].status = '<span class="status-err">✗ ' + (zpr.error || 'ошибка парсинга') + '</span>'; state.files[zIdx].status = '<span class="status-err">✗ ' + (zpr.error || 'ошибка парсинга') + '</span>';
} else { } else {
fileQueue[zIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>'; state.files[zIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
} }
renderTable(); render(state);
} }
} catch(ze) { } catch(ze) {
fileQueue[zipIdx].status = '<span class="status-err">✗ ZIP: ' + ze.message + '</span>'; state.files[zipIdx].status = '<span class="status-err">✗ ZIP: ' + ze.message + '</span>';
renderTable(); render(state);
} }
continue; // ZIP обработан, переходим к следующему файлу continue; // ZIP обработан, переходим к следующему файлу
} }
@@ -231,55 +249,55 @@ fileInput.addEventListener('change', async function() {
// Обычный файл (не ZIP) // Обычный файл (не ZIP)
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' }; var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
var existingIdx = -1; var existingIdx = -1;
for (var j = 0; j < fileQueue.length; j++) { for (var j = 0; j < state.files.length; j++) {
if (fileQueue[j].name === f.name) { existingIdx = j; break; } if (state.files[j].name === f.name) { existingIdx = j; break; }
} }
var rowIdx; var rowIdx;
if (existingIdx >= 0) { if (existingIdx >= 0) {
fileQueue[existingIdx] = entry; state.files[existingIdx] = entry;
rowIdx = existingIdx; rowIdx = existingIdx;
} else { } else {
fileQueue.push(entry); state.files.push(entry);
rowIdx = fileQueue.length - 1; rowIdx = state.files.length - 1;
} }
renderTable(); render(state);
try { try {
var resp = await uploadFile(f, function(pct) { var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '↑ ' + pct + '%'; state.files[rowIdx].status = '↑ ' + pct + '%';
renderTable(); render(state);
}); });
// Python API returns lowercase keys (doc_id, contract_id, parsed) // Python API returns lowercase keys (doc_id, contract_id, parsed)
if (resp && resp.contract_id) contractId = resp.contract_id; if (resp && resp.contract_id) state.contractId = resp.contract_id;
fileQueue[rowIdx].doc_id = resp.doc_id; state.files[rowIdx].doc_id = resp.doc_id;
fileQueue[rowIdx].status = '<span class="status-ok">✓</span>'; state.files[rowIdx].status = '<span class="status-ok">✓</span>';
fileQueue[rowIdx].uploaded = true; state.files[rowIdx].uploaded = true;
// Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC) // Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC)
var t0 = Date.now(); var t0 = Date.now();
var pr = resp.parsed; var pr = resp.parsed;
if (pr && pr.status === 'parsed') { if (pr && pr.status === 'parsed') {
var elapsed = ((Date.now() - t0) / 1000).toFixed(1); var elapsed = ((Date.now() - t0) / 1000).toFixed(1);
fileQueue[rowIdx].parsed = true; state.files[rowIdx].parsed = true;
fileQueue[rowIdx].parseInfo = pr; state.files[rowIdx].parseInfo = pr;
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)</span>'; state.files[rowIdx].status = '<span class="status-ok">✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)</span>';
} else if (pr && pr.status === 'error') { } else if (pr && pr.status === 'error') {
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + (pr.error || 'ошибка парсинга') + '</span>'; state.files[rowIdx].status = '<span class="status-err">✗ ' + (pr.error || 'ошибка парсинга') + '</span>';
fileQueue[rowIdx].parseInfo = pr; state.files[rowIdx].parseInfo = pr;
} else { } else {
fileQueue[rowIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>'; state.files[rowIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
} }
} catch(err) { } catch(err) {
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>'; state.files[rowIdx].status = '<span class="status-err">✗ ' + err.message + '</span>';
} }
renderTable(); render(state);
} }
await refreshSupps(); await refreshSupps();
stepDone('stepUpload'); stepDone('stepUpload');
stepActive('stepClassify'); stepActive('stepClassify');
// Синхронизировать БД с таблицей — удалить всё, чего нет в fileQueue // Синхронизировать БД с таблицей — удалить всё, чего нет в state.files
syncDB(); syncDB();
fileInput.value = ''; fileInput.value = '';
@@ -322,7 +340,7 @@ async function runClassify() {
// Poll progress каждые 2с // Poll progress каждые 2с
var pollTimer = setInterval(async function() { var pollTimer = setInterval(async function() {
try { try {
var r = await fetch(VM_API + '/api/batch-progress?batch=' + batchId); var r = await fetch(VM_API + '/api/batch-progress?batch=' + state.batchId);
var d = await r.json(); var d = await r.json();
if (d.ok && d.counts) { if (d.ok && d.counts) {
var done = (d.counts.classified || 0) + (d.counts.failed || 0); var done = (d.counts.classified || 0) + (d.counts.failed || 0);
@@ -339,7 +357,7 @@ async function runClassify() {
var resp = await fetch(VM_API + '/api/classify-batch', { var resp = await fetch(VM_API + '/api/classify-batch', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({batch_id: batchId}) body: JSON.stringify({batch_id: state.batchId})
}); });
var data = await resp.json(); var data = await resp.json();
clearInterval(timer); clearInterval(timer);
@@ -362,7 +380,7 @@ async function runClassify() {
} }
async function loadGroups() { async function loadGroups() {
var resp = await fetch(VM_API + '/api/groups?batch=' + batchId); var resp = await fetch(VM_API + '/api/groups?batch=' + state.batchId);
var data = await resp.json(); var data = await resp.json();
if (!data.ok || !data.groups) return; if (!data.ok || !data.groups) return;
@@ -375,7 +393,7 @@ async function loadGroups() {
stepDone('stepGroups'); stepDone('stepGroups');
// Сохраняем группы для compare // Сохраняем группы для compare
window._groupsData = data.groups; state.groups = data.groups;
data.groups.forEach(function(g, gi) { data.groups.forEach(function(g, gi) {
var isUnresolved = g.contract_number === '__unresolved__'; var isUnresolved = g.contract_number === '__unresolved__';
@@ -442,7 +460,7 @@ function markGroupDone(gi, time) {
var bodyEl = document.getElementById('cmpBody_' + gi); var bodyEl = document.getElementById('cmpBody_' + gi);
var bodyHTML = bodyEl ? bodyEl.innerHTML : ''; var bodyHTML = bodyEl ? bodyEl.innerHTML : '';
var card = bodyEl.parentNode; var card = bodyEl.parentNode;
var g = window._groupsData[gi]; var g = state.groups[gi];
card.innerHTML = '<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;">' + card.innerHTML = '<div class="cmp-header" style="font-weight:600;margin-bottom:6px;cursor:pointer;">' +
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' + '<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
'📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') + '📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') +
@@ -475,16 +493,15 @@ function markGroupDone(gi, time) {
} }
// Текущий активный EventSource (только один одновременно) // Текущий активный EventSource (только один одновременно)
var _activeCompareES = null; // state._activeCompare — теперь в state.js (Фаза 0: state + render)
var _activeCompareTimer = null;
window.runCompareForGroup = async function(gi, btn) { window.runCompareForGroup = async function(gi, btn) {
var group = window._groupsData[gi]; var group = state.groups[gi];
if (!group || group.contract_number === '__unresolved__') return; if (!group || group.contract_number === '__unresolved__') return;
// Остановить предыдущее сравнение // Остановить предыдущее сравнение
if (_activeCompareES) { _activeCompareES.close(); _activeCompareES = null; } if (state._activeCompare.es) { state._activeCompare.es.close(); state._activeCompare.es = null; }
if (_activeCompareTimer) { clearInterval(_activeCompareTimer); _activeCompareTimer = null; } if (state._activeCompare.timer) { clearInterval(state._activeCompare.timer); state._activeCompare.timer = null; }
// Свернуть ТОЛЬКО тело текущей группы // Свернуть ТОЛЬКО тело текущей группы
var curBody = document.getElementById('cmpBody_' + gi); var curBody = document.getElementById('cmpBody_' + gi);
@@ -498,7 +515,7 @@ window.runCompareForGroup = async function(gi, btn) {
var ar = await fetch(VM_API + '/api/apply-groups', { var ar = await fetch(VM_API + '/api/apply-groups', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({batch_id: batchId, groups: [group]}) body: JSON.stringify({batch_id: state.batchId, groups: [group]})
}); });
var ad = await ar.json(); var ad = await ar.json();
if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) { if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) {
@@ -530,12 +547,12 @@ window.runCompareForGroup = async function(gi, btn) {
cmpBody.appendChild(statusEl); cmpBody.appendChild(statusEl);
var start = Date.now(); var start = Date.now();
_activeCompareTimer = setInterval(function() { state._activeCompare.timer = setInterval(function() {
statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с'; statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с';
}, 200); }, 200);
var es = new EventSource(VM_API + '/process-v2?contract_id=' + cid); var es = new EventSource(VM_API + '/process-v2?contract_id=' + cid);
_activeCompareES = es; state._activeCompare.es = es;
var sections = {}; var sections = {};
es.onmessage = function(e) { es.onmessage = function(e) {
@@ -573,8 +590,8 @@ window.runCompareForGroup = async function(gi, btn) {
} }
} }
else if (d.type === 'done') { else if (d.type === 'done') {
clearInterval(_activeCompareTimer); _activeCompareTimer = null; clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
es.close(); _activeCompareES = null; es.close(); state._activeCompare.es = null;
statusEl.textContent = '✓ ' + d.total_time_s + 'с'; statusEl.textContent = '✓ ' + d.total_time_s + 'с';
statusEl.style.color = 'var(--green)'; statusEl.style.color = 'var(--green)';
// Заменить карточку на обработанную (другая вёрстка, нет кнопок) // Заменить карточку на обработанную (другая вёрстка, нет кнопок)
@@ -584,16 +601,16 @@ window.runCompareForGroup = async function(gi, btn) {
stepDone('stepCompare'); stepDone('stepCompare');
} }
else if (d.type === 'error') { else if (d.type === 'error') {
clearInterval(_activeCompareTimer); _activeCompareTimer = null; clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
es.close(); _activeCompareES = null; es.close(); state._activeCompare.es = null;
cmpBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + d.message + '</div>'; cmpBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + d.message + '</div>';
btn.innerHTML = '✗'; btn.disabled = false; btn.innerHTML = '✗'; btn.disabled = false;
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
} }
}; };
es.onerror = function() { es.onerror = function() {
clearInterval(_activeCompareTimer); _activeCompareTimer = null; clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
es.close(); _activeCompareES = null; es.close(); state._activeCompare.es = null;
btn.innerHTML = '✗ соединение'; btn.disabled = false; btn.innerHTML = '✗ соединение'; btn.disabled = false;
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; }); document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
}; };
@@ -610,8 +627,8 @@ function uploadFile(file, onProgress) {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
var fd = new FormData(); var fd = new FormData();
fd.append('files', uploadFile, uploadName); fd.append('files', uploadFile, uploadName);
if (contractId) fd.append('contract_id', contractId); if (state.contractId) fd.append('contract_id', state.contractId);
fd.append('batch_id', batchId); fd.append('batch_id', state.batchId);
xhr.open('POST', UPLOAD_URL); xhr.open('POST', UPLOAD_URL);
xhr.upload.onprogress = function(e) { xhr.upload.onprogress = function(e) {
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100)); if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
@@ -652,7 +669,7 @@ function uploadFile(file, onProgress) {
// ── LLM: Общее сравнение (Event Sourcing) ──────────────────────── // ── LLM: Общее сравнение (Event Sourcing) ────────────────────────
document.getElementById('llmBtn').addEventListener('click', function() { document.getElementById('llmBtn').addEventListener('click', function() {
if (!contractId) return; if (!state.contractId) return;
var btn = this; var btn = this;
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Сравнение...'; btn.innerHTML = '<span class="spinner"></span> Сравнение...';
@@ -669,8 +686,8 @@ document.getElementById('llmBtn').addEventListener('click', function() {
compareStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с'; compareStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
}, 200); }, 200);
var order = fileQueue.map(function(f) { return f.supp_id; }).filter(Boolean).join(','); var order = state.files.map(function(f) { return f.supp_id; }).filter(Boolean).join(',');
var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + contractId + (order ? '&order=' + encodeURIComponent(order) : '')); var es = new EventSource('https://contracts.kube5s.ru/process-v2?contract_id=' + state.contractId + (order ? '&order=' + encodeURIComponent(order) : ''));
var sections = {}; // supplement_id → DOM element var sections = {}; // supplement_id → DOM element
es.onmessage = function(e) { es.onmessage = function(e) {
@@ -723,7 +740,7 @@ document.getElementById('llmBtn').addEventListener('click', function() {
btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово'; btn.innerHTML = '<i data-lucide="check" style="width:16px;height:16px;"></i> ✓ Готово';
stepDone('stepCompare'); stepDone('stepCompare');
if (d.total_unresolved > 0) { if (d.total_unresolved > 0) {
compareBody.innerHTML += '<div style="margin-top:8px;color:#eab308;">⚠ ' + d.total_unresolved + ' неразрешённых строк — <a href=\"/resolve.cfm?contract_id=' + contractId + '\">разобрать</a></div>'; 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'; document.getElementById('chatCard').style.display = 'block';
lucide.createIcons(); lucide.createIcons();
@@ -751,22 +768,22 @@ document.getElementById('llmBtn').addEventListener('click', function() {
// ── Обновить supplement_id/type для radio ────────────────── // ── Обновить supplement_id/type для radio ──────────────────
async function refreshSupps() { async function refreshSupps() {
if (!contractId) { console.log('refreshSupps: no contractId'); return; } if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; }
try { try {
var resp = await fetch(VM_API + '/api/supplements?contract_id=' + contractId); var resp = await fetch(VM_API + '/api/supplements?contract_id=' + state.contractId);
var data = await resp.json(); var data = await resp.json();
console.log('refreshSupps: data=', data); console.log('refreshSupps: data=', data);
if (data.ok && data.supplements) { if (data.ok && data.supplements) {
data.supplements.forEach(function(supp) { data.supplements.forEach(function(supp) {
console.log('refreshSupps: supp=', supp); console.log('refreshSupps: supp=', supp);
for (var i = 0; i < fileQueue.length; i++) { for (var i = 0; i < state.files.length; i++) {
if (fileQueue[i].doc_id === supp.document_id) { if (state.files[i].doc_id === supp.document_id) {
fileQueue[i].supp_id = supp.id; state.files[i].supp_id = supp.id;
fileQueue[i].supp_type = supp.type; state.files[i].supp_type = supp.type;
} }
} }
}); });
renderTable(); render(state);
} }
} catch(e) { console.log('refreshSupps error:', e); } } catch(e) { console.log('refreshSupps error:', e); }
} }
@@ -775,7 +792,7 @@ async function refreshSupps() {
document.getElementById('chatSend').addEventListener('click', function() { document.getElementById('chatSend').addEventListener('click', function() {
var input = document.getElementById('chatInput'); var input = document.getElementById('chatInput');
var q = input.value.trim(); var q = input.value.trim();
if (!q || !contractId) return; if (!q || !state.contractId) return;
var msgs = document.getElementById('chatMessages'); var msgs = document.getElementById('chatMessages');
msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>'; msgs.innerHTML += '<div style="color:var(--brand-primary);margin-top:4px;"><strong>Вы:</strong> ' + q + '</div>';
input.value = ''; input.value = '';
@@ -785,7 +802,7 @@ document.getElementById('chatSend').addEventListener('click', function() {
var fd = new FormData(); var fd = new FormData();
fd.append('question', q); fd.append('question', q);
fetch('/chat.cfm?contract_id=' + contractId, { method: 'POST', body: fd }) fetch('/chat.cfm?contract_id=' + state.contractId, { method: 'POST', body: fd })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(d) { .then(function(d) {
msgs.lastChild.remove(); msgs.lastChild.remove();
@@ -811,7 +828,7 @@ function closeModal(e) {
} }
async function showText(i) { async function showText(i) {
var f = fileQueue[i]; var f = state.files[i];
var docId = f.doc_id; var docId = f.doc_id;
document.getElementById('modalTitle').textContent = f.name; document.getElementById('modalTitle').textContent = f.name;
document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>'; document.getElementById('modalBody').innerHTML = '<span style="color:var(--muted);">Загрузка...</span>';
+15 -8
View File
@@ -28,11 +28,14 @@ function formatDate(ts) {
/** /**
* Удаляет файл из очереди по индексу. * Удаляет файл из очереди по индексу.
* Вызывается по кнопке ✕ в таблице файлов. * Вызывается по кнопке ✕ в таблице файлов.
*
* Фаза 0 (state + render): мутирует state.files, затем render(state).
* Если очередь опустела — сбрасывает state.contractId.
*/ */
function removeFile(i) { function removeFile(i) {
fileQueue.splice(i, 1); state.files.splice(i, 1);
if (fileQueue.length === 0) contractId = null; if (state.files.length === 0) state.contractId = null;
renderTable(); render(state);
// Синхронизировать БД с таблицей // Синхронизировать БД с таблицей
if (typeof syncDB === 'function') syncDB(); if (typeof syncDB === 'function') syncDB();
// Сбросить прогресс при изменении состава файлов // Сбросить прогресс при изменении состава файлов
@@ -45,21 +48,25 @@ window.removeFile = removeFile;
/** /**
* Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки). * Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки).
* В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация. * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
*
* Фаза 0 (state + render): мутирует state.files, затем render(state).
*/ */
function moveUp(i) { function moveUp(i) {
if (i <= 0) return; if (i <= 0) return;
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp; var tmp = state.files[i]; state.files[i] = state.files[i-1]; state.files[i-1] = tmp;
renderTable(); render(state);
} }
/** /**
* Переместить файл на одну позицию ВНИЗ (сохранено для возможного возврата ручной сортировки). * Переместить файл на одну позицию ВНИЗ (сохранено для возможного возврата ручной сортировки).
* В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация. * В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
*
* Фаза 0 (state + render): мутирует state.files, затем render(state).
*/ */
function moveDown(i) { function moveDown(i) {
if (i >= fileQueue.length - 1) return; if (i >= state.files.length - 1) return;
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp; var tmp = state.files[i]; state.files[i] = state.files[i+1]; state.files[i+1] = tmp;
renderTable(); render(state);
} }
window.moveUp = moveUp; window.moveUp = moveUp;
window.moveDown = moveDown; window.moveDown = moveDown;
+73
View File
@@ -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 }
*
* 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: '○'
}
}
};
+4 -3
View File
@@ -75,7 +75,7 @@
<body> <body>
<div class="topbar"> <div class="topbar">
<img src="/nubes-logo.svg" alt="Nubes"> <img src="/nubes-logo.svg" alt="Nubes">
<span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.177</span></span> <span class="title">Сверка договоров — LLM AI-driven Event Sourcing <span style="font-weight:400;color:var(--muted);font-size:12px;">v1.0.178</span></span>
<div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);"> <div id="pipelineStepper" style="display:flex;gap:8px;font-size:11px;align-items:center;color:var(--muted);">
<span id="stepUpload">○ Загрузка</span><span>→</span> <span id="stepUpload">○ Загрузка</span><span>→</span>
<span id="stepClassify">○ Классификация</span><span>→</span> <span id="stepClassify">○ Классификация</span><span>→</span>
@@ -245,7 +245,8 @@
</div> </div>
</div> </div>
<script src="https://contracts.kube5s.ru/static/app_utils.js?v=1.0.177"></script> <script src="https://contracts.kube5s.ru/static/state.js?v=1.0.178"></script>
<script src="https://contracts.kube5s.ru/static/app.js?v=1.0.177"></script> <script src="https://contracts.kube5s.ru/static/app_utils.js?v=1.0.178"></script>
<script src="https://contracts.kube5s.ru/static/app.js?v=1.0.178"></script>
</body> </body>
</html> </html>