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 fileTable = document.getElementById('fileTable');
var fileQueue = [];
var contractId = null;
var batchId = crypto.randomUUID(); // классификация: привязка всех файлов сессии
// 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() {
@@ -20,9 +21,26 @@ var batchId = crypto.randomUUID(); // классификация: привяз
} 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() {
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 {
await fetch(VM_API + '/api/sync', {
method: 'POST',
@@ -55,10 +73,10 @@ function resetStepper(fromId) {
// вынесены в app_utils.js для облегчения анализа и отладки.
function renderTable() {
if (fileQueue.length === 0) {
if (state.files.length === 0) {
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
} else {
fileTable.innerHTML = fileQueue.map(function(f, i) {
fileTable.innerHTML = state.files.map(function(f, i) {
var rows = '<tr id="row_' + 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>' : '') +
@@ -88,7 +106,7 @@ window.toggleClassifyDetail = async function(i) {
return;
}
var f = fileQueue[i];
var f = state.files[i];
if (!f || !f.doc_id) return;
detailRow.style.display = '';
@@ -149,10 +167,10 @@ fileInput.addEventListener('change', async function() {
// Удалить из очереди файлы, которых нет в новом выборе
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);
});
renderTable();
render(state);
// Разблокировать кнопку классификации — состав файлов изменился
showClassifyBtn();
// Сбросить прогресс пайплайна
@@ -165,9 +183,9 @@ fileInput.addEventListener('change', async function() {
if (isZip) {
// ZIP: показать временную строку, распаковать, убрать
var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: '⏳ распаковка...' };
fileQueue.push(zipEntry);
var zipIdx = fileQueue.length - 1;
renderTable();
state.files.push(zipEntry);
var zipIdx = state.files.length - 1;
render(state);
try {
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');
// Убрать строку ZIP из таблицы
fileQueue.splice(zipIdx, 1);
renderTable();
state.files.splice(zipIdx, 1);
render(state);
// Добавить файлы из архива, пропуская дубликаты
for (var zi = 0; zi < zipData.files.length; zi++) {
@@ -194,8 +212,8 @@ fileInput.addEventListener('change', async function() {
// Проверить дубликат по имени
var dup = -1;
for (var dj = 0; dj < fileQueue.length; dj++) {
if (fileQueue[dj].name === zf.filename) { dup = dj; break; }
for (var dj = 0; dj < state.files.length; dj++) {
if (state.files[dj].name === zf.filename) { dup = dj; break; }
}
if (dup >= 0) continue; // уже есть — не добавляем
@@ -204,26 +222,26 @@ fileInput.addEventListener('change', async function() {
doc_id: zf.doc_id, uploaded: true,
status: '⏳ парсинг...'
};
fileQueue.push(zEntry);
var zIdx = fileQueue.length - 1;
if (!contractId && zf.contract_id) contractId = zf.contract_id;
renderTable();
state.files.push(zEntry);
var zIdx = state.files.length - 1;
if (!state.contractId && zf.contract_id) state.contractId = zf.contract_id;
render(state);
// Авто-парсинг: VM Python парсит ВСЁ
var zpr = zf.parsed;
if (zpr && zpr.status === 'parsed') {
fileQueue[zIdx].parsed = true;
fileQueue[zIdx].status = '<span class="status-ok">✓ ' + zpr.element_count + ' эл.</span>';
state.files[zIdx].parsed = true;
state.files[zIdx].status = '<span class="status-ok">✓ ' + zpr.element_count + ' эл.</span>';
} 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 {
fileQueue[zIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
state.files[zIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
}
renderTable();
render(state);
}
} catch(ze) {
fileQueue[zipIdx].status = '<span class="status-err">✗ ZIP: ' + ze.message + '</span>';
renderTable();
state.files[zipIdx].status = '<span class="status-err">✗ ZIP: ' + ze.message + '</span>';
render(state);
}
continue; // ZIP обработан, переходим к следующему файлу
}
@@ -231,55 +249,55 @@ fileInput.addEventListener('change', async function() {
// Обычный файл (не ZIP)
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: '↑ 0%' };
var existingIdx = -1;
for (var j = 0; j < fileQueue.length; j++) {
if (fileQueue[j].name === f.name) { existingIdx = j; break; }
for (var j = 0; j < state.files.length; j++) {
if (state.files[j].name === f.name) { existingIdx = j; break; }
}
var rowIdx;
if (existingIdx >= 0) {
fileQueue[existingIdx] = entry;
state.files[existingIdx] = entry;
rowIdx = existingIdx;
} else {
fileQueue.push(entry);
rowIdx = fileQueue.length - 1;
state.files.push(entry);
rowIdx = state.files.length - 1;
}
renderTable();
render(state);
try {
var resp = await uploadFile(f, function(pct) {
fileQueue[rowIdx].status = '↑ ' + pct + '%';
renderTable();
state.files[rowIdx].status = '↑ ' + pct + '%';
render(state);
});
// Python API returns lowercase keys (doc_id, contract_id, parsed)
if (resp && resp.contract_id) contractId = resp.contract_id;
fileQueue[rowIdx].doc_id = resp.doc_id;
fileQueue[rowIdx].status = '<span class="status-ok">✓</span>';
fileQueue[rowIdx].uploaded = true;
if (resp && resp.contract_id) state.contractId = resp.contract_id;
state.files[rowIdx].doc_id = resp.doc_id;
state.files[rowIdx].status = '<span class="status-ok">✓</span>';
state.files[rowIdx].uploaded = true;
// Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC)
var t0 = Date.now();
var pr = resp.parsed;
if (pr && pr.status === 'parsed') {
var elapsed = ((Date.now() - t0) / 1000).toFixed(1);
fileQueue[rowIdx].parsed = true;
fileQueue[rowIdx].parseInfo = pr;
fileQueue[rowIdx].status = '<span class="status-ok">✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)</span>';
state.files[rowIdx].parsed = true;
state.files[rowIdx].parseInfo = pr;
state.files[rowIdx].status = '<span class="status-ok">✓ ' + pr.element_count + ' эл. (' + elapsed + 'с)</span>';
} else if (pr && pr.status === 'error') {
fileQueue[rowIdx].status = '<span class="status-err">✗ ' + (pr.error || 'ошибка парсинга') + '</span>';
fileQueue[rowIdx].parseInfo = pr;
state.files[rowIdx].status = '<span class="status-err">✗ ' + (pr.error || 'ошибка парсинга') + '</span>';
state.files[rowIdx].parseInfo = pr;
} else {
fileQueue[rowIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
state.files[rowIdx].status = '<span class="status-err">✗ Неизвестная ошибка</span>';
}
} 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();
stepDone('stepUpload');
stepActive('stepClassify');
// Синхронизировать БД с таблицей — удалить всё, чего нет в fileQueue
// Синхронизировать БД с таблицей — удалить всё, чего нет в state.files
syncDB();
fileInput.value = '';
@@ -322,7 +340,7 @@ async function runClassify() {
// Poll progress каждые 2с
var pollTimer = setInterval(async function() {
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();
if (d.ok && d.counts) {
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', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({batch_id: batchId})
body: JSON.stringify({batch_id: state.batchId})
});
var data = await resp.json();
clearInterval(timer);
@@ -362,7 +380,7 @@ async function runClassify() {
}
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();
if (!data.ok || !data.groups) return;
@@ -375,7 +393,7 @@ async function loadGroups() {
stepDone('stepGroups');
// Сохраняем группы для compare
window._groupsData = data.groups;
state.groups = data.groups;
data.groups.forEach(function(g, gi) {
var isUnresolved = g.contract_number === '__unresolved__';
@@ -442,7 +460,7 @@ function markGroupDone(gi, time) {
var bodyEl = document.getElementById('cmpBody_' + gi);
var bodyHTML = bodyEl ? bodyEl.innerHTML : '';
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;">' +
'<span class="cmp-arrow" id="cmpArrow_' + gi + '" style="font-size:10px;margin-right:4px;">▾</span>' +
'📄 Договор №' + escHtml(g.contract_number || '?') + ' — ' + escHtml(g.counterparty || 'контрагент не определён') +
@@ -475,16 +493,15 @@ function markGroupDone(gi, time) {
}
// Текущий активный EventSource (только один одновременно)
var _activeCompareES = null;
var _activeCompareTimer = null;
// state._activeCompare — теперь в state.js (Фаза 0: state + render)
window.runCompareForGroup = async function(gi, btn) {
var group = window._groupsData[gi];
var group = state.groups[gi];
if (!group || group.contract_number === '__unresolved__') return;
// Остановить предыдущее сравнение
if (_activeCompareES) { _activeCompareES.close(); _activeCompareES = null; }
if (_activeCompareTimer) { clearInterval(_activeCompareTimer); _activeCompareTimer = null; }
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);
@@ -498,7 +515,7 @@ window.runCompareForGroup = async function(gi, btn) {
var ar = await fetch(VM_API + '/api/apply-groups', {
method: 'POST',
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();
if (!ad.ok || !ad.contract_ids || !ad.contract_ids.length) {
@@ -530,12 +547,12 @@ window.runCompareForGroup = async function(gi, btn) {
cmpBody.appendChild(statusEl);
var start = Date.now();
_activeCompareTimer = setInterval(function() {
state._activeCompare.timer = setInterval(function() {
statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с';
}, 200);
var es = new EventSource(VM_API + '/process-v2?contract_id=' + cid);
_activeCompareES = es;
state._activeCompare.es = es;
var sections = {};
es.onmessage = function(e) {
@@ -573,8 +590,8 @@ window.runCompareForGroup = async function(gi, btn) {
}
}
else if (d.type === 'done') {
clearInterval(_activeCompareTimer); _activeCompareTimer = null;
es.close(); _activeCompareES = null;
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
es.close(); state._activeCompare.es = null;
statusEl.textContent = '✓ ' + d.total_time_s + 'с';
statusEl.style.color = 'var(--green)';
// Заменить карточку на обработанную (другая вёрстка, нет кнопок)
@@ -584,16 +601,16 @@ window.runCompareForGroup = async function(gi, btn) {
stepDone('stepCompare');
}
else if (d.type === 'error') {
clearInterval(_activeCompareTimer); _activeCompareTimer = null;
es.close(); _activeCompareES = null;
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
es.close(); state._activeCompare.es = null;
cmpBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + d.message + '</div>';
btn.innerHTML = '✗'; btn.disabled = false;
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
}
};
es.onerror = function() {
clearInterval(_activeCompareTimer); _activeCompareTimer = null;
es.close(); _activeCompareES = null;
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
es.close(); state._activeCompare.es = null;
btn.innerHTML = '✗ соединение'; btn.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 fd = new FormData();
fd.append('files', uploadFile, uploadName);
if (contractId) fd.append('contract_id', contractId);
fd.append('batch_id', batchId);
if (state.contractId) fd.append('contract_id', state.contractId);
fd.append('batch_id', state.batchId);
xhr.open('POST', UPLOAD_URL);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable && onProgress) onProgress(Math.round(e.loaded / e.total * 100));
@@ -652,7 +669,7 @@ function uploadFile(file, onProgress) {
// ── LLM: Общее сравнение (Event Sourcing) ────────────────────────
document.getElementById('llmBtn').addEventListener('click', function() {
if (!contractId) return;
if (!state.contractId) return;
var btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
@@ -669,8 +686,8 @@ document.getElementById('llmBtn').addEventListener('click', function() {
compareStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
}, 200);
var order = fileQueue.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 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=' + state.contractId + (order ? '&order=' + encodeURIComponent(order) : ''));
var sections = {}; // supplement_id → DOM element
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> ✓ Готово';
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=' + 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';
lucide.createIcons();
@@ -751,22 +768,22 @@ document.getElementById('llmBtn').addEventListener('click', function() {
// ── Обновить supplement_id/type для radio ──────────────────
async function refreshSupps() {
if (!contractId) { console.log('refreshSupps: no contractId'); return; }
if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; }
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();
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 < fileQueue.length; i++) {
if (fileQueue[i].doc_id === supp.document_id) {
fileQueue[i].supp_id = supp.id;
fileQueue[i].supp_type = supp.type;
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;
}
}
});
renderTable();
render(state);
}
} catch(e) { console.log('refreshSupps error:', e); }
}
@@ -775,7 +792,7 @@ async function refreshSupps() {
document.getElementById('chatSend').addEventListener('click', function() {
var input = document.getElementById('chatInput');
var q = input.value.trim();
if (!q || !contractId) return;
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 = '';
@@ -785,7 +802,7 @@ document.getElementById('chatSend').addEventListener('click', function() {
var fd = new FormData();
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(d) {
msgs.lastChild.remove();
@@ -811,7 +828,7 @@ function closeModal(e) {
}
async function showText(i) {
var f = fileQueue[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>';
+15 -8
View File
@@ -28,11 +28,14 @@ function formatDate(ts) {
/**
* Удаляет файл из очереди по индексу.
* Вызывается по кнопке ✕ в таблице файлов.
*
* Фаза 0 (state + render): мутирует state.files, затем render(state).
* Если очередь опустела — сбрасывает state.contractId.
*/
function removeFile(i) {
fileQueue.splice(i, 1);
if (fileQueue.length === 0) contractId = null;
renderTable();
state.files.splice(i, 1);
if (state.files.length === 0) state.contractId = null;
render(state);
// Синхронизировать БД с таблицей
if (typeof syncDB === 'function') syncDB();
// Сбросить прогресс при изменении состава файлов
@@ -45,21 +48,25 @@ window.removeFile = removeFile;
/**
* Переместить файл на одну позицию ВВЕРХ (сохранено для возможного возврата ручной сортировки).
* В текущей версии (v1.0.164+) не используется — порядок определяет авто-классификация.
*
* Фаза 0 (state + render): мутирует state.files, затем render(state).
*/
function moveUp(i) {
if (i <= 0) return;
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i-1]; fileQueue[i-1] = tmp;
renderTable();
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 >= fileQueue.length - 1) return;
var tmp = fileQueue[i]; fileQueue[i] = fileQueue[i+1]; fileQueue[i+1] = tmp;
renderTable();
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;
+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>
<div class="topbar">
<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);">
<span id="stepUpload">○ Загрузка</span><span>→</span>
<span id="stepClassify">○ Классификация</span><span>→</span>
@@ -245,7 +245,8 @@
</div>
</div>
<script src="https://contracts.kube5s.ru/static/app_utils.js?v=1.0.177"></script>
<script src="https://contracts.kube5s.ru/static/app.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_utils.js?v=1.0.178"></script>
<script src="https://contracts.kube5s.ru/static/app.js?v=1.0.178"></script>
</body>
</html>