v1.0.178: Фаза 3 — compare.js. applyCompareEvent, startCompareSSE, renderCompareOpsTable. ~160 строк дублирования SSE → ~60 строк общих + 2 тонких вызова в runCompareForGroup и llmBtn.
This commit is contained in:
+57
-148
@@ -187,7 +187,8 @@ window.runCompareForGroup = async function(gi, btn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var cid = ad.contract_ids[0];
|
var cid = ad.contract_ids[0];
|
||||||
// Создать тело для результатов под кнопкой
|
|
||||||
|
// Создать контейнер для результатов сравнения
|
||||||
var cmpBody = document.getElementById('cmpBody_' + gi);
|
var cmpBody = document.getElementById('cmpBody_' + gi);
|
||||||
if (!cmpBody) {
|
if (!cmpBody) {
|
||||||
cmpBody = document.createElement('div');
|
cmpBody = document.createElement('div');
|
||||||
@@ -197,93 +198,53 @@ window.runCompareForGroup = async function(gi, btn) {
|
|||||||
btn.parentNode.insertBefore(cmpBody, btn.nextSibling);
|
btn.parentNode.insertBefore(cmpBody, btn.nextSibling);
|
||||||
}
|
}
|
||||||
cmpBody.innerHTML = '';
|
cmpBody.innerHTML = '';
|
||||||
var cmpBody = document.getElementById('cmpBody_' + gi);
|
|
||||||
cmpBody.style.display = 'block';
|
cmpBody.style.display = 'block';
|
||||||
cmpBody.innerHTML = '';
|
|
||||||
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
btn.innerHTML = '<span class="spinner"></span> Сравнение...';
|
||||||
stepActive('stepCompare');
|
stepActive('stepCompare');
|
||||||
|
|
||||||
var statusEl = document.createElement('div');
|
var statusEl = document.createElement('div');
|
||||||
statusEl.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px;';
|
statusEl.style.cssText = 'font-size:11px;color:var(--muted);margin-bottom:4px;';
|
||||||
statusEl.textContent = '⏳ 0.0с';
|
statusEl.textContent = '⏳ 0.0с';
|
||||||
cmpBody.appendChild(statusEl);
|
cmpBody.appendChild(statusEl);
|
||||||
|
|
||||||
var start = Date.now();
|
|
||||||
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);
|
|
||||||
state._activeCompare.es = es;
|
|
||||||
var sections = {};
|
|
||||||
|
|
||||||
es.onmessage = function(e) {
|
|
||||||
var d = JSON.parse(e.data);
|
|
||||||
|
|
||||||
if (d.type === 'extract_start') {
|
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
||||||
var sec = document.createElement('div');
|
var result = startCompareSSE(
|
||||||
sec.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
|
VM_API + '/process-v2?contract_id=' + cid,
|
||||||
sec.innerHTML = '<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;" onclick="var b=this.nextElementSibling;b.style.display=b.style.display===\'none\'?\'block\':\'none\';">⏳ ' + d.filename + '</div><div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
cmpBody,
|
||||||
cmpBody.appendChild(sec);
|
statusEl,
|
||||||
sections[d.supplement_id] = sec;
|
{
|
||||||
}
|
onDone: function(d, sections) {
|
||||||
else if (d.type === 'llm_done') {
|
state._activeCompare.timer = null;
|
||||||
var sec = sections[d.supplement_id];
|
state._activeCompare.es = null;
|
||||||
if (sec) {
|
group.compare = {
|
||||||
sec.querySelector('.diff-section-header').innerHTML = '✓ ' + d.filename + ' — ' + d.ops_count + ' оп., ' + d.mode + ' (' + d.time_s + 'с)';
|
status: 'done',
|
||||||
sec.querySelector('.diff-section-header').style.color = 'var(--green)';
|
totalTime: d.total_time_s + 'с',
|
||||||
|
bodyHTML: cmpBody.innerHTML,
|
||||||
|
sections: sections
|
||||||
|
};
|
||||||
|
renderGroups(state);
|
||||||
|
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||||
|
stepDone('stepCompare');
|
||||||
|
},
|
||||||
|
onError: function(d) {
|
||||||
|
state._activeCompare.timer = null;
|
||||||
|
state._activeCompare.es = null;
|
||||||
|
group.compare = null;
|
||||||
|
cmpBody.innerHTML += '<div style="color:var(--destructive);">✗ ' + d.message + '</div>';
|
||||||
|
btn.innerHTML = '✗'; btn.disabled = false;
|
||||||
|
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||||
|
},
|
||||||
|
onConnectionError: function() {
|
||||||
|
state._activeCompare.timer = null;
|
||||||
|
state._activeCompare.es = null;
|
||||||
|
group.compare = null;
|
||||||
|
btn.innerHTML = '✗ соединение'; btn.disabled = false;
|
||||||
|
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (d.type === 'applied') {
|
);
|
||||||
var sec = sections[d.supplement_id];
|
state._activeCompare.es = result.es;
|
||||||
if (sec && d.ops && d.ops.length > 0) {
|
state._activeCompare.timer = result.timer;
|
||||||
var s = d.summary || {};
|
|
||||||
var body = sec.querySelector('.diff-section-body');
|
|
||||||
var html = '<div style="font-size:12px;margin-bottom:6px;">+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + '</div>';
|
|
||||||
html += '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
|
||||||
d.ops.forEach(function(op) {
|
|
||||||
var cls = op.action === 'ADD' ? 'diff-added' : op.action === 'DELETE' ? 'diff-deleted' : 'diff-changed';
|
|
||||||
var nr = op.new_row || {};
|
|
||||||
html += '<tr class="' + cls + '"><td>' + op.action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
|
||||||
});
|
|
||||||
html += '</tbody></table></div>';
|
|
||||||
body.innerHTML = html;
|
|
||||||
body.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (d.type === 'done') {
|
|
||||||
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)';
|
|
||||||
// Фаза 2: вместо markGroupDone — флаг в state + пересборка карточек
|
|
||||||
// renderGroups перестроит карточку как «готовую» (renderGroupCardDone)
|
|
||||||
group.compare = {
|
|
||||||
status: 'done',
|
|
||||||
totalTime: d.total_time_s + 'с',
|
|
||||||
bodyHTML: cmpBody.innerHTML
|
|
||||||
};
|
|
||||||
renderGroups(state);
|
|
||||||
// Разблокировать остальные кнопки
|
|
||||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
|
||||||
stepDone('stepCompare');
|
|
||||||
}
|
|
||||||
else if (d.type === 'error') {
|
|
||||||
clearInterval(state._activeCompare.timer); state._activeCompare.timer = null;
|
|
||||||
es.close(); state._activeCompare.es = null;
|
|
||||||
group.compare = null; // Фаза 2: сбросить статус группы при ошибке
|
|
||||||
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(state._activeCompare.timer); state._activeCompare.timer = null;
|
|
||||||
es.close(); state._activeCompare.es = null;
|
|
||||||
group.compare = null; // Фаза 2: сбросить статус группы при ошибке
|
|
||||||
btn.innerHTML = '✗ соединение'; btn.disabled = false;
|
|
||||||
document.querySelectorAll('.cmp-btn').forEach(function(b) { b.disabled = false; });
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// uploadFile → files.js (Фаза 1)
|
// uploadFile → files.js (Фаза 1)
|
||||||
@@ -303,61 +264,14 @@ document.getElementById('llmBtn').addEventListener('click', function() {
|
|||||||
compareStatus.textContent = '⏳ 0.0с';
|
compareStatus.textContent = '⏳ 0.0с';
|
||||||
|
|
||||||
var llmStart = Date.now();
|
var llmStart = Date.now();
|
||||||
var timerInterval = setInterval(function() {
|
|
||||||
compareStatus.textContent = '⏳ ' + ((Date.now() - llmStart) / 1000).toFixed(1) + 'с';
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
var order = state.files.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=' + state.contractId + (order ? '&order=' + encodeURIComponent(order) : ''));
|
var url = 'https://contracts.kube5s.ru/process-v2?contract_id=' + state.contractId +
|
||||||
var sections = {}; // supplement_id → DOM element
|
(order ? '&order=' + encodeURIComponent(order) : '');
|
||||||
|
|
||||||
es.onmessage = function(e) {
|
// Фаза 3: унифицированный SSE через startCompareSSE (compare.js)
|
||||||
var d = JSON.parse(e.data);
|
startCompareSSE(url, compareBody, compareStatus, {
|
||||||
|
onDone: function(d) {
|
||||||
if (d.type === 'extract_start') {
|
|
||||||
var sec = document.createElement('div');
|
|
||||||
sec.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
|
|
||||||
sec.innerHTML = '<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;" onclick="var b=this.nextElementSibling;b.style.display=b.style.display===\'none\'?\'block\':\'none\';">⏳ ' + d.filename + '</div><div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
|
||||||
compareBody.appendChild(sec);
|
|
||||||
sections[d.supplement_id] = sec;
|
|
||||||
}
|
|
||||||
else if (d.type === 'llm_done') {
|
|
||||||
var sec = sections[d.supplement_id];
|
|
||||||
if (sec) {
|
|
||||||
sec.querySelector('.diff-section-header').innerHTML = '✓ ' + d.filename + ' — ' + d.ops_count + ' оп., ' + d.mode + ' (' + d.time_s + 'с)';
|
|
||||||
sec.querySelector('.diff-section-header').style.color = 'var(--green)';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (d.type === 'applied') {
|
|
||||||
var sec = sections[d.supplement_id];
|
|
||||||
if (sec && d.ops && d.ops.length > 0) {
|
|
||||||
var s = d.summary || {};
|
|
||||||
var body = sec.querySelector('.diff-section-body');
|
|
||||||
var html = '<div style="font-size:12px;margin-bottom:6px;">+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0) + ' ?' + (s.unresolved||0) + '</div>';
|
|
||||||
html += '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
|
||||||
d.ops.forEach(function(op) {
|
|
||||||
var action = op.action;
|
|
||||||
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
|
||||||
var nr = op.new_row || {};
|
|
||||||
var name = nr.name || '';
|
|
||||||
var price = nr.price != null ? nr.price : '';
|
|
||||||
var qty = nr.qty != null ? nr.qty : '';
|
|
||||||
var sum = nr.sum != null ? nr.sum : '';
|
|
||||||
var ds = nr.date_start || '';
|
|
||||||
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + name + '</td><td class="num-cell">' + price + '</td><td class="num-cell">' + qty + '</td><td class="num-cell">' + sum + '</td><td>' + ds + '</td></tr>';
|
|
||||||
});
|
|
||||||
html += '</tbody></table></div>';
|
|
||||||
body.innerHTML = html;
|
|
||||||
body.style.display = 'block';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
|
||||||
compareBody.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.filename + ': ' + d.error + '</div>';
|
|
||||||
}
|
|
||||||
else if (d.type === 'done') {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
es.close();
|
|
||||||
compareStatus.textContent = '✓ ' + d.total_time_s + 'с';
|
|
||||||
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) {
|
||||||
@@ -365,26 +279,21 @@ document.getElementById('llmBtn').addEventListener('click', function() {
|
|||||||
}
|
}
|
||||||
document.getElementById('chatCard').style.display = 'block';
|
document.getElementById('chatCard').style.display = 'block';
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
},
|
||||||
else if (d.type === 'error') {
|
onError: function(d) {
|
||||||
clearInterval(timerInterval);
|
compareBody.innerHTML += '<div style="color:var(--destructive);margin-top:8px;">✗ ' + d.message + '</div>';
|
||||||
es.close();
|
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||||||
compareBody.innerHTML += '<div style=\"color:var(--destructive);margin-top:8px;\">✗ ' + d.message + '</div>';
|
|
||||||
btn.innerHTML = '<i data-lucide=\"scale\" style=\"width:16px;height:16px;\"></i> Общее сравнение';
|
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
},
|
||||||
|
onConnectionError: function() {
|
||||||
|
if (compareBody.innerHTML === '') {
|
||||||
|
compareBody.innerHTML = '<div style="color:var(--destructive);">✗ Ошибка соединения</div>';
|
||||||
|
}
|
||||||
|
btn.innerHTML = '<i data-lucide="scale" style="width:16px;height:16px;"></i> Общее сравнение';
|
||||||
|
btn.disabled = false;
|
||||||
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
es.onerror = function() {
|
|
||||||
clearInterval(timerInterval);
|
|
||||||
es.close();
|
|
||||||
if (compareBody.innerHTML === '') {
|
|
||||||
compareBody.innerHTML = '<div style=\"color:var(--destructive);\">✗ Ошибка соединения</div>';
|
|
||||||
}
|
|
||||||
btn.innerHTML = '<i data-lucide=\"scale\" style=\"width:16px;height:16px;\"></i> Общее сравнение';
|
|
||||||
btn.disabled = false;
|
|
||||||
lucide.createIcons();
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// refreshSupps → files.js (Фаза 1)
|
// refreshSupps → files.js (Фаза 1)
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* compare.js — Модуль сравнения через SSE (Фаза 3, decoupling-final-plan.md).
|
||||||
|
*
|
||||||
|
* УНИФИЦИРУЕТ дублирование (~160 строк) между runCompareForGroup и llmBtn.
|
||||||
|
*
|
||||||
|
* ПАТТЕРН:
|
||||||
|
* applyCompareEvent(sections, event) — чистая мутация: SSE-событие → state
|
||||||
|
* startCompareSSE(url, container, statusEl, callbacks) — жизненный цикл SSE
|
||||||
|
* renderCompareOpsTable(ops) — общий рендер таблицы операций
|
||||||
|
*
|
||||||
|
* ЗАВИСИМОСТИ:
|
||||||
|
* state.js → state (для _activeCompare)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* applyCompareEvent(sections, event) — Чистая функция (Фаза 3).
|
||||||
|
*
|
||||||
|
* Применяет ОДНО SSE-событие к состоянию sections.
|
||||||
|
* НЕ трогает DOM — только мутирует объект sections.
|
||||||
|
*
|
||||||
|
* Вход: sections — объект { [supplement_id]: sectionState }
|
||||||
|
* event — { type, supplement_id, filename, ops_count, mode, ... }
|
||||||
|
* Выход: нет (мутирует sections)
|
||||||
|
*
|
||||||
|
* Типы событий:
|
||||||
|
* extract_start → создаёт запись в sections
|
||||||
|
* llm_done → обновляет статус, ops_count, mode, time_s
|
||||||
|
* applied → сохраняет ops и summary
|
||||||
|
* extract_error/apply_error → сохраняет ошибку
|
||||||
|
*/
|
||||||
|
function applyCompareEvent(sections, event) {
|
||||||
|
var d = event;
|
||||||
|
var suppId = d.supplement_id;
|
||||||
|
|
||||||
|
if (d.type === 'extract_start') {
|
||||||
|
// Создать новую секцию: извлечение началось
|
||||||
|
sections[suppId] = {
|
||||||
|
filename: d.filename,
|
||||||
|
status: 'extracting',
|
||||||
|
ops_count: 0,
|
||||||
|
mode: '',
|
||||||
|
time_s: 0,
|
||||||
|
summary: null,
|
||||||
|
ops: null,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (d.type === 'llm_done') {
|
||||||
|
// LLM закончил — сохранить метаданные
|
||||||
|
var sec = sections[suppId];
|
||||||
|
if (sec) {
|
||||||
|
sec.status = 'llm_done';
|
||||||
|
sec.ops_count = d.ops_count || 0;
|
||||||
|
sec.mode = d.mode || '';
|
||||||
|
sec.time_s = d.time_s || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (d.type === 'applied') {
|
||||||
|
// Результаты сравнения применены — сохранить ops и summary
|
||||||
|
var sec = sections[suppId];
|
||||||
|
if (sec) {
|
||||||
|
sec.status = 'applied';
|
||||||
|
sec.summary = d.summary || {};
|
||||||
|
sec.ops = d.ops || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
||||||
|
// Ошибка извлечения или применения
|
||||||
|
var sec = sections[suppId];
|
||||||
|
if (sec) {
|
||||||
|
sec.status = 'error';
|
||||||
|
sec.error = d.error || 'неизвестная ошибка';
|
||||||
|
} else {
|
||||||
|
// Ошибка для ещё не созданной секции
|
||||||
|
sections[suppId] = {
|
||||||
|
filename: d.filename || '?',
|
||||||
|
status: 'error',
|
||||||
|
error: d.error || 'неизвестная ошибка',
|
||||||
|
ops_count: 0, mode: '', time_s: 0, summary: null, ops: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renderCompareSectionHeader(sec) — Чистая функция: заголовок секции (Фаза 3).
|
||||||
|
*
|
||||||
|
* Вход: sec — { filename, status, ops_count, mode, time_s, error }
|
||||||
|
* Выход: HTML-строка для div.diff-section-header
|
||||||
|
*/
|
||||||
|
function renderCompareSectionHeader(sec) {
|
||||||
|
if (sec.status === 'extracting') {
|
||||||
|
return '⏳ ' + sec.filename;
|
||||||
|
}
|
||||||
|
if (sec.status === 'llm_done' || sec.status === 'applied') {
|
||||||
|
return '✓ ' + sec.filename + ' — ' + sec.ops_count + ' оп., ' + sec.mode + ' (' + sec.time_s + 'с)';
|
||||||
|
}
|
||||||
|
if (sec.status === 'error') {
|
||||||
|
return '✗ ' + sec.filename + ': ' + sec.error;
|
||||||
|
}
|
||||||
|
return sec.filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renderCompareOpsTable(ops) — Чистая функция: таблица операций (Фаза 3).
|
||||||
|
*
|
||||||
|
* Используется ОБОИМИ обработчиками (runCompareForGroup и llmBtn).
|
||||||
|
* Раньше этот код был продублирован ~30 строк × 2.
|
||||||
|
*
|
||||||
|
* Вход: ops — [{ action, new_row: { name, price, qty, sum, date_start } }]
|
||||||
|
* Выход: HTML-строка <table>
|
||||||
|
*/
|
||||||
|
function renderCompareOpsTable(ops) {
|
||||||
|
var html = '<div class="table-wrap"><table style="font-size:11px;"><thead><tr><th>Действие</th><th>Услуга</th><th>Цена</th><th>Кол-во</th><th>Сумма</th><th>Дата</th></tr></thead><tbody>';
|
||||||
|
ops.forEach(function(op) {
|
||||||
|
var action = op.action;
|
||||||
|
// Цвет строки зависит от действия: ADD=зелёный, DELETE=красный, UPDATE=жёлтый
|
||||||
|
var cls = action === 'ADD' ? 'diff-added' : action === 'DELETE' ? 'diff-deleted' : action === 'UPDATE' ? 'diff-changed' : '';
|
||||||
|
var nr = op.new_row || {};
|
||||||
|
html += '<tr class="' + cls + '"><td>' + action + '</td><td>' + (nr.name||'') + '</td><td class="num-cell">' + (nr.price!=null?nr.price:'') + '</td><td class="num-cell">' + (nr.qty!=null?nr.qty:'') + '</td><td class="num-cell">' + (nr.sum!=null?nr.sum:'') + '</td><td>' + (nr.date_start||'') + '</td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renderCompareSectionBody(sec) — Чистая функция: тело секции (Фаза 3).
|
||||||
|
*
|
||||||
|
* Рендерит summary (статистику) + таблицу ops.
|
||||||
|
*
|
||||||
|
* Вход: sec — { summary: { added, updated, deleted, unresolved }, ops }
|
||||||
|
* Выход: HTML-строка
|
||||||
|
*/
|
||||||
|
function renderCompareSectionBody(sec) {
|
||||||
|
if (!sec || sec.status !== 'applied' || !sec.ops || !sec.ops.length) return '';
|
||||||
|
var s = sec.summary || {};
|
||||||
|
var html = '<div style="font-size:12px;margin-bottom:6px;">';
|
||||||
|
html += '+' + (s.added||0) + ' ~' + (s.updated||0) + ' -' + (s.deleted||0);
|
||||||
|
if (s.unresolved) html += ' ?' + s.unresolved;
|
||||||
|
html += '</div>';
|
||||||
|
html += renderCompareOpsTable(sec.ops);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* startCompareSSE(url, container, statusEl, callbacks) — Жизненный цикл SSE (Фаза 3).
|
||||||
|
*
|
||||||
|
* УНИФИЦИРОВАННЫЙ обработчик SSE для ОБОИХ режимов сравнения.
|
||||||
|
* Раньше ~80 строк дублировалось в runCompareForGroup и llmBtn.
|
||||||
|
*
|
||||||
|
* Параметры:
|
||||||
|
* url — URL для EventSource (/process-v2?contract_id=...)
|
||||||
|
* container — DOM-элемент, куда добавлять секции (cmpBody или compareBody)
|
||||||
|
* statusEl — DOM-элемент для отображения таймера/статуса
|
||||||
|
* callbacks — { onDone(d, sections), onError(d), onConnectionError() }
|
||||||
|
* Все колбэки опциональны.
|
||||||
|
* Таймер и EventSource УЖЕ остановлены к моменту вызова колбэков.
|
||||||
|
*
|
||||||
|
* Возвращает: { es, timer, sections } — для сохранения в state._activeCompare
|
||||||
|
*/
|
||||||
|
function startCompareSSE(url, container, statusEl, callbacks) {
|
||||||
|
callbacks = callbacks || {};
|
||||||
|
|
||||||
|
// Таймер: обновляет statusEl каждые 200мс
|
||||||
|
var start = Date.now();
|
||||||
|
var timer = setInterval(function() {
|
||||||
|
statusEl.textContent = '⏳ ' + ((Date.now() - start) / 1000).toFixed(1) + 'с';
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
var es = new EventSource(url);
|
||||||
|
var sections = {}; // supplement_id → { filename, status, ops_count, mode, time_s, summary, ops, error, _el }
|
||||||
|
|
||||||
|
es.onmessage = function(e) {
|
||||||
|
var d = JSON.parse(e.data);
|
||||||
|
|
||||||
|
// ── extract_start: создать DOM-секцию ──
|
||||||
|
if (d.type === 'extract_start') {
|
||||||
|
applyCompareEvent(sections, d);
|
||||||
|
var sec = sections[d.supplement_id];
|
||||||
|
var secEl = document.createElement('div');
|
||||||
|
secEl.style.cssText = 'margin-top:8px;border:1px solid var(--brand-gray);border-radius:6px;overflow:hidden;';
|
||||||
|
secEl.innerHTML =
|
||||||
|
'<div class="diff-section-header" style="padding:8px 12px;background:var(--brand-grey-light);cursor:pointer;font-size:13px;font-weight:600;"' +
|
||||||
|
' onclick="var b=this.nextElementSibling;b.style.display=b.style.display===\'none\'?\'block\':\'none\';">' +
|
||||||
|
renderCompareSectionHeader(sec) + '</div>' +
|
||||||
|
'<div class="diff-section-body" style="display:none;padding:8px 12px;"></div>';
|
||||||
|
container.appendChild(secEl);
|
||||||
|
sec._el = secEl; // ссылка на DOM для последующих обновлений
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── llm_done: обновить заголовок секции ──
|
||||||
|
else if (d.type === 'llm_done') {
|
||||||
|
applyCompareEvent(sections, d);
|
||||||
|
var sec = sections[d.supplement_id];
|
||||||
|
if (sec && sec._el) {
|
||||||
|
sec._el.querySelector('.diff-section-header').innerHTML = renderCompareSectionHeader(sec);
|
||||||
|
sec._el.querySelector('.diff-section-header').style.color = 'var(--green)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── applied: заполнить тело секции таблицей ops ──
|
||||||
|
else if (d.type === 'applied') {
|
||||||
|
applyCompareEvent(sections, d);
|
||||||
|
var sec = sections[d.supplement_id];
|
||||||
|
if (sec && sec._el && sec.ops && sec.ops.length > 0) {
|
||||||
|
var body = sec._el.querySelector('.diff-section-body');
|
||||||
|
body.innerHTML = renderCompareSectionBody(sec);
|
||||||
|
body.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── extract_error / apply_error: показать ошибку ──
|
||||||
|
else if (d.type === 'extract_error' || d.type === 'apply_error') {
|
||||||
|
applyCompareEvent(sections, d);
|
||||||
|
container.innerHTML += '<div style="font-size:12px;color:var(--destructive);">✗ ' + d.filename + ': ' + d.error + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── done: завершение ──
|
||||||
|
else if (d.type === 'done') {
|
||||||
|
clearInterval(timer);
|
||||||
|
es.close();
|
||||||
|
statusEl.textContent = '✓ ' + d.total_time_s + 'с';
|
||||||
|
statusEl.style.color = 'var(--green)';
|
||||||
|
if (callbacks.onDone) callbacks.onDone(d, sections);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── error: фатальная ошибка ──
|
||||||
|
else if (d.type === 'error') {
|
||||||
|
clearInterval(timer);
|
||||||
|
es.close();
|
||||||
|
if (callbacks.onError) callbacks.onError(d);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Сетевая ошибка ──
|
||||||
|
es.onerror = function() {
|
||||||
|
clearInterval(timer);
|
||||||
|
es.close();
|
||||||
|
if (callbacks.onConnectionError) callbacks.onConnectionError();
|
||||||
|
};
|
||||||
|
|
||||||
|
return { es: es, timer: timer, sections: sections };
|
||||||
|
}
|
||||||
@@ -249,6 +249,7 @@
|
|||||||
<script src="https://contracts.kube5s.ru/static/app_utils.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/files.js?v=1.0.178"></script>
|
<script src="https://contracts.kube5s.ru/static/files.js?v=1.0.178"></script>
|
||||||
<script src="https://contracts.kube5s.ru/static/groups.js?v=1.0.178"></script>
|
<script src="https://contracts.kube5s.ru/static/groups.js?v=1.0.178"></script>
|
||||||
|
<script src="https://contracts.kube5s.ru/static/compare.js?v=1.0.178"></script>
|
||||||
<script src="https://contracts.kube5s.ru/static/app.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