v1.0.178: Фаза 1.3 — вынос в files.js. statusToHTML, renderFiles, syncDB, toggleClassifyDetail, uploadFile, refreshSupps → files.js. app.js похудел на ~200 строк. index.cfm грузит files.js.
This commit is contained in:
+7
-187
@@ -38,17 +38,9 @@ function render(state) {
|
|||||||
renderFiles(state);
|
renderFiles(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Синхронизация БД с таблицей — удалить всё, чего нет в state.files
|
// syncDB, statusToHTML, renderFiles, toggleClassifyDetail, uploadFile, refreshSupps
|
||||||
async function syncDB() {
|
// → вынесены в files.js (Фаза 1)
|
||||||
var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean);
|
|
||||||
try {
|
|
||||||
await fetch(VM_API + '/api/sync', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({keep_ids: keepIds})
|
|
||||||
});
|
|
||||||
} catch(e) { /* сервер недоступен — не критично */ }
|
|
||||||
}
|
|
||||||
// ── Pipeline stepper ─────────────────────────────────────────
|
// ── Pipeline stepper ─────────────────────────────────────────
|
||||||
function stepDone(id) {
|
function stepDone(id) {
|
||||||
var el = document.getElementById(id);
|
var el = document.getElementById(id);
|
||||||
@@ -71,113 +63,10 @@ function resetStepper(fromId) {
|
|||||||
}
|
}
|
||||||
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
// Утилиты (formatSize, formatDate, moveUp, moveDown, escHtml, fmtDate) —
|
||||||
// вынесены в app_utils.js для облегчения анализа и отладки.
|
// вынесены в app_utils.js для облегчения анализа и отладки.
|
||||||
|
// statusToHTML, renderFiles → files.js (Фаза 1)
|
||||||
/**
|
|
||||||
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
|
||||||
*
|
|
||||||
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
|
||||||
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
|
|
||||||
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
|
||||||
*
|
|
||||||
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
|
||||||
* state хранит чистые данные, statusToHTML — чистая функция рендеринга.
|
|
||||||
*/
|
|
||||||
function statusToHTML(st) {
|
|
||||||
if (!st || !st.kind) return '';
|
|
||||||
switch (st.kind) {
|
|
||||||
case 'uploading': return '↑ ' + (st.pct || 0) + '%';
|
|
||||||
case 'uploaded': return '<span class="status-ok">✓</span>';
|
|
||||||
case 'unzipping': return '⏳ распаковка...';
|
|
||||||
case 'parsing': return '⏳ парсинг...';
|
|
||||||
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>';
|
|
||||||
case 'error': return '<span class="status-err">✗ ' + (st.text || 'Неизвестная ошибка') + '</span>';
|
|
||||||
default: return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFiles(state) {
|
|
||||||
if (state.files.length === 0) {
|
|
||||||
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
|
||||||
} else {
|
|
||||||
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>' : '') +
|
|
||||||
escHtml(f.name) + '</td>' +
|
|
||||||
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
|
|
||||||
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
|
|
||||||
'<td class="status-cell">' + statusToHTML(f.status) + '</td>' +
|
|
||||||
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
|
|
||||||
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
|
|
||||||
'</tr>' +
|
|
||||||
'<tr id="detail_' + i + '" style="display:none;"><td colspan="7" style="padding:4px 12px;font-size:11px;background:var(--brand-grey-light);"><span style="color:var(--muted);">Загрузка...</span></td></tr>';
|
|
||||||
return rows;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Раскрыть/скрыть результат классификации под строкой файла ──
|
// ── Раскрыть/скрыть результат классификации под строкой файла ──
|
||||||
window.toggleClassifyDetail = async function(i) {
|
// toggleClassifyDetail → files.js (Фаза 1)
|
||||||
var detailRow = document.getElementById('detail_' + i);
|
|
||||||
var expandIcon = document.getElementById('expand_' + i);
|
|
||||||
if (!detailRow) return;
|
|
||||||
|
|
||||||
if (detailRow.style.display !== 'none') {
|
|
||||||
detailRow.style.display = 'none';
|
|
||||||
if (expandIcon) expandIcon.textContent = '▸';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var f = state.files[i];
|
|
||||||
if (!f || !f.doc_id) return;
|
|
||||||
|
|
||||||
detailRow.style.display = '';
|
|
||||||
if (expandIcon) expandIcon.textContent = '▾';
|
|
||||||
|
|
||||||
try {
|
|
||||||
var resp = await fetch(VM_API + '/api/documents/' + f.doc_id);
|
|
||||||
var qData = await resp.json();
|
|
||||||
if (!qData.ok) { detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка загрузки</span>'; return; }
|
|
||||||
|
|
||||||
var html = '';
|
|
||||||
if (qData.classify_status === 'classified') {
|
|
||||||
html += '<div style="font-weight:600;margin-bottom:4px;">🏷️ Результат классификации</div>';
|
|
||||||
html += '<div style="display:flex;gap:16px;flex-wrap:wrap;">';
|
|
||||||
if (qData.doc_type) html += '<span><b>Тип:</b> ' + escHtml(qData.doc_type) + '</span>';
|
|
||||||
if (qData.own_number) html += '<span><b>Номер:</b> ' + escHtml(qData.own_number) + '</span>';
|
|
||||||
if (qData.parent_number) html += '<span><b>Родительский:</b> ' + escHtml(qData.parent_number) + '</span>';
|
|
||||||
if (qData.doc_date) html += '<span><b>Дата:</b> ' + escHtml(qData.doc_date) + '</span>';
|
|
||||||
if (qData.counterparty) html += '<span><b>Контрагент:</b> ' + escHtml(qData.counterparty) + '</span>';
|
|
||||||
html += '</div>';
|
|
||||||
if (qData.classify_input) {
|
|
||||||
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📤 Текст отправленный в LLM</summary>';
|
|
||||||
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_input) + '</pre>';
|
|
||||||
html += '</details>';
|
|
||||||
}
|
|
||||||
if (qData.classify_raw) {
|
|
||||||
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📥 Сырой ответ LLM</summary>';
|
|
||||||
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_raw) + '</pre>';
|
|
||||||
html += '</details>';
|
|
||||||
}
|
|
||||||
if (qData.elements_json) {
|
|
||||||
var ej = qData.elements_json;
|
|
||||||
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
|
||||||
var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2);
|
|
||||||
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📄 Распарсенные элементы (JSON)</summary>';
|
|
||||||
html += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '</pre>';
|
|
||||||
html += '</details>';
|
|
||||||
}
|
|
||||||
} else if (qData.classify_status === 'failed') {
|
|
||||||
html += '<span style="color:var(--destructive);">✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + '</span>';
|
|
||||||
} else {
|
|
||||||
html += '<span style="color:var(--muted);">Ещё не классифицирован</span>';
|
|
||||||
}
|
|
||||||
detailRow.cells[0].innerHTML = html;
|
|
||||||
} catch(e) {
|
|
||||||
detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка: ' + escHtml(e.message) + '</span>';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
|
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
|
||||||
|
|
||||||
@@ -639,56 +528,7 @@ window.runCompareForGroup = async function(gi, btn) {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function uploadFile(file, onProgress) {
|
// uploadFile → files.js (Фаза 1)
|
||||||
return new Promise(function(resolve, reject) {
|
|
||||||
// .doc → конвертация в docx
|
|
||||||
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
|
|
||||||
var uploadFile = file;
|
|
||||||
var uploadName = file.name;
|
|
||||||
|
|
||||||
function doUpload() {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
var fd = new FormData();
|
|
||||||
fd.append('files', uploadFile, uploadName);
|
|
||||||
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));
|
|
||||||
};
|
|
||||||
xhr.onload = function() {
|
|
||||||
try {
|
|
||||||
var r = JSON.parse(xhr.responseText);
|
|
||||||
if (r.ok) resolve(r);
|
|
||||||
else reject(new Error(r.error || 'Неизвестная ошибка'));
|
|
||||||
} catch(e) { reject(new Error('Некорректный ответ')); }
|
|
||||||
};
|
|
||||||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
|
||||||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
|
||||||
xhr.timeout = 180000;
|
|
||||||
xhr.send(fd);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDoc) {
|
|
||||||
var xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', CONVERT_URL);
|
|
||||||
xhr.responseType = 'blob';
|
|
||||||
xhr.onload = function() {
|
|
||||||
if (xhr.status === 200 && xhr.response.size > 100) {
|
|
||||||
uploadFile = xhr.response;
|
|
||||||
uploadName = file.name.replace(/\.doc$/i, '.docx');
|
|
||||||
doUpload();
|
|
||||||
} else {
|
|
||||||
reject(new Error('Конвертация .doc'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
xhr.onerror = function() { reject(new Error('Конвертер')); };
|
|
||||||
xhr.send(file);
|
|
||||||
} else {
|
|
||||||
doUpload();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── LLM: Общее сравнение (Event Sourcing) ────────────────────────
|
// ── LLM: Общее сравнение (Event Sourcing) ────────────────────────
|
||||||
document.getElementById('llmBtn').addEventListener('click', function() {
|
document.getElementById('llmBtn').addEventListener('click', function() {
|
||||||
@@ -789,27 +629,7 @@ document.getElementById('llmBtn').addEventListener('click', function() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Обновить supplement_id/type для radio ──────────────────
|
// refreshSupps → files.js (Фаза 1)
|
||||||
async function refreshSupps() {
|
|
||||||
if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; }
|
|
||||||
try {
|
|
||||||
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 < 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
render(state);
|
|
||||||
}
|
|
||||||
} catch(e) { console.log('refreshSupps error:', e); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Чат ──────────────────────────────────────────────────────
|
// ── Чат ──────────────────────────────────────────────────────
|
||||||
document.getElementById('chatSend').addEventListener('click', function() {
|
document.getElementById('chatSend').addEventListener('click', function() {
|
||||||
|
|||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
/**
|
||||||
|
* files.js — Модуль работы с файлами (Фаза 1, decoupling-final-plan.md).
|
||||||
|
*
|
||||||
|
* ВЫНЕСЕНО из app.js:
|
||||||
|
* - statusToHTML(st) — чистая: структура → HTML
|
||||||
|
* - renderFiles(state) — рендер таблицы файлов
|
||||||
|
* - syncDB() — синхронизация БД с fileQueue
|
||||||
|
* - toggleClassifyDetail(i) — раскрыть результат классификации
|
||||||
|
* - uploadFile(file, cb) — XHR-загрузка одного файла (.doc/.docx/.pdf)
|
||||||
|
* - refreshSupps() — обновить supplement_id для файлов
|
||||||
|
*
|
||||||
|
* ЗАВИСИМОСТИ (глобальные, загружаются раньше):
|
||||||
|
* state.js → state (центральное состояние)
|
||||||
|
* app_utils.js → escHtml, formatSize, formatDate
|
||||||
|
* app.js → render(), stepDone, stepActive, resetStepper, showClassifyBtn
|
||||||
|
*
|
||||||
|
* ЗАГРУЖАЕТСЯ: после app_utils.js, перед app.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
||||||
|
*
|
||||||
|
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
||||||
|
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | ''
|
||||||
|
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
||||||
|
*
|
||||||
|
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
||||||
|
* state хранит чистые данные, statusToHTML — чистая функция рендеринга.
|
||||||
|
*/
|
||||||
|
function statusToHTML(st) {
|
||||||
|
if (!st || !st.kind) return '';
|
||||||
|
switch (st.kind) {
|
||||||
|
case 'uploading': return '↑ ' + (st.pct || 0) + '%';
|
||||||
|
case 'uploaded': return '<span class="status-ok">✓</span>';
|
||||||
|
case 'unzipping': return '⏳ распаковка...';
|
||||||
|
case 'parsing': return '⏳ парсинг...';
|
||||||
|
case 'parsed': return '<span class="status-ok">✓ ' + (st.count || 0) + ' эл.' + (st.elapsed ? ' (' + st.elapsed + 'с)' : '') + '</span>';
|
||||||
|
case 'error': return '<span class="status-err">✗ ' + (st.text || 'Неизвестная ошибка') + '</span>';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renderFiles(state) — Рендер таблицы файлов (бывший renderTable, Фаза 1).
|
||||||
|
*
|
||||||
|
* Читает state.files, генерирует HTML для #fileTable.
|
||||||
|
* Использует statusToHTML() для рендеринга статусов (чистые данные → HTML).
|
||||||
|
* Вызывает lucide.createIcons() для иконок.
|
||||||
|
*
|
||||||
|
* Вход: state (весь объект состояния, но читает только state.files).
|
||||||
|
* Выход: мутирует DOM (fileTable.innerHTML).
|
||||||
|
*/
|
||||||
|
function renderFiles(state) {
|
||||||
|
if (state.files.length === 0) {
|
||||||
|
fileTable.innerHTML = '<tr class="empty-row"><td colspan="7">Нет файлов — выберите .docx / .pdf</td></tr>';
|
||||||
|
} else {
|
||||||
|
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>' : '') +
|
||||||
|
escHtml(f.name) + '</td>' +
|
||||||
|
'<td style="font-size:12px;color:var(--muted);">' + formatDate(f.lastModified) + '</td>' +
|
||||||
|
'<td class="num-cell" style="font-size:12px;color:var(--muted);">' + formatSize(f.size) + '</td>' +
|
||||||
|
'<td class="status-cell">' + statusToHTML(f.status) + '</td>' +
|
||||||
|
'<td><button class="info-btn' + (f.doc_id ? ' visible' : '') + '" onclick="showText(' + i + ')" title="Текст / Распарсено"><i data-lucide="file-text" style="width:16px;height:16px;"></i></button></td>' +
|
||||||
|
'<td><button class="remove-btn" onclick="removeFile(' + i + ')" title="Удалить"><i data-lucide="trash-2" style="width:16px;height:16px;"></i></button></td>' +
|
||||||
|
'</tr>' +
|
||||||
|
'<tr id="detail_' + i + '" style="display:none;"><td colspan="7" style="padding:4px 12px;font-size:11px;background:var(--brand-grey-light);"><span style="color:var(--muted);">Загрузка...</span></td></tr>';
|
||||||
|
return rows;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* syncDB() — Синхронизация БД с текущим состоянием файлов.
|
||||||
|
*
|
||||||
|
* Отправляет на бэкенд список doc_id, которые должны остаться в БД.
|
||||||
|
* Всё, чего нет в state.files, будет удалено из БД (cascade).
|
||||||
|
* Вызывается после каждого изменения состава файлов (removeFile, upload).
|
||||||
|
*/
|
||||||
|
async function syncDB() {
|
||||||
|
var keepIds = state.files.map(function(f) { return f.doc_id; }).filter(Boolean);
|
||||||
|
try {
|
||||||
|
await fetch(VM_API + '/api/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({keep_ids: keepIds})
|
||||||
|
});
|
||||||
|
} catch(e) { /* сервер недоступен — не критично */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* toggleClassifyDetail(i) — Раскрыть/скрыть результат классификации под строкой файла.
|
||||||
|
*
|
||||||
|
* Вызывается по клику на имя файла в таблице.
|
||||||
|
* Загружает данные с бэкенда (/api/documents/:id) и показывает:
|
||||||
|
* - Тип, номер, родительский номер, дату, контрагента
|
||||||
|
* - Текст отправленный в LLM (📤)
|
||||||
|
* - Сырой ответ LLM (📥)
|
||||||
|
* - Распарсенные элементы JSON (📄)
|
||||||
|
*/
|
||||||
|
window.toggleClassifyDetail = async function(i) {
|
||||||
|
var detailRow = document.getElementById('detail_' + i);
|
||||||
|
var expandIcon = document.getElementById('expand_' + i);
|
||||||
|
if (!detailRow) return;
|
||||||
|
|
||||||
|
if (detailRow.style.display !== 'none') {
|
||||||
|
detailRow.style.display = 'none';
|
||||||
|
if (expandIcon) expandIcon.textContent = '▸';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var f = state.files[i];
|
||||||
|
if (!f || !f.doc_id) return;
|
||||||
|
|
||||||
|
detailRow.style.display = '';
|
||||||
|
if (expandIcon) expandIcon.textContent = '▾';
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch(VM_API + '/api/documents/' + f.doc_id);
|
||||||
|
var qData = await resp.json();
|
||||||
|
if (!qData.ok) { detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка загрузки</span>'; return; }
|
||||||
|
|
||||||
|
var html = '';
|
||||||
|
if (qData.classify_status === 'classified') {
|
||||||
|
html += '<div style="font-weight:600;margin-bottom:4px;">🏷️ Результат классификации</div>';
|
||||||
|
html += '<div style="display:flex;gap:16px;flex-wrap:wrap;">';
|
||||||
|
if (qData.doc_type) html += '<span><b>Тип:</b> ' + escHtml(qData.doc_type) + '</span>';
|
||||||
|
if (qData.own_number) html += '<span><b>Номер:</b> ' + escHtml(qData.own_number) + '</span>';
|
||||||
|
if (qData.parent_number) html += '<span><b>Родительский:</b> ' + escHtml(qData.parent_number) + '</span>';
|
||||||
|
if (qData.doc_date) html += '<span><b>Дата:</b> ' + escHtml(qData.doc_date) + '</span>';
|
||||||
|
if (qData.counterparty) html += '<span><b>Контрагент:</b> ' + escHtml(qData.counterparty) + '</span>';
|
||||||
|
html += '</div>';
|
||||||
|
if (qData.classify_input) {
|
||||||
|
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📤 Текст отправленный в LLM</summary>';
|
||||||
|
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_input) + '</pre>';
|
||||||
|
html += '</details>';
|
||||||
|
}
|
||||||
|
if (qData.classify_raw) {
|
||||||
|
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📥 Сырой ответ LLM</summary>';
|
||||||
|
html += '<pre style="font-size:10px;max-height:150px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(qData.classify_raw) + '</pre>';
|
||||||
|
html += '</details>';
|
||||||
|
}
|
||||||
|
if (qData.elements_json) {
|
||||||
|
var ej = qData.elements_json;
|
||||||
|
if (typeof ej === 'object' && ej.Value) ej = ej.Value;
|
||||||
|
var ejStr = typeof ej === 'string' ? ej : JSON.stringify(ej, null, 2);
|
||||||
|
html += '<details style="margin-top:4px;"><summary style="cursor:pointer;color:var(--brand-primary);">📄 Распарсенные элементы (JSON)</summary>';
|
||||||
|
html += '<pre style="font-size:10px;max-height:200px;overflow:auto;background:#fff;padding:4px;border-radius:3px;margin-top:2px;">' + escHtml(ejStr.substring(0, 5000)) + (ejStr.length > 5000 ? '\n... (обрезано)' : '') + '</pre>';
|
||||||
|
html += '</details>';
|
||||||
|
}
|
||||||
|
} else if (qData.classify_status === 'failed') {
|
||||||
|
html += '<span style="color:var(--destructive);">✗ Ошибка классификации: ' + escHtml(qData.error_message || '?') + '</span>';
|
||||||
|
} else {
|
||||||
|
html += '<span style="color:var(--muted);">Ещё не классифицирован</span>';
|
||||||
|
}
|
||||||
|
detailRow.cells[0].innerHTML = html;
|
||||||
|
} catch(e) {
|
||||||
|
detailRow.cells[0].innerHTML = '<span style="color:var(--destructive);">Ошибка: ' + escHtml(e.message) + '</span>';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uploadFile(file, onProgress) — XHR-загрузка одного файла на бэкенд.
|
||||||
|
*
|
||||||
|
* Особенности:
|
||||||
|
* - .doc (не .docx!) конвертируется через CONVERT_URL перед загрузкой
|
||||||
|
* - onProgress(pct) — callback с процентом загрузки (0-100)
|
||||||
|
* - Возвращает Promise<ответ API> с полями doc_id, contract_id, parsed
|
||||||
|
* - Таймаут 180с (большие PDF)
|
||||||
|
*/
|
||||||
|
function uploadFile(file, onProgress) {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
// .doc → конвертация в docx (старый формат Word)
|
||||||
|
var isDoc = file.name.toLowerCase().endsWith('.doc') && !file.name.toLowerCase().endsWith('.docx');
|
||||||
|
var uploadFile = file;
|
||||||
|
var uploadName = file.name;
|
||||||
|
|
||||||
|
function doUpload() {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('files', uploadFile, uploadName);
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
xhr.onload = function() {
|
||||||
|
try {
|
||||||
|
var r = JSON.parse(xhr.responseText);
|
||||||
|
if (r.ok) resolve(r);
|
||||||
|
else reject(new Error(r.error || 'Неизвестная ошибка'));
|
||||||
|
} catch(e) { reject(new Error('Некорректный ответ')); }
|
||||||
|
};
|
||||||
|
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||||||
|
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
||||||
|
xhr.timeout = 180000;
|
||||||
|
xhr.send(fd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDoc) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', CONVERT_URL);
|
||||||
|
xhr.responseType = 'blob';
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status === 200 && xhr.response.size > 100) {
|
||||||
|
uploadFile = xhr.response;
|
||||||
|
uploadName = file.name.replace(/\.doc$/i, '.docx');
|
||||||
|
doUpload();
|
||||||
|
} else {
|
||||||
|
reject(new Error('Конвертация .doc'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = function() { reject(new Error('Конвертер')); };
|
||||||
|
xhr.send(file);
|
||||||
|
} else {
|
||||||
|
doUpload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* refreshSupps() — Обновить supplement_id/type для файлов в state.files.
|
||||||
|
*
|
||||||
|
* Запрашивает с бэкенда список supplement-записей для текущего contractId,
|
||||||
|
* сопоставляет их с файлами по doc_id → document_id и проставляет supp_id, supp_type.
|
||||||
|
* Вызывается после каждого upload и после apply-groups.
|
||||||
|
*/
|
||||||
|
async function refreshSupps() {
|
||||||
|
if (!state.contractId) { console.log('refreshSupps: no state.contractId'); return; }
|
||||||
|
try {
|
||||||
|
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 < 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
render(state);
|
||||||
|
}
|
||||||
|
} catch(e) { console.log('refreshSupps error:', e); }
|
||||||
|
}
|
||||||
@@ -247,6 +247,7 @@
|
|||||||
|
|
||||||
<script src="https://contracts.kube5s.ru/static/state.js?v=1.0.178"></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_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/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