545 lines
25 KiB
JavaScript
545 lines
25 KiB
JavaScript
/**
|
||
* files.js — Модуль работы с файлами.
|
||
*
|
||
* ⛔⛔⛔ НЕ МЕНЯТЬ БЕЗ РАЗРЕШЕНИЯ НАЕЛЯ ⛔⛔⛔
|
||
*
|
||
* v2.0.3: честный счётчик ⏳ соединение... Nс вместо фейкового ↑N%
|
||
* Проверено в бою 2026-07-16. Любое изменение = риск сломать загрузку.
|
||
*
|
||
* КЛЮЧЕВЫЕ ФУНКЦИИ (не трогать):
|
||
* - uploadFile() — fetch-загрузка с имитацией прогресса
|
||
* - onFilesSelected() — все строки в таблицу сразу, потом загрузка по одной
|
||
* - statusToHTML() — рендер статуса (↑ N%, ✓)
|
||
* - renderFiles() — рендер всей таблицы
|
||
|
||
/**
|
||
* statusToHTML(status) — Чистая функция: структура → HTML (Фаза 1).
|
||
*
|
||
* Вход: { kind, pct?, text?, count?, elapsed? } — ни одного HTML-тега.
|
||
* kind = 'uploading' | 'uploaded' | 'unzipping' | 'parsing' | 'parsed' | 'error' | '' | 'connecting'
|
||
* Выход: безопасная HTML-строка (статусы не содержат пользовательских данных).
|
||
*
|
||
* ПАТТЕРН (decoupling-final-plan.md): отделяем данные от представления.
|
||
* state хранит чистые данные, statusToHTML — чистая функция рендеринга.
|
||
*/
|
||
function statusToHTML(st) {
|
||
if (!st || !st.kind) return '';
|
||
switch (st.kind) {
|
||
case 'connecting': return '⏳ соединение... ' + (st.elapsed || 0) + 'с';
|
||
case 'uploading': return '⏳ отправка... ' + (st.elapsed || 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">✗ ' + escHtml(st.text || 'Неизвестная ошибка') + '</span>';
|
||
case 'duplicate': return '<span style="color:var(--muted);">⤿ дубликат</span>';
|
||
case 'warning': return '<span style="color:#eab308;">⚠ ' + escHtml(st.text || 'пустой') + '</span>';
|
||
case 'no_format': return '<span class="status-err">✗ формат не поддерживается</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">Нет файлов — выберите .doc / .docx / .pdf</td></tr>';
|
||
lucide.createIcons();
|
||
return;
|
||
}
|
||
|
||
// Сгруппировать файлы по zip_source
|
||
var groups = []; // [{zip: "name.zip"|null, files: [f, ...]}]
|
||
var seen = {};
|
||
for (var i = 0; i < state.files.length; i++) {
|
||
var f = state.files[i];
|
||
var zip = f.zip_source || null;
|
||
var key = zip || '__naked__';
|
||
if (!seen[key]) {
|
||
seen[key] = { zip: zip, files: [] };
|
||
groups.push(seen[key]);
|
||
}
|
||
// Сохраняем оригинальный индекс для id строк
|
||
f._idx = i;
|
||
seen[key].files.push(f);
|
||
}
|
||
|
||
// Рендер: заголовок ZIP → файлы с отступом
|
||
var rows = [];
|
||
for (var gi = 0; gi < groups.length; gi++) {
|
||
var g = groups[gi];
|
||
|
||
if (g.zip) {
|
||
// Заголовок-секция ZIP
|
||
rows.push(
|
||
'<tr class="zip-header" style="background:var(--brand-grey-light);">' +
|
||
'<td colspan="7" style="padding:6px 12px;font-size:12px;font-weight:600;">' +
|
||
'📦 ' + escHtml(g.zip) + ' — ' + g.files.length + ' файл.' +
|
||
(g.files.length === 1 ? '' : 'ов') +
|
||
'</td></tr>'
|
||
);
|
||
}
|
||
|
||
// Файлы внутри группы (с отступом если из ZIP)
|
||
for (var fi = 0; fi < g.files.length; fi++) {
|
||
var f = g.files[fi];
|
||
var i = f._idx;
|
||
var indent = g.zip ? 'padding-left:24px;' : '';
|
||
|
||
rows.push(
|
||
'<tr id="row_' + i + '">' +
|
||
'<td class="name-cell" style="cursor:pointer;' + indent + '" 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>'
|
||
);
|
||
}
|
||
}
|
||
|
||
fileTable.innerHTML = 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 — fetch-загрузка с честным счётчиком времени.
|
||
*
|
||
* v2.0.3: вместо фейкового ↑N% — честный счётчик ⏳ соединение... Nс → ⏳ отправка... Nс.
|
||
* fetch() не даёт реальный upload progress, поэтому считаем секунды.
|
||
* Кэш-бастинг: ?_=Date.now()
|
||
*/
|
||
function uploadFile(file, onProgress, zipSource) {
|
||
var startTime = Date.now();
|
||
var phase = 'connecting'; // connecting → uploading
|
||
if (onProgress) onProgress({ kind: 'connecting', elapsed: 0 });
|
||
var fd = new FormData();
|
||
fd.append('files', file, file.name);
|
||
if (state.contractId) fd.append('contract_id', state.contractId);
|
||
fd.append('batch_id', state.batchId);
|
||
if (zipSource) fd.append('zip_source', zipSource);
|
||
|
||
// Честный счётчик: каждую секунду обновляем elapsed
|
||
var timer = setInterval(function() {
|
||
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||
if (onProgress) onProgress({ kind: phase, elapsed: elapsed });
|
||
}, 1000);
|
||
|
||
return fetch(UPLOAD_URL + '?_=' + Date.now(), { method: 'POST', body: fd })
|
||
.then(function(r) {
|
||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||
return r.json();
|
||
})
|
||
.then(function(data) {
|
||
clearInterval(timer);
|
||
if (data.ok) {
|
||
var elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||
if (onProgress) onProgress({ kind: 'uploading', elapsed: elapsed });
|
||
return data;
|
||
}
|
||
throw new Error(data.error || 'Неизвестная ошибка');
|
||
})
|
||
.catch(function(e) {
|
||
clearInterval(timer);
|
||
if (e.message === 'Failed to fetch' || e.name === 'TypeError') {
|
||
throw new Error('Сеть');
|
||
}
|
||
throw e;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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); }
|
||
}
|
||
|
||
/**
|
||
* reconcileSelection(files, newFiles) — Чистая функция (Фаза 1).
|
||
*
|
||
* Согласование состава: оставляет в массиве files только те,
|
||
* имена которых есть в newFiles. Остальные удаляются.
|
||
*
|
||
* Вход: files — текущий state.files (массив)
|
||
* newFiles — FileList от <input> (массив File-объектов)
|
||
* Выход: новый массив (НЕ мутирует входной)
|
||
*
|
||
* Вызывается из onFilesSelected() ПЕРЕД обработкой каждого файла.
|
||
*/
|
||
function reconcileSelection(files, newFiles) {
|
||
var newNames = new Set(newFiles.map(function(f) { return f.name; }));
|
||
return files.filter(function(f) {
|
||
return newNames.has(f.name);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* applyParseResult(entry, parsed, elapsed) — Чистая функция (Фаза 1).
|
||
*
|
||
* Применяет результат парсинга к файлу. Унифицирует две ветки (ZIP и обычную),
|
||
* которые раньше дублировали этот код.
|
||
*
|
||
* Вход: entry — элемент state.files (мутируется)
|
||
* parsed — { status, element_count, error } от бэкенда
|
||
* elapsed — время парсинга в секундах (только для обычных файлов)
|
||
* Выход: нет (мутирует entry на месте)
|
||
*/
|
||
function applyParseResult(entry, parsed, elapsed) {
|
||
if (parsed && parsed.status === 'parsed') {
|
||
entry.parsed = true;
|
||
entry.parseInfo = parsed;
|
||
entry.status = { kind: 'parsed', count: parsed.element_count };
|
||
if (elapsed) entry.status.elapsed = elapsed;
|
||
} else if (parsed && parsed.status === 'error') {
|
||
entry.parseInfo = parsed;
|
||
entry.status = { kind: 'error', text: parsed.error || 'ошибка парсинга' };
|
||
} else {
|
||
entry.status = { kind: 'error', text: 'Неизвестная ошибка' };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* addZipFile(file) — Async-action: обработка ZIP-файла (Фаза 1, упрощена).
|
||
*
|
||
* 1. Отправляет ZIP на бэкенд (только распаковка, без БД/парсинга)
|
||
* 2. Для каждого файла из архива: base64 → Blob → File → addRegularFile()
|
||
*
|
||
* addRegularFile делает ВСЁ: upload, parse, дубликаты, статусы, render.
|
||
* Никакого дублирования логики — единый путь для обычных и ZIP-файлов.
|
||
*/
|
||
async function addZipFile(file) {
|
||
var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } };
|
||
state.files.push(zipEntry);
|
||
render(state);
|
||
|
||
try {
|
||
// Шаг 1: распаковать ZIP на бэкенде
|
||
var zipResp = await new Promise(function(resolve, reject) {
|
||
var xhr = new XMLHttpRequest();
|
||
xhr.open('POST', UNZIP_URL);
|
||
xhr.responseType = 'json';
|
||
xhr.onload = function() { resolve(xhr.response); };
|
||
xhr.onerror = function() { reject(new Error('Сеть')); };
|
||
xhr.ontimeout = function() { reject(new Error('Таймаут')); };
|
||
xhr.timeout = 60000;
|
||
var fd = new FormData();
|
||
fd.append('files', file);
|
||
xhr.send(fd);
|
||
});
|
||
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
|
||
|
||
// Подтверждение: показать первые 10 файлов + итог
|
||
var preview = zipResp.files.slice(0, 10).map(function(f) { return ' • ' + f.filename + ' (' + (f.size/1024).toFixed(1) + ' KB)'; }).join('\n');
|
||
if (total > 10) preview += '\n ... и ещё ' + (total - 10) + ' файлов';
|
||
if (!confirm('Архив «' + file.name + '» — ' + total + ' файлов:\n\n' + preview + '\n\nЗагрузить эти файлы?')) {
|
||
// Отмена — убрать строку ZIP
|
||
var zipPos2 = state.files.indexOf(zipEntry);
|
||
if (zipPos2 >= 0) state.files.splice(zipPos2, 1);
|
||
render(state);
|
||
return;
|
||
}
|
||
|
||
// Шаг 2: обработать каждый файл из архива
|
||
// Временная строка ZIP остаётся в таблице — обновляем её статус
|
||
var total = zipResp.files.length;
|
||
for (var zi = 0; zi < total; zi++) {
|
||
var zf = zipResp.files[zi];
|
||
zipEntry.status = { kind: 'unzipping' }; // обновляем статус
|
||
render(state);
|
||
|
||
if (zf.error) continue;
|
||
|
||
// base64 → File → addRegularFile (единый путь)
|
||
var byteStr = atob(zf.data_b64);
|
||
var bytes = new Uint8Array(byteStr.length);
|
||
for (var b = 0; b < byteStr.length; b++) bytes[b] = byteStr.charCodeAt(b);
|
||
var mime = {docx:'application/vnd.openxmlformats-officedocument.wordprocessingml.document',doc:'application/msword',pdf:'application/pdf'}[zf.ext] || 'application/octet-stream';
|
||
var extractedFile = new File([bytes], zf.filename, { type: mime, lastModified: Date.now() });
|
||
|
||
await addRegularFile(extractedFile, file.name);
|
||
}
|
||
|
||
// Шаг 3: убрать временную строку ZIP (только после успешной обработки всех файлов)
|
||
var zipPos = state.files.indexOf(zipEntry);
|
||
if (zipPos >= 0) state.files.splice(zipPos, 1);
|
||
render(state);
|
||
|
||
} catch(ze) {
|
||
// Ошибка — показать на строке ZIP (zipEntry всё ещё в state.files)
|
||
zipEntry.status = { kind: 'error', text: 'ZIP: ' + ze.message };
|
||
render(state);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* addRegularFile(file) — Async-action: обработка обычного файла (Фаза 1).
|
||
*
|
||
* 1. Добавляет/заменяет файл в state.files
|
||
* 2. Загружает через XHR (uploadFile) с индикатором прогресса
|
||
* 3. После загрузки: проставляет doc_id, contractId
|
||
* 4. applyParseResult — применяет результат парсинга
|
||
*
|
||
* Мутирует state.files, вызывает render(state) после каждого изменения.
|
||
*/
|
||
async function addRegularFile(file, zipSource) {
|
||
// Создать запись с начальным статусом
|
||
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'connecting', elapsed: 0 }, zip_source: zipSource || null };
|
||
|
||
// ДЕДУПЛИКАЦИЯ: ключ = (zip_source, name), чтобы одноимённые файлы из разных ZIP не затирались
|
||
var dupKey = (zipSource || '') + '/' + file.name;
|
||
var existingIdx = -1;
|
||
for (var j = 0; j < state.files.length; j++) {
|
||
var ejKey = (state.files[j].zip_source || '') + '/' + state.files[j].name;
|
||
if (ejKey === dupKey) { existingIdx = j; break; }
|
||
}
|
||
var rowIdx;
|
||
if (existingIdx >= 0) {
|
||
// Файл с таким именем уже есть — спросить пользователя
|
||
if (!confirm('Файл «' + file.name + '» уже есть в списке.\n\nOK — перезаписать\nОтмена — пропустить')) {
|
||
return; // пользователь выбрал «пропустить»
|
||
}
|
||
state.files[existingIdx] = entry;
|
||
rowIdx = existingIdx;
|
||
} else {
|
||
state.files.push(entry);
|
||
rowIdx = state.files.length - 1;
|
||
}
|
||
render(state);
|
||
|
||
try {
|
||
// fetch-загрузка с честным счётчиком времени
|
||
var resp = await uploadFile(file, function(st) {
|
||
state.files[rowIdx].status = st;
|
||
render(state);
|
||
}, zipSource);
|
||
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
|
||
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
||
state.files[rowIdx].doc_id = resp.doc_id;
|
||
|
||
// Дубликат?
|
||
if (resp && resp.duplicate_of) {
|
||
state.files[rowIdx].status = { kind: 'duplicate' };
|
||
render(state);
|
||
return;
|
||
}
|
||
// Warning от парсинга?
|
||
if (resp && resp.warning) {
|
||
state.files[rowIdx].status = { kind: 'warning', text: resp.warning };
|
||
render(state);
|
||
} else {
|
||
state.files[rowIdx].status = { kind: 'uploaded' };
|
||
}
|
||
state.files[rowIdx].uploaded = true;
|
||
|
||
// Авто-парсинг: бэкенд парсит всё (PDF + DOCX + DOC)
|
||
var t0 = Date.now();
|
||
var pr = resp.parsed;
|
||
var elapsed = pr && pr.status === 'parsed' ? ((Date.now() - t0) / 1000).toFixed(1) : null;
|
||
applyParseResult(state.files[rowIdx], pr, elapsed);
|
||
} catch(err) {
|
||
state.files[rowIdx].status = { kind: 'error', text: err.message };
|
||
}
|
||
render(state);
|
||
}
|
||
|
||
/**
|
||
* finalizeUpload() — Завершение цикла загрузки (Фаза 1).
|
||
*
|
||
* Вызывается ПОСЛЕ обработки всех файлов в onFilesSelected().
|
||
* 1. Обновляет supplement_id/type через refreshSupps()
|
||
* 2. Отмечает шаг «Загрузка» как готовый
|
||
* 3. Активирует шаг «Классификация»
|
||
* 4. Синхронизирует БД
|
||
* 5. Показывает кнопки (LLM, классификация)
|
||
*/
|
||
async function finalizeUpload() {
|
||
await refreshSupps();
|
||
stepDone('stepUpload');
|
||
stepActive('stepClassify');
|
||
syncDB();
|
||
|
||
fileInput.value = '';
|
||
fileInput.disabled = false;
|
||
|
||
var llmBtn = document.getElementById('llmBtn');
|
||
llmBtn.style.display = 'flex';
|
||
llmBtn.disabled = false;
|
||
showClassifyBtn();
|
||
lucide.createIcons();
|
||
}
|
||
|
||
/**
|
||
* ⛔ НЕ МЕНЯТЬ ⛔ onFilesSelected — все строки сразу, потом загрузка.
|
||
*
|
||
* v2.0.2: Фаза 1 — все строки в таблицу. Фаза 2 — загрузка по одной.
|
||
* Юзер видит таблицу целиком, каждая строка обновляется независимо.
|
||
*/
|
||
async function onFilesSelected(newFiles) {
|
||
if (newFiles.length === 0) return;
|
||
|
||
fileInput.disabled = true;
|
||
resetStepper('stepUpload');
|
||
|
||
// Фаза 1: ВСЕ строки в таблицу сразу
|
||
for (var i = 0; i < newFiles.length; i++) {
|
||
var f = newFiles[i];
|
||
if (f.name.toLowerCase().endsWith('.zip')) {
|
||
await addZipFile(f);
|
||
continue;
|
||
}
|
||
state.files.push({ name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'connecting', elapsed: 0 }, zip_source: null });
|
||
state.files[state.files.length - 1]._pendingFile = f;
|
||
}
|
||
render(state);
|
||
|
||
// Фаза 2: загрузка по одному с обновлением строки
|
||
for (var j = 0; j < state.files.length; j++) {
|
||
var entry = state.files[j];
|
||
var pendingFile = entry._pendingFile;
|
||
if (!pendingFile) continue;
|
||
delete entry._pendingFile;
|
||
var f = pendingFile;
|
||
try {
|
||
var resp = await uploadFile(f, function(st) {
|
||
entry.status = st;
|
||
render(state);
|
||
});
|
||
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
||
entry.doc_id = resp.doc_id;
|
||
entry.status = { kind: 'uploaded' };
|
||
entry.uploaded = true;
|
||
var pr = resp.parsed;
|
||
var elapsed = pr && pr.status === 'parsed' ? '0.0' : null;
|
||
applyParseResult(entry, pr, elapsed);
|
||
} catch(err) {
|
||
entry.status = { kind: 'error', text: err.message };
|
||
}
|
||
render(state);
|
||
}
|
||
|
||
await finalizeUpload();
|
||
}
|