v1.0.178: Фаза 1.4 — разбит fileInput handler. Чистые функции: reconcileSelection, applyParseResult. Async-actions: addZipFile, addRegularFile. Оркестратор: onFilesSelected (15 строк). finalizeUpload. app.js fileInput listener: 4 строки.
This commit is contained in:
+4
-147
@@ -71,155 +71,12 @@ function resetStepper(fromId) {
|
|||||||
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
|
// moveUp/moveDown/removeFile/openAbout/closeAbout — вынесены в app_utils.js
|
||||||
|
|
||||||
// ── Автозагрузка при выборе файлов ──────────────────────────
|
// ── Автозагрузка при выборе файлов ──────────────────────────
|
||||||
|
// Фаза 1 (decoupling-final-plan.md): fileInput handler разбит на чистые функции
|
||||||
|
// onFilesSelected → reconcileSelection → addZipFile/addRegularFile → finalizeUpload
|
||||||
|
// Вся логика — в files.js. Здесь только тонкая обёртка.
|
||||||
fileInput.addEventListener('change', async function() {
|
fileInput.addEventListener('change', async function() {
|
||||||
var newFiles = Array.from(fileInput.files);
|
var newFiles = Array.from(fileInput.files);
|
||||||
if (newFiles.length === 0) return;
|
onFilesSelected(newFiles);
|
||||||
|
|
||||||
fileInput.disabled = true;
|
|
||||||
|
|
||||||
// Удалить из очереди файлы, которых нет в новом выборе
|
|
||||||
var newNames = new Set(newFiles.map(function(f) { return f.name; }));
|
|
||||||
state.files = state.files.filter(function(f) {
|
|
||||||
return newNames.has(f.name);
|
|
||||||
});
|
|
||||||
render(state);
|
|
||||||
// Разблокировать кнопку классификации — состав файлов изменился
|
|
||||||
showClassifyBtn();
|
|
||||||
// Сбросить прогресс пайплайна
|
|
||||||
resetStepper('stepUpload');
|
|
||||||
|
|
||||||
for (var i = 0; i < newFiles.length; i++) {
|
|
||||||
var f = newFiles[i];
|
|
||||||
var isZip = f.name.toLowerCase().endsWith('.zip');
|
|
||||||
|
|
||||||
if (isZip) {
|
|
||||||
// ZIP: показать временную строку, распаковать, убрать
|
|
||||||
var zipEntry = { name: f.name, lastModified: f.lastModified, size: f.size, status: { kind: 'unzipping' } };
|
|
||||||
state.files.push(zipEntry);
|
|
||||||
var zipIdx = state.files.length - 1;
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
try {
|
|
||||||
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;
|
|
||||||
xhr.send(f);
|
|
||||||
});
|
|
||||||
var zipData = zipResp;
|
|
||||||
if (!zipData.ok || !zipData.files) throw new Error('unzip failed');
|
|
||||||
|
|
||||||
// Убрать строку ZIP из таблицы
|
|
||||||
state.files.splice(zipIdx, 1);
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
// Добавить файлы из архива, пропуская дубликаты
|
|
||||||
for (var zi = 0; zi < zipData.files.length; zi++) {
|
|
||||||
var zf = zipData.files[zi];
|
|
||||||
if (zf.error) continue; // пропускаем ошибки
|
|
||||||
|
|
||||||
// Проверить дубликат по имени
|
|
||||||
var dup = -1;
|
|
||||||
for (var dj = 0; dj < state.files.length; dj++) {
|
|
||||||
if (state.files[dj].name === zf.filename) { dup = dj; break; }
|
|
||||||
}
|
|
||||||
if (dup >= 0) continue; // уже есть — не добавляем
|
|
||||||
|
|
||||||
var zEntry = {
|
|
||||||
name: zf.filename, size: zf.size,
|
|
||||||
doc_id: zf.doc_id, uploaded: true,
|
|
||||||
status: { kind: 'parsing' }
|
|
||||||
};
|
|
||||||
state.files.push(zEntry);
|
|
||||||
var zIdx = state.files.length - 1;
|
|
||||||
if (!state.contractId && zf.contract_id) state.contractId = zf.contract_id;
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
// Авто-парсинг: VM Python парсит ВСЁ
|
|
||||||
var zpr = zf.parsed;
|
|
||||||
if (zpr && zpr.status === 'parsed') {
|
|
||||||
state.files[zIdx].parsed = true;
|
|
||||||
state.files[zIdx].status = { kind: 'parsed', count: zpr.element_count };
|
|
||||||
} else if (zpr && zpr.status === 'error') {
|
|
||||||
state.files[zIdx].status = { kind: 'error', text: zpr.error || 'ошибка парсинга' };
|
|
||||||
} else {
|
|
||||||
state.files[zIdx].status = { kind: 'error', text: 'Неизвестная ошибка' };
|
|
||||||
}
|
|
||||||
render(state);
|
|
||||||
}
|
|
||||||
} catch(ze) {
|
|
||||||
state.files[zipIdx].status = { kind: 'error', text: 'ZIP: ' + ze.message };
|
|
||||||
render(state);
|
|
||||||
}
|
|
||||||
continue; // ZIP обработан, переходим к следующему файлу
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обычный файл (не ZIP)
|
|
||||||
var entry = { name: f.name, lastModified: f.lastModified, size: f.size, file: f, status: { kind: 'uploading', pct: 0 } };
|
|
||||||
var existingIdx = -1;
|
|
||||||
for (var j = 0; j < state.files.length; j++) {
|
|
||||||
if (state.files[j].name === f.name) { existingIdx = j; break; }
|
|
||||||
}
|
|
||||||
var rowIdx;
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
state.files[existingIdx] = entry;
|
|
||||||
rowIdx = existingIdx;
|
|
||||||
} else {
|
|
||||||
state.files.push(entry);
|
|
||||||
rowIdx = state.files.length - 1;
|
|
||||||
}
|
|
||||||
render(state);
|
|
||||||
|
|
||||||
try {
|
|
||||||
var resp = await uploadFile(f, function(pct) {
|
|
||||||
state.files[rowIdx].status = { kind: 'uploading', pct: pct };
|
|
||||||
render(state);
|
|
||||||
});
|
|
||||||
// Python API returns lowercase keys (doc_id, contract_id, parsed)
|
|
||||||
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
|
||||||
state.files[rowIdx].doc_id = resp.doc_id;
|
|
||||||
state.files[rowIdx].status = { kind: 'uploaded' };
|
|
||||||
state.files[rowIdx].uploaded = true;
|
|
||||||
|
|
||||||
// Авто-парсинг: VM Python парсит ВСЁ (PDF + DOCX + DOC)
|
|
||||||
var t0 = Date.now();
|
|
||||||
var pr = resp.parsed;
|
|
||||||
if (pr && pr.status === 'parsed') {
|
|
||||||
var elapsed = ((Date.now() - t0) / 1000).toFixed(1);
|
|
||||||
state.files[rowIdx].parsed = true;
|
|
||||||
state.files[rowIdx].parseInfo = pr;
|
|
||||||
state.files[rowIdx].status = { kind: 'parsed', count: pr.element_count, elapsed: elapsed };
|
|
||||||
} else if (pr && pr.status === 'error') {
|
|
||||||
state.files[rowIdx].status = { kind: 'error', text: pr.error || 'ошибка парсинга' };
|
|
||||||
state.files[rowIdx].parseInfo = pr;
|
|
||||||
} else {
|
|
||||||
state.files[rowIdx].status = { kind: 'error', text: 'Неизвестная ошибка' };
|
|
||||||
}
|
|
||||||
} catch(err) {
|
|
||||||
state.files[rowIdx].status = { kind: 'error', text: err.message };
|
|
||||||
}
|
|
||||||
render(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshSupps();
|
|
||||||
stepDone('stepUpload');
|
|
||||||
stepActive('stepClassify');
|
|
||||||
|
|
||||||
// Синхронизировать БД с таблицей — удалить всё, чего нет в state.files
|
|
||||||
syncDB();
|
|
||||||
|
|
||||||
fileInput.value = '';
|
|
||||||
fileInput.disabled = false;
|
|
||||||
// Показать кнопки после авто-парсинга
|
|
||||||
var llmBtn = document.getElementById('llmBtn');
|
|
||||||
llmBtn.style.display = 'flex';
|
|
||||||
llmBtn.disabled = false;
|
|
||||||
showClassifyBtn();
|
|
||||||
lucide.createIcons();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Классификация ──────────────────────────────────────────
|
// ── Классификация ──────────────────────────────────────────
|
||||||
|
|||||||
+228
@@ -248,3 +248,231 @@ async function refreshSupps() {
|
|||||||
}
|
}
|
||||||
} catch(e) { console.log('refreshSupps error:', e); }
|
} 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. Показывает временную строку «⏳ распаковка...»
|
||||||
|
* 2. Отправляет ZIP на бэкенд (UNZIP_URL)
|
||||||
|
* 3. Убирает временную строку
|
||||||
|
* 4. Добавляет файлы из архива в state.files, пропуская дубликаты
|
||||||
|
* 5. Для каждого: applyParseResult (результат парсинга от бэкенда)
|
||||||
|
*
|
||||||
|
* Мутирует state.files, вызывает render(state) после каждого изменения.
|
||||||
|
*/
|
||||||
|
async function addZipFile(file) {
|
||||||
|
// Показать временную строку для ZIP
|
||||||
|
var zipEntry = { name: file.name, lastModified: file.lastModified, size: file.size, status: { kind: 'unzipping' } };
|
||||||
|
state.files.push(zipEntry);
|
||||||
|
var zipIdx = state.files.length - 1;
|
||||||
|
render(state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Отправить 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;
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
|
if (!zipResp.ok || !zipResp.files) throw new Error('unzip failed');
|
||||||
|
|
||||||
|
// Убрать временную строку ZIP
|
||||||
|
state.files.splice(zipIdx, 1);
|
||||||
|
render(state);
|
||||||
|
|
||||||
|
// Добавить файлы из архива, пропуская дубликаты по имени
|
||||||
|
for (var zi = 0; zi < zipResp.files.length; zi++) {
|
||||||
|
var zf = zipResp.files[zi];
|
||||||
|
if (zf.error) continue; // файл с ошибкой — пропускаем
|
||||||
|
|
||||||
|
// Проверить дубликат по имени (защита от повторной загрузки того же архива)
|
||||||
|
var dup = -1;
|
||||||
|
for (var dj = 0; dj < state.files.length; dj++) {
|
||||||
|
if (state.files[dj].name === zf.filename) { dup = dj; break; }
|
||||||
|
}
|
||||||
|
if (dup >= 0) continue; // уже есть в очереди — не добавляем
|
||||||
|
|
||||||
|
var zEntry = {
|
||||||
|
name: zf.filename, size: zf.size,
|
||||||
|
doc_id: zf.doc_id, uploaded: true,
|
||||||
|
status: { kind: 'parsing' }
|
||||||
|
};
|
||||||
|
state.files.push(zEntry);
|
||||||
|
var zIdx = state.files.length - 1;
|
||||||
|
// Если это первый файл — запомнить contract_id
|
||||||
|
if (!state.contractId && zf.contract_id) state.contractId = zf.contract_id;
|
||||||
|
render(state);
|
||||||
|
|
||||||
|
// Применить результат парсинга от бэкенда (clean function)
|
||||||
|
applyParseResult(state.files[zIdx], zf.parsed);
|
||||||
|
render(state);
|
||||||
|
}
|
||||||
|
} catch(ze) {
|
||||||
|
// Ошибка распаковки — показать в строке ZIP
|
||||||
|
state.files[zipIdx].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) {
|
||||||
|
// Создать запись с начальным статусом
|
||||||
|
var entry = { name: file.name, lastModified: file.lastModified, size: file.size, file: file, status: { kind: 'uploading', pct: 0 } };
|
||||||
|
|
||||||
|
// Заменить существующий файл с тем же именем или добавить новый
|
||||||
|
var existingIdx = -1;
|
||||||
|
for (var j = 0; j < state.files.length; j++) {
|
||||||
|
if (state.files[j].name === file.name) { existingIdx = j; break; }
|
||||||
|
}
|
||||||
|
var rowIdx;
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
state.files[existingIdx] = entry;
|
||||||
|
rowIdx = existingIdx;
|
||||||
|
} else {
|
||||||
|
state.files.push(entry);
|
||||||
|
rowIdx = state.files.length - 1;
|
||||||
|
}
|
||||||
|
render(state);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// XHR-загрузка с прогрессом
|
||||||
|
var resp = await uploadFile(file, function(pct) {
|
||||||
|
state.files[rowIdx].status = { kind: 'uploading', pct: pct };
|
||||||
|
render(state);
|
||||||
|
});
|
||||||
|
// Бэкенд возвращает doc_id и contract_id (нижний регистр — Python keys)
|
||||||
|
if (resp && resp.contract_id) state.contractId = resp.contract_id;
|
||||||
|
state.files[rowIdx].doc_id = resp.doc_id;
|
||||||
|
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(newFiles) — Оркестратор загрузки (Фаза 1).
|
||||||
|
*
|
||||||
|
* ПАТТЕРН (decoupling-final-plan.md):
|
||||||
|
* 1. reconcileSelection — согласовать состав (чистая мутация)
|
||||||
|
* 2. Для каждого файла: addZipFile (ZIP) или addRegularFile (обычный)
|
||||||
|
* 3. finalizeUpload — завершить цикл
|
||||||
|
*
|
||||||
|
* Это ТОНКАЯ функция (~15 строк) — вся логика в шагах 1-3.
|
||||||
|
* Вызывается из fileInput.addEventListener('change', ...).
|
||||||
|
*/
|
||||||
|
async function onFilesSelected(newFiles) {
|
||||||
|
if (newFiles.length === 0) return;
|
||||||
|
|
||||||
|
fileInput.disabled = true;
|
||||||
|
|
||||||
|
// Шаг 1: согласовать состав — убрать файлы, которых нет в новом выборе
|
||||||
|
state.files = reconcileSelection(state.files, newFiles);
|
||||||
|
render(state);
|
||||||
|
showClassifyBtn();
|
||||||
|
resetStepper('stepUpload');
|
||||||
|
|
||||||
|
// Шаг 2: обработать каждый файл
|
||||||
|
for (var i = 0; i < newFiles.length; i++) {
|
||||||
|
var f = newFiles[i];
|
||||||
|
if (f.name.toLowerCase().endsWith('.zip')) {
|
||||||
|
await addZipFile(f);
|
||||||
|
} else {
|
||||||
|
await addRegularFile(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 3: финализация
|
||||||
|
await finalizeUpload();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user