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