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:
+101
-84
@@ -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
@@ -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;
|
||||
|
||||
@@ -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: '○'
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user